Skip to main content

flowey_lib_hvlite/
assemble_openvmm_vendor_release.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Assemble the uploaded OpenVMM vendor archive.
5//!
6//! GitHub provides the source archive for the release tag. This node builds the
7//! additional vendor tarball a packager needs for
8//! `cargo build --locked --offline`.
9
10use flowey::node::prelude::*;
11
12/// The vendored Cargo source replacement config emitted by `cargo vendor`.
13pub const CARGO_CONFIG_FILE: &str = "cargo_config";
14
15/// The `tar` flags that make the vendor archive byte-reproducible.
16///
17/// Shared with the tests so the tested argument list is the released one. The
18/// format is pinned because a vendored Cargo tree has paths past the 100
19/// character ustar limit, and the pax format tar picks under `POSIXLY_CORRECT`
20/// names its extended headers after the archiving process's pid.
21const DETERMINISTIC_TAR_ARGS: &[&str] = &[
22    "--sort=name",
23    "--mtime=@0",
24    "--owner=0",
25    "--group=0",
26    "--numeric-owner",
27    "--mode=u=rwX,go=rX",
28    "--format=gnu",
29];
30
31/// Internal identity stored alongside the assembled assets.
32///
33/// This cannot be a [`VendorReleaseOutput`] field: flowey serializes an
34/// artifact to JSON and copies every string value as a source path, so a
35/// version string would be treated as a file to copy.
36pub(crate) const IDENTITY_FILE: &str = ".openvmm-vendor-identity.json";
37
38/// The private identity of the assembled vendor archive.
39///
40/// Both fields are read out of the tree, so two jobs at the same commit agree.
41#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
42pub struct VendorReleaseIdentity {
43    /// The workspace version, e.g. `0.12.3`.
44    pub version: String,
45    /// The full commit the archive was produced from.
46    pub revision: String,
47}
48
49/// The assembled vendor archive transferred between jobs.
50#[derive(Serialize, Deserialize)]
51pub struct VendorReleaseOutput {
52    /// Directory containing the vendor archive and internal identity metadata.
53    pub assets: PathBuf,
54}
55
56impl Artifact for VendorReleaseOutput {}
57
58/// Read the identity transferred with assembled vendor assets.
59pub fn read_vendor_identity(assets: &Path) -> anyhow::Result<VendorReleaseIdentity> {
60    let path = assets.join(IDENTITY_FILE);
61    let contents =
62        fs_err::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
63    serde_json::from_slice(&contents).with_context(|| format!("failed to parse {}", path.display()))
64}
65
66impl VendorReleaseIdentity {
67    /// The name of the uploaded vendor archive.
68    pub fn archive_name(&self) -> String {
69        format!("openvmm-{}-vendor.tar.gz", self.version)
70    }
71}
72
73/// Resolve the identity of the OpenVMM checkout in the current working
74/// directory.
75pub fn resolve_identity(rt: &mut RustRuntimeServices<'_>) -> anyhow::Result<VendorReleaseIdentity> {
76    let revision = flowey::shell_cmd!(rt, "git rev-parse HEAD")
77        .read()?
78        .trim()
79        .to_owned();
80    let manifest_path = rt.sh.current_dir().absolute()?.join("Cargo.toml");
81    let version = workspace_version(&manifest_path)?;
82
83    Ok(VendorReleaseIdentity { version, revision })
84}
85
86/// Read `[workspace.package] version` out of a workspace manifest.
87fn workspace_version(manifest_path: &Path) -> anyhow::Result<String> {
88    let manifest = fs_err::read_to_string(manifest_path)?
89        .parse::<toml_edit::DocumentMut>()
90        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
91
92    let version = manifest
93        .get("workspace")
94        .and_then(|workspace| workspace.get("package"))
95        .and_then(|package| package.get("version"))
96        .with_context(|| {
97            format!(
98                "{} has no [workspace.package] version",
99                manifest_path.display()
100            )
101        })?
102        .as_str()
103        .context("[workspace.package] version is not a string")?;
104
105    if version.is_empty() || version.contains(['/', '\\', ' ']) {
106        anyhow::bail!("[workspace.package] version is not usable as a name: {version:?}");
107    }
108
109    Ok(version.to_owned())
110}
111
112/// Confirm `cargo vendor` emitted a source replacement pointing at the relative
113/// `vendor` directory.
114///
115/// The archive ships this config next to the tree it describes, so an absolute
116/// path would point a packager at the release machine instead.
117fn validate_vendor_config(cargo_config: &[u8]) -> anyhow::Result<()> {
118    let cargo_config = std::str::from_utf8(cargo_config)
119        .context("cargo vendor emitted a non-UTF-8 source replacement config")?;
120    let cargo_config = cargo_config
121        .parse::<toml_edit::DocumentMut>()
122        .context("failed to parse the source replacement config from cargo vendor")?;
123
124    let directory = cargo_config
125        .get("source")
126        .and_then(|source| source.get("vendored-sources"))
127        .and_then(|vendored| vendored.get("directory"))
128        .and_then(|directory| directory.as_str())
129        .context("cargo vendor did not emit source.vendored-sources.directory")?;
130
131    if directory != "vendor" {
132        anyhow::bail!(
133            "cargo vendor emitted source.vendored-sources.directory = {directory:?}, \
134             expected the relative path \"vendor\""
135        );
136    }
137
138    Ok(())
139}
140
141flowey_request! {
142    pub struct Request {
143        /// The assembled vendor assets.
144        pub release: WriteVar<VendorReleaseOutput>,
145    }
146}
147
148/// The directory the vendor assets are assembled into, relative to the job's
149/// working directory.
150const OUTPUT_DIR: &str = "openvmm-vendor-release";
151
152new_simple_flow_node!(struct Node);
153
154impl SimpleFlowNode for Node {
155    type Request = Request;
156
157    fn imports(ctx: &mut ImportCtx<'_>) {
158        ctx.import::<crate::git_checkout_openvmm_repo::Node>();
159        ctx.import::<flowey_lib_common::install_rust::Node>();
160    }
161
162    fn process_request(request: Self::Request, ctx: &mut NodeCtx<'_>) -> anyhow::Result<()> {
163        let Request { release } = request;
164
165        let openvmm_repo_path = ctx.reqv(crate::git_checkout_openvmm_repo::req::GetRepoDir);
166        let rust_is_installed = ctx.reqv(flowey_lib_common::install_rust::Request::EnsureInstalled);
167        let rust_toolchain = ctx.reqv(flowey_lib_common::install_rust::Request::GetRustupToolchain);
168
169        ctx.emit_rust_step("assemble OpenVMM vendor archive", |ctx| {
170            rust_is_installed.claim(ctx);
171            let openvmm_repo_path = openvmm_repo_path.claim(ctx);
172            let rust_toolchain = rust_toolchain.claim(ctx);
173            let release = release.claim(ctx);
174            move |rt| {
175                let output_dir = std::env::current_dir()?.join(OUTPUT_DIR);
176                let repo_path = rt.read(openvmm_repo_path);
177                let rust_toolchain = rt.read(rust_toolchain);
178
179                // Assets are named after the version, so a stale archive from a
180                // different version would survive reassembly.
181                if output_dir.exists() {
182                    fs_err::remove_dir_all(&output_dir)?;
183                }
184                fs_err::create_dir_all(&output_dir)?;
185
186                rt.sh.change_dir(&repo_path);
187
188                let identity = resolve_identity(rt)?;
189
190                // `cargo vendor` resolves against the checkout's manifests and
191                // lock file, so tracked modifications would make the identity
192                // lie about the bytes in the uploaded archive.
193                let dirty =
194                    flowey::shell_cmd!(rt, "git status --porcelain --untracked-files=no").read()?;
195                if !dirty.trim().is_empty() {
196                    anyhow::bail!(
197                        "refusing to assemble a vendor archive with tracked modifications; \
198                         the archive would not match HEAD.\nmodified:\n{dirty}"
199                    );
200                }
201
202                let stage_dir = output_dir.join("staging");
203                fs_err::create_dir_all(&stage_dir)?;
204
205                let manifest_path = repo_path.join("Cargo.toml");
206                let cargo = if let Some(rust_toolchain) = &rust_toolchain {
207                    flowey::shell_cmd!(rt, "rustup run {rust_toolchain} cargo")
208                } else {
209                    flowey::shell_cmd!(rt, "cargo")
210                };
211
212                let prior_dir = rt.sh.current_dir();
213                rt.sh.change_dir(&stage_dir);
214                let vendor_output = cargo
215                    .args([
216                        "vendor".as_ref(),
217                        "--manifest-path".as_ref(),
218                        manifest_path.as_os_str(),
219                        "--locked".as_ref(),
220                        "--versioned-dirs".as_ref(),
221                        "vendor".as_ref(),
222                    ])
223                    .ignore_status()
224                    .output()?;
225                rt.sh.change_dir(prior_dir);
226
227                if !vendor_output.status.success() {
228                    anyhow::bail!(
229                        "cargo vendor failed with {}.\nstderr:\n{}",
230                        vendor_output.status,
231                        String::from_utf8_lossy(&vendor_output.stderr)
232                    );
233                }
234
235                let cargo_config = vendor_output.stdout;
236                validate_vendor_config(&cargo_config)?;
237
238                fs_err::write(stage_dir.join(CARGO_CONFIG_FILE), &cargo_config)?;
239
240                let vendor_dir = stage_dir.join("vendor");
241                if !vendor_dir.is_dir() {
242                    anyhow::bail!(
243                        "cargo vendor did not produce the expected directory {}",
244                        vendor_dir.display()
245                    );
246                }
247
248                let tar_path = output_dir.join(identity.archive_name()).with_extension("");
249                let cargo_config_file = CARGO_CONFIG_FILE;
250                let tar_args = DETERMINISTIC_TAR_ARGS;
251                rt.sh.change_dir(&stage_dir);
252                flowey::shell_cmd!(
253                    rt,
254                    "tar {tar_args...} -cf {tar_path} vendor {cargo_config_file}"
255                )
256                .run()?;
257                flowey::shell_cmd!(rt, "gzip -n --best -f {tar_path}").run()?;
258
259                rt.sh.change_dir(&output_dir);
260                fs_err::remove_dir_all(&stage_dir)?;
261                fs_err::write(
262                    output_dir.join(IDENTITY_FILE),
263                    serde_json::to_vec(&identity)?,
264                )?;
265
266                rt.write(release, &VendorReleaseOutput { assets: output_dir });
267
268                Ok(())
269            }
270        });
271
272        Ok(())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn identity(version: &str) -> VendorReleaseIdentity {
281        VendorReleaseIdentity {
282            version: version.into(),
283            revision: "0123456789abcdef0123456789abcdef01234567".into(),
284        }
285    }
286
287    #[test]
288    fn asset_names_follow_the_version() {
289        let identity = identity("0.12.3");
290        assert_eq!(identity.archive_name(), "openvmm-0.12.3-vendor.tar.gz");
291    }
292
293    #[test]
294    fn accepts_a_relative_vendor_source_replacement() {
295        // Formatting varies across Cargo versions, so the check must be
296        // structural rather than textual.
297        let config = b"[source.crates-io]\nreplace-with = 'vendored-sources'\n\n\
298            [source.vendored-sources]\ndirectory   =   \"vendor\"\n";
299        validate_vendor_config(config).unwrap();
300    }
301
302    #[test]
303    fn rejects_an_absolute_or_missing_vendor_directory() {
304        // An absolute path would point back at the release machine.
305        let absolute = b"[source.vendored-sources]\ndirectory = \"/build/stage/vendor\"\n";
306        assert!(validate_vendor_config(absolute).is_err());
307
308        let missing = b"[source.crates-io]\nreplace-with = \"vendored-sources\"\n";
309        assert!(validate_vendor_config(missing).is_err());
310
311        assert!(validate_vendor_config(b"not = = toml").is_err());
312        assert!(validate_vendor_config(&[0xff, 0xfe]).is_err());
313    }
314
315    #[test]
316    fn reads_the_workspace_version() {
317        let dir = tempfile::tempdir().unwrap();
318        let manifest = dir.path().join("Cargo.toml");
319
320        fs_err::write(
321            &manifest,
322            "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.12.3-dev\"\n",
323        )
324        .unwrap();
325        assert_eq!(workspace_version(&manifest).unwrap(), "0.12.3-dev");
326
327        fs_err::write(&manifest, "[workspace]\nmembers = []\n").unwrap();
328        assert!(workspace_version(&manifest).is_err());
329
330        fs_err::write(
331            &manifest,
332            "[workspace.package]\nversion = { workspace = true }\n",
333        )
334        .unwrap();
335        assert!(workspace_version(&manifest).is_err());
336
337        fs_err::write(&manifest, "[workspace.package]\nversion = \"0.1.0/x\"\n").unwrap();
338        assert!(workspace_version(&manifest).is_err());
339    }
340
341    #[cfg(target_os = "linux")]
342    mod linux {
343        use super::*;
344        use std::collections::BTreeSet;
345        use std::os::unix::fs::PermissionsExt;
346        use std::process::Command;
347
348        fn run_command(
349            mut command: Command,
350            description: &str,
351        ) -> anyhow::Result<std::process::Output> {
352            let output = command
353                .output()
354                .with_context(|| format!("failed to run {description}"))?;
355            if output.status.success() {
356                return Ok(output);
357            }
358
359            anyhow::bail!(
360                "{description} failed with {}.\nstdout:\n{}\nstderr:\n{}",
361                output.status,
362                String::from_utf8_lossy(&output.stdout),
363                String::from_utf8_lossy(&output.stderr)
364            );
365        }
366
367        fn create_deterministic_vendor_archive(
368            stage_dir: &Path,
369            archive_path: &Path,
370        ) -> anyhow::Result<()> {
371            let tar_path = archive_path.with_extension("");
372
373            let mut tar = Command::new("tar");
374            tar.current_dir(stage_dir)
375                .args(DETERMINISTIC_TAR_ARGS)
376                .arg("-cf")
377                .arg(&tar_path)
378                .args(["vendor", CARGO_CONFIG_FILE]);
379            run_command(tar, "tar")?;
380
381            let mut gzip = Command::new("gzip");
382            gzip.args(["-n", "--best", "-f"]).arg(&tar_path);
383            run_command(gzip, "gzip")?;
384
385            anyhow::ensure!(
386                archive_path.is_file(),
387                "gzip did not produce {}",
388                archive_path.display()
389            );
390            Ok(())
391        }
392
393        fn write_fixture(stage_dir: &Path) {
394            let crate_dir = stage_dir.join("vendor").join("demo-0.1.0");
395            fs_err::create_dir_all(crate_dir.join("bin")).unwrap();
396            fs_err::write(
397                stage_dir.join(CARGO_CONFIG_FILE),
398                b"[source.crates-io]\nreplace-with = \"vendored-sources\"\n[source.vendored-sources]\ndirectory = \"vendor\"\n",
399            )
400            .unwrap();
401            fs_err::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
402            fs_err::write(crate_dir.join("bin").join("tool"), b"#!/bin/sh\nexit 0\n").unwrap();
403            fs_err::set_permissions(
404                crate_dir.join("bin").join("tool"),
405                std::fs::Permissions::from_mode(0o755),
406            )
407            .unwrap();
408
409            // Longer than the 100 character ustar path limit, so the archive
410            // has to carry the name in a format extension. Without this a
411            // fixture cannot detect a format whose extended headers are named
412            // nondeterministically.
413            let long_dir = crate_dir
414                .join("src")
415                .join("a".repeat(60))
416                .join("b".repeat(60));
417            fs_err::create_dir_all(&long_dir).unwrap();
418            fs_err::write(long_dir.join("deeply_nested_source.rs"), b"// vendored\n").unwrap();
419        }
420
421        fn set_timestamp(path: &Path, timestamp: &str) {
422            let mut touch = Command::new("touch");
423            touch.args(["-d", timestamp]).arg(path);
424            run_command(touch, "touch").unwrap();
425        }
426
427        fn list_archive_entries(archive: &Path) -> Vec<String> {
428            let mut tar = Command::new("tar");
429            tar.args(["-tzf"]).arg(archive);
430            let output = run_command(tar, "tar -tzf").unwrap();
431            String::from_utf8(output.stdout)
432                .unwrap()
433                .lines()
434                .map(str::to_owned)
435                .collect()
436        }
437
438        #[test]
439        fn archive_has_the_expected_top_level_layout() {
440            let dir = tempfile::tempdir().unwrap();
441            let stage_dir = dir.path().join("stage");
442            fs_err::create_dir_all(&stage_dir).unwrap();
443            write_fixture(&stage_dir);
444
445            let archive = dir.path().join("openvmm-0.12.3-vendor.tar.gz");
446            create_deterministic_vendor_archive(&stage_dir, &archive).unwrap();
447
448            let entries = list_archive_entries(&archive);
449            let top_level = entries
450                .iter()
451                .map(|entry| {
452                    entry
453                        .trim_end_matches('/')
454                        .split('/')
455                        .next()
456                        .unwrap()
457                        .to_owned()
458                })
459                .collect::<BTreeSet<_>>();
460
461            assert_eq!(
462                top_level,
463                BTreeSet::from(["cargo_config".to_owned(), "vendor".to_owned()])
464            );
465            assert!(entries.iter().any(|entry| entry == CARGO_CONFIG_FILE));
466            assert!(entries.iter().any(|entry| entry == "vendor/"));
467        }
468
469        #[test]
470        fn deterministic_tiny_fixture_tar_output_on_linux() {
471            let dir = tempfile::tempdir().unwrap();
472
473            let first_stage = dir.path().join("stage-a");
474            let second_stage = dir.path().join("stage-b");
475            fs_err::create_dir_all(&first_stage).unwrap();
476            fs_err::create_dir_all(&second_stage).unwrap();
477
478            write_fixture(&first_stage);
479            write_fixture(&second_stage);
480
481            set_timestamp(
482                &first_stage
483                    .join("vendor")
484                    .join("demo-0.1.0")
485                    .join("Cargo.toml"),
486                "2001-02-03 04:05:06 UTC",
487            );
488            set_timestamp(
489                &second_stage
490                    .join("vendor")
491                    .join("demo-0.1.0")
492                    .join("Cargo.toml"),
493                "2011-12-13 14:15:16 UTC",
494            );
495
496            let first_archive = dir.path().join("first.tar.gz");
497            let second_archive = dir.path().join("second.tar.gz");
498            create_deterministic_vendor_archive(&first_stage, &first_archive).unwrap();
499            create_deterministic_vendor_archive(&second_stage, &second_archive).unwrap();
500
501            assert_eq!(
502                fs_err::read(first_archive).unwrap(),
503                fs_err::read(second_archive).unwrap()
504            );
505        }
506    }
507}