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 = config.custom_disk_policy;
84        let custom_cache_dir = config.custom_cache_dir;
85
86        let persistent_dir = ctx.persistent_dir();
87
88        let azcopy_bin = ctx.reqv(flowey_lib_common::download_azcopy::Request::GetAzCopy);
89
90        let (files_to_download, write_files_to_download) = ctx.new_var::<Vec<(String, u64)>>();
91        let (output_folder, write_output_folder) = ctx.new_var();
92
93        ctx.emit_rust_step("calculating required VMM tests disk images", |ctx| {
94            let persistent_dir = persistent_dir.clone().claim(ctx);
95            let test_artifacts = test_artifacts.into_iter().collect::<Vec<_>>();
96            let write_files_to_download = write_files_to_download.claim(ctx);
97            let write_output_folder = write_output_folder.claim(ctx);
98            move |rt| {
99                let output_folder = if let Some(dir) = custom_cache_dir {
100                    dir
101                } else if let Some(dir) = persistent_dir {
102                    rt.read(dir)
103                } else {
104                    std::env::current_dir()?
105                };
106
107                rt.write(write_output_folder, &output_folder.absolute()?);
108
109                //
110                // Check for VHDs that have already been downloaded, to see if
111                // we can skip invoking azure-cli and `azcopy` entirely.
112                //
113                let mut skip_artifacts = BTreeSet::new();
114                let mut unexpected_artifacts = BTreeSet::new();
115
116                for e in fs_err::read_dir(&output_folder)? {
117                    let e = e?;
118                    if e.file_type()?.is_dir() {
119                        continue;
120                    }
121                    let filename = e.file_name();
122                    let Some(filename) = filename.to_str() else {
123                        continue;
124                    };
125
126                    if let Some(vhd) = KnownTestArtifacts::from_filename(filename) {
127                        let size = e.metadata()?.len();
128                        let expected_size = vhd.file_size();
129                        if size != expected_size {
130                            log::warn!(
131                                "unexpected size for {}: expected {}, found {}",
132                                filename,
133                                expected_size,
134                                size
135                            );
136                            unexpected_artifacts.insert(vhd);
137                        } else {
138                            skip_artifacts.insert(vhd);
139                        }
140                    } else {
141                        continue;
142                    }
143                }
144
145                if !unexpected_artifacts.is_empty() {
146                    if custom_disk_policy.is_none() && matches!(rt.backend(), FlowBackend::Local) {
147                        log::warn!(
148                            r#"
149================================================================================
150Detected inconsistencies between expected and cached VMM test images.
151
152  If you are trying to use the same disks used in CI, then this is not expected,
153  and your cached disks are corrupt / out-of-date and need to be re-downloaded.
154  Please set the `custom_disk_policy` config to `CustomDiskPolicy::Strict`.
155
156  If you manually modified or replaced disks and you would like to keep them,
157  please set the `custom_disk_policy` config to `CustomDiskPolicy::Loose`.
158================================================================================
159"#
160                        );
161                    }
162
163                    match custom_disk_policy {
164                        Some(CustomDiskPolicy::Loose) => {
165                            skip_artifacts.extend(unexpected_artifacts.iter().copied());
166                            unexpected_artifacts.clear();
167                        }
168                        Some(CustomDiskPolicy::Strict) => {
169                            log::warn!("detected inconsistent disks. will re-download them");
170                        }
171                        None => {
172                            anyhow::bail!("detected inconsistent disks in disk cache")
173                        }
174                    }
175                }
176
177                let files_to_download = {
178                    let mut files = Vec::new();
179
180                    for artifact in test_artifacts {
181                        if !skip_artifacts.contains(&artifact)
182                            || unexpected_artifacts.contains(&artifact)
183                        {
184                            files.push((artifact.filename().to_string(), artifact.file_size()));
185                        }
186                    }
187
188                    // for aesthetic reasons
189                    files.sort();
190                    files
191                };
192
193                if !files_to_download.is_empty() {
194                    //
195                    // If running locally, warn the user they're about to download a
196                    // _lot_ of data
197                    //
198                    if matches!(rt.backend(), FlowBackend::Local) {
199                        let output_folder = output_folder.display();
200                        let disk_image_list = files_to_download
201                            .iter()
202                            .map(|(name, size)| format!("  - {name} ({size})"))
203                            .collect::<Vec<_>>()
204                            .join("\n");
205                        let download_size: u64 =
206                            files_to_download.iter().map(|(_, size)| size).sum();
207                        let msg = format!(
208                            r#"
209================================================================================
210In order to run the selected VMM tests, some (possibly large) disk images need
211to be downloaded from Azure blob storage.
212================================================================================
213- The following disk images will be downloaded:
214{disk_image_list}
215
216- Images will be downloaded to: {output_folder}
217- The total download size is: {download_size} bytes
218
219If running locally, you can re-run with `--help` for info on how to:
220- tweak the selected download folder (e.g: download images to an external HDD)
221- skip this warning prompt in the future
222
223If you're OK with starting the download, please press just <enter>.
224Otherwise, press anything else with <enter> to cancel the run.
225================================================================================
226"#
227                        );
228                        log::warn!("{}", msg.trim());
229
230                        // If this is not an interactive terminal, just allow the download to proceed
231                        let is_terminal = std::io::stdin().is_terminal();
232
233                        if !skip_prompt && is_terminal {
234                            // Only display the prompt for 30s before timing out
235                            let result = crossterm::event::poll(std::time::Duration::from_secs(30));
236                            match result {
237                                Ok(true) => {
238                                    if let crossterm::event::Event::Key(key_event) =
239                                        crossterm::event::read().unwrap()
240                                    {
241                                        if key_event.code == crossterm::event::KeyCode::Enter {
242                                            // proceed with download
243                                        } else {
244                                            anyhow::bail!("user cancelled the run");
245                                        }
246                                    } else {
247                                        anyhow::bail!(
248                                            "unexpected event while waiting for user input"
249                                        );
250                                    }
251                                }
252                                Ok(false) => {
253                                    anyhow::bail!("timed out waiting for user input");
254                                }
255                                Err(e) => {
256                                    anyhow::bail!("error while waiting for user input: {e}");
257                                }
258                            }
259                        }
260                    }
261                }
262
263                rt.write(write_files_to_download, &files_to_download);
264                Ok(())
265            }
266        });
267
268        let did_download = ctx.emit_rust_step("downloading VMM test disk images", |ctx| {
269            let azcopy_bin = azcopy_bin.claim(ctx);
270            let files_to_download = files_to_download.claim(ctx);
271            let output_folder = output_folder.clone().claim(ctx);
272            |rt| {
273                let files_to_download = rt.read(files_to_download);
274                let output_folder = rt.read(output_folder);
275                let azcopy_bin = rt.read(azcopy_bin);
276
277                if !files_to_download.is_empty() {
278                    download_blobs_from_azure(
279                        rt,
280                        &azcopy_bin,
281                        None,
282                        files_to_download,
283                        &output_folder,
284                    )?;
285                }
286
287                Ok(())
288            }
289        });
290
291        ctx.emit_minor_rust_step("report downloaded VMM test disk images", |ctx| {
292            did_download.claim(ctx);
293            let output_folder = output_folder.claim(ctx);
294            let get_download_folder = get_download_folder.claim(ctx);
295            |rt| {
296                let output_folder = rt.read(output_folder);
297                for path in get_download_folder {
298                    rt.write(path, &output_folder)
299                }
300            }
301        });
302
303        Ok(())
304    }
305}
306
307#[expect(dead_code)]
308enum AzCopyAuthMethod {
309    /// Pull credentials from the Azure CLI instance running the command.
310    AzureCli,
311    /// Print a link to stdout and require the user to click it to authenticate.
312    Device,
313}
314
315fn download_blobs_from_azure(
316    // pass dummy _rt to ensure no-one accidentally calls this at graph
317    // resolution time
318    rt: &mut RustRuntimeServices<'_>,
319    azcopy_bin: &PathBuf,
320    azcopy_auth_method: Option<AzCopyAuthMethod>,
321    files_to_download: Vec<(String, u64)>,
322    output_folder: &Path,
323) -> anyhow::Result<()> {
324    //
325    // Use azcopy to download the files
326    //
327    let url = format!("https://{STORAGE_ACCOUNT}.blob.core.windows.net/{CONTAINER}/*");
328
329    let include_path = files_to_download
330        .into_iter()
331        .map(|(name, _)| name)
332        .collect::<Vec<_>>()
333        .join(";");
334
335    // Translate the authentication method we're using.
336    let auth_method = azcopy_auth_method.map(|x| match x {
337        AzCopyAuthMethod::AzureCli => "AZCLI",
338        AzCopyAuthMethod::Device => "DEVICE",
339    });
340
341    if let Some(auth_method) = auth_method {
342        rt.sh.set_var("AZCOPY_AUTO_LOGIN_TYPE", auth_method);
343    }
344    // instead of using return codes to signal success/failure,
345    // azcopy forces you to parse execution logs in order to find
346    // specific strings to detect if/how a copy has failed
347    //
348    // thanks azcopy. very cool.
349    //
350    // <https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azcopy-configure#review-the-logs-for-errors>
351    let current_dir = rt.sh.current_dir();
352    rt.sh
353        .set_var("AZCOPY_JOB_PLAN_LOCATION", current_dir.clone());
354    rt.sh.set_var("AZCOPY_LOG_LOCATION", current_dir.clone());
355
356    // setting `--overwrite true` since we do our own pre-download
357    // filtering
358    let result = flowey::shell_cmd!(
359        rt,
360        "{azcopy_bin} copy
361            {url}
362            {output_folder}
363            --include-path {include_path}
364            --overwrite true
365            --skip-version-check
366        "
367    )
368    .run();
369
370    if result.is_err() {
371        flowey::shell_cmd!(
372            rt,
373            "df -h --output=source,fstype,size,used,avail,pcent,target -x tmpfs -x devtmpfs"
374        )
375        .run()?;
376        let dir_contents = rt.sh.read_dir(current_dir)?;
377        for log in dir_contents
378            .iter()
379            .filter(|p| p.extension() == Some("log".as_ref()))
380        {
381            println!("{}:\n{}\n", log.display(), rt.sh.read_file(log)?);
382        }
383        return result.context("failed to download VMM test disk images");
384    }
385
386    Ok(())
387}