Skip to main content

flowey_lib_hvlite/
download_openvmm_vmm_tests_artifacts.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Download OpenVMM VMM test artifacts from Azure Blob Storage.
5//!
6//! If persistent storage is available, caches downloaded artifacts locally.
7
8use flowey::node::prelude::*;
9use std::collections::BTreeSet;
10use std::io::IsTerminal;
11use vmm_test_images::CONTAINER;
12use vmm_test_images::KnownTestArtifacts;
13use vmm_test_images::STORAGE_ACCOUNT;
14
15#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
16pub enum CustomDiskPolicy {
17    /// Allow swapping in non-standard disk image variants
18    Loose,
19    /// Deny swapping in non-standard disk image variants, redownloading any
20    /// images that were detected as inconsistent.
21    Strict,
22}
23
24flowey_config! {
25    /// Config for the download_openvmm_vmm_tests_artifacts node.
26    pub struct Config {
27        /// Local only: if true, skips interactive prompt that warns user about
28        /// downloading many gigabytes of disk images.
29        pub skip_prompt: Option<bool>,
30        /// Local only: set policy when detecting a non-standard cached disk image
31        pub custom_disk_policy: Option<CustomDiskPolicy>,
32        /// Specify a custom cache directory. By default, VHDs are cloned
33        /// into a job-local temp directory.
34        pub custom_cache_dir: Option<PathBuf>,
35    }
36}
37
38flowey_request! {
39    pub enum Request {
40        /// Download test artifacts into the download folder
41        Download(Vec<KnownTestArtifacts>),
42        /// Get path to folder containing all downloaded artifacts
43        GetDownloadFolder(WriteVar<PathBuf>),
44    }
45}
46
47new_flow_node_with_config!(struct Node);
48
49impl FlowNodeWithConfig for Node {
50    type Request = Request;
51    type Config = Config;
52
53    fn imports(ctx: &mut ImportCtx<'_>) {
54        ctx.import::<flowey_lib_common::download_azcopy::Node>();
55        ctx.import::<flowey_lib_common::install_azure_cli::Node>();
56    }
57
58    fn emit(
59        config: Config,
60        requests: Vec<Self::Request>,
61        ctx: &mut NodeCtx<'_>,
62    ) -> anyhow::Result<()> {
63        let mut test_artifacts = BTreeSet::<_>::new();
64        let mut get_download_folder = Vec::new();
65
66        for req in requests {
67            match req {
68                Request::Download(v) => v.into_iter().for_each(|v| {
69                    test_artifacts.insert(v);
70                }),
71                Request::GetDownloadFolder(path) => get_download_folder.push(path),
72            }
73        }
74
75        let skip_prompt = if matches!(ctx.backend(), FlowBackend::Local) {
76            config.skip_prompt.unwrap_or(false)
77        } else {
78            if config.skip_prompt.is_some() {
79                anyhow::bail!("set `skip_prompt` config on non-local backend")
80            }
81            true
82        };
83        let custom_disk_policy = match ctx.backend() {
84            FlowBackend::Local => config.custom_disk_policy,
85            // default to strict policy in CI
86            _ => Some(
87                config
88                    .custom_disk_policy
89                    .unwrap_or(CustomDiskPolicy::Strict),
90            ),
91        };
92        let custom_cache_dir = config.custom_cache_dir;
93
94        let persistent_dir = ctx.persistent_dir();
95
96        let azcopy_bin = ctx.reqv(flowey_lib_common::download_azcopy::Request::GetAzCopy);
97
98        let (files_to_download, write_files_to_download) = ctx.new_var::<Vec<(String, u64)>>();
99        let (output_folder, write_output_folder) = ctx.new_var();
100
101        ctx.emit_rust_step("calculating required VMM tests disk images", |ctx| {
102            let persistent_dir = persistent_dir.clone().claim(ctx);
103            let test_artifacts = test_artifacts.into_iter().collect::<Vec<_>>();
104            let write_files_to_download = write_files_to_download.claim(ctx);
105            let write_output_folder = write_output_folder.claim(ctx);
106            move |rt| {
107                let output_folder = if let Some(dir) = custom_cache_dir {
108                    dir
109                } else if let Some(dir) =
110                    std::env::var_os("VMM_TEST_IMAGES").and_then(|v| (!v.is_empty()).then_some(v))
111                {
112                    PathBuf::from(dir)
113                } else if let Some(dir) = persistent_dir {
114                    rt.read(dir)
115                } else {
116                    std::env::current_dir()?
117                };
118
119                if output_folder.exists() && !output_folder.is_dir() {
120                    anyhow::bail!(
121                        "output dir path exists but is not a directory: {}",
122                        output_folder.display()
123                    );
124                }
125
126                fs_err::create_dir_all(&output_folder)?;
127
128                rt.write(write_output_folder, &output_folder.absolute()?);
129
130                //
131                // Check for VHDs that have already been downloaded, to see if
132                // we can skip invoking azure-cli and `azcopy` entirely.
133                //
134                let mut skip_artifacts = BTreeSet::new();
135                let mut unexpected_artifacts = BTreeSet::new();
136
137                for e in fs_err::read_dir(&output_folder)? {
138                    let e = e?;
139                    if e.file_type()?.is_dir() {
140                        continue;
141                    }
142                    let filename = e.file_name();
143                    let Some(filename) = filename.to_str() else {
144                        continue;
145                    };
146
147                    if let Some(vhd) = KnownTestArtifacts::from_filename(filename) {
148                        let size = e.metadata()?.len();
149                        let expected_size = vhd.file_size();
150                        if size != expected_size {
151                            log::warn!(
152                                "unexpected size for {}: expected {}, found {}",
153                                filename,
154                                expected_size,
155                                size
156                            );
157                            unexpected_artifacts.insert(vhd);
158                        } else {
159                            skip_artifacts.insert(vhd);
160                        }
161                    } else {
162                        continue;
163                    }
164                }
165
166                if !unexpected_artifacts.is_empty() {
167                    if custom_disk_policy.is_none() && matches!(rt.backend(), FlowBackend::Local) {
168                        log::warn!(
169                            r#"
170================================================================================
171Detected inconsistencies between expected and cached VMM test images.
172
173  If you are trying to use the same disks used in CI, then this is not expected,
174  and your cached disks are corrupt / out-of-date and need to be re-downloaded.
175  Please set the `custom_disk_policy` config to `CustomDiskPolicy::Strict`.
176
177  If you manually modified or replaced disks and you would like to keep them,
178  please set the `custom_disk_policy` config to `CustomDiskPolicy::Loose`.
179================================================================================
180"#
181                        );
182                    }
183
184                    match custom_disk_policy {
185                        Some(CustomDiskPolicy::Loose) => {
186                            skip_artifacts.extend(unexpected_artifacts.iter().copied());
187                            unexpected_artifacts.clear();
188                        }
189                        Some(CustomDiskPolicy::Strict) => {
190                            log::warn!("detected inconsistent disks. will re-download them");
191                        }
192                        None => {
193                            anyhow::bail!("detected inconsistent disks in disk cache")
194                        }
195                    }
196                }
197
198                let files_to_download = {
199                    let mut files = Vec::new();
200
201                    for artifact in test_artifacts {
202                        if !skip_artifacts.contains(&artifact)
203                            || unexpected_artifacts.contains(&artifact)
204                        {
205                            files.push((artifact.filename().to_string(), artifact.file_size()));
206                        }
207                    }
208
209                    // for aesthetic reasons
210                    files.sort();
211                    files
212                };
213
214                if !files_to_download.is_empty() {
215                    //
216                    // If running locally, warn the user they're about to download a
217                    // _lot_ of data
218                    //
219                    if matches!(rt.backend(), FlowBackend::Local) {
220                        let output_folder = output_folder.display();
221                        let disk_image_list = files_to_download
222                            .iter()
223                            .map(|(name, size)| format!("  - {name} ({size})"))
224                            .collect::<Vec<_>>()
225                            .join("\n");
226                        let download_size: u64 =
227                            files_to_download.iter().map(|(_, size)| size).sum();
228                        let msg = format!(
229                            r#"
230================================================================================
231In order to run the selected VMM tests, some (possibly large) disk images need
232to be downloaded from Azure blob storage.
233================================================================================
234- The following disk images will be downloaded:
235{disk_image_list}
236
237- Images will be downloaded to: {output_folder}
238- The total download size is: {download_size} bytes
239
240If running locally, you can re-run with `--help` for info on how to:
241- tweak the selected download folder (e.g: download images to an external HDD)
242- skip this warning prompt in the future
243
244If you're OK with starting the download, please press just <enter>.
245Otherwise, press anything else with <enter> to cancel the run.
246================================================================================
247"#
248                        );
249                        log::warn!("{}", msg.trim());
250
251                        // If this is not an interactive terminal, just allow the download to proceed
252                        let is_terminal = std::io::stdin().is_terminal();
253
254                        if !skip_prompt && is_terminal {
255                            // Only display the prompt for 30s before timing out
256                            // TODO: fix this on windows (it immediately returns)
257                            let result = crossterm::event::poll(std::time::Duration::from_secs(30));
258                            match result {
259                                Ok(true) => {
260                                    if let crossterm::event::Event::Key(key_event) =
261                                        crossterm::event::read().unwrap()
262                                    {
263                                        if key_event.code == crossterm::event::KeyCode::Enter {
264                                            // proceed with download
265                                        } else {
266                                            anyhow::bail!("user cancelled the run");
267                                        }
268                                    } else {
269                                        anyhow::bail!(
270                                            "unexpected event while waiting for user input"
271                                        );
272                                    }
273                                }
274                                Ok(false) => {
275                                    anyhow::bail!("timed out waiting for user input");
276                                }
277                                Err(e) => {
278                                    anyhow::bail!("error while waiting for user input: {e}");
279                                }
280                            }
281                        }
282                    }
283                }
284
285                rt.write(write_files_to_download, &files_to_download);
286                Ok(())
287            }
288        });
289
290        let did_download = ctx.emit_rust_step("downloading VMM test disk images", |ctx| {
291            let azcopy_bin = azcopy_bin.claim(ctx);
292            let files_to_download = files_to_download.claim(ctx);
293            let output_folder = output_folder.clone().claim(ctx);
294            |rt| {
295                let files_to_download = rt.read(files_to_download);
296                let output_folder = rt.read(output_folder);
297                let azcopy_bin = rt.read(azcopy_bin);
298
299                if !files_to_download.is_empty() {
300                    download_blobs_from_azure(
301                        rt,
302                        &azcopy_bin,
303                        None,
304                        files_to_download,
305                        &output_folder,
306                    )?;
307                }
308
309                Ok(())
310            }
311        });
312
313        ctx.emit_minor_rust_step("report downloaded VMM test disk images", |ctx| {
314            did_download.claim(ctx);
315            let output_folder = output_folder.claim(ctx);
316            let get_download_folder = get_download_folder.claim(ctx);
317            |rt| {
318                let output_folder = rt.read(output_folder);
319                for path in get_download_folder {
320                    rt.write(path, &output_folder)
321                }
322            }
323        });
324
325        Ok(())
326    }
327}
328
329#[expect(dead_code)]
330enum AzCopyAuthMethod {
331    /// Pull credentials from the Azure CLI instance running the command.
332    AzureCli,
333    /// Print a link to stdout and require the user to click it to authenticate.
334    Device,
335}
336
337fn download_blobs_from_azure(
338    // pass dummy _rt to ensure no-one accidentally calls this at graph
339    // resolution time
340    rt: &mut RustRuntimeServices<'_>,
341    azcopy_bin: &PathBuf,
342    azcopy_auth_method: Option<AzCopyAuthMethod>,
343    files_to_download: Vec<(String, u64)>,
344    output_folder: &Path,
345) -> anyhow::Result<()> {
346    //
347    // Use azcopy to download the files
348    //
349    let url = format!("https://{STORAGE_ACCOUNT}.blob.core.windows.net/{CONTAINER}/*");
350
351    let include_path = files_to_download
352        .into_iter()
353        .map(|(name, _)| name)
354        .collect::<Vec<_>>()
355        .join(";");
356
357    // Translate the authentication method we're using.
358    let auth_method = azcopy_auth_method.map(|x| match x {
359        AzCopyAuthMethod::AzureCli => "AZCLI",
360        AzCopyAuthMethod::Device => "DEVICE",
361    });
362
363    if let Some(auth_method) = auth_method {
364        rt.sh.set_var("AZCOPY_AUTO_LOGIN_TYPE", auth_method);
365    }
366    // instead of using return codes to signal success/failure,
367    // azcopy forces you to parse execution logs in order to find
368    // specific strings to detect if/how a copy has failed
369    //
370    // thanks azcopy. very cool.
371    //
372    // <https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-configure#review-the-logs-for-errors>
373    let current_dir = rt.sh.current_dir();
374    rt.sh
375        .set_var("AZCOPY_JOB_PLAN_LOCATION", current_dir.clone());
376    rt.sh.set_var("AZCOPY_LOG_LOCATION", current_dir.clone());
377
378    // setting `--overwrite true` since we do our own pre-download
379    // filtering
380    let result = flowey::shell_cmd!(
381        rt,
382        "{azcopy_bin} copy
383            {url}
384            {output_folder}
385            --include-path {include_path}
386            --overwrite true
387            --skip-version-check
388        "
389    )
390    .run();
391
392    if result.is_err() {
393        flowey::shell_cmd!(
394            rt,
395            "df -h --output=source,fstype,size,used,avail,pcent,target -x tmpfs -x devtmpfs"
396        )
397        .run()?;
398        let dir_contents = rt.sh.read_dir(current_dir)?;
399        for log in dir_contents
400            .iter()
401            .filter(|p| p.extension() == Some("log".as_ref()))
402        {
403            println!("{}:\n{}\n", log.display(), rt.sh.read_file(log)?);
404        }
405        return result.context("failed to download VMM test disk images");
406    }
407
408    Ok(())
409}