OXYGENHUB

flow deps check: is every crate of a Cargo.lock in the image (closes #101) f3b5b86e23ef

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c8fc4c257e06
When
· 2026-11-03 22:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
f3b5b86e23ef1fa5ed4c34c1d67951fc40cc765648fbb2571232add4eeec4406
Depends on
010b9a79de79 1a50d4dae89c 22007df200f3 50354f576ea6 5cf1e8aefbb6 c3db750ed96e e0918b887850 eb5c4db673e5 eba589f87e4f f98a8f7881e0
Depended on by
0aac9efcbeea
In bookmarks
main
In tags
v0.5.3
Edits
11 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/flow/src/deps.rs +129 −0
1 +
//! `flow deps check` (#101): whether every crate a `Cargo.lock` needs is
2 +
//! vendored in the build image at that version. Jobs run with no network,
3 +
//! so a crate the image lacks fails the run with "no matching package";
4 +
//! this says so before the push, naming the versions the image does have.
5 +
6 +
use std::collections::{BTreeMap, BTreeSet};
7 +
use std::path::Path;
8 +
9 +
/// A crate at a version.
10 +
pub type Crate = (String, String);
11 +
12 +
/// The packages of a `Cargo.lock` that come from somewhere (a registry or
13 +
/// git): the ones `cargo vendor` copies. A workspace's own members have no
14 +
/// `source` and are left out.
15 +
pub fn from_lock(text: &str) -> Result<BTreeSet<Crate>, String> {
16 +
    let lock: toml::Value = text.parse().map_err(|e| format!("not a Cargo.lock: {e}"))?;
17 +
    let mut out = BTreeSet::new();
18 +
    for p in lock.get("package").and_then(|p| p.as_array()).into_iter().flatten() {
19 +
        let (Some(name), Some(version)) = (p.get("name").and_then(|v| v.as_str()), p.get("version").and_then(|v| v.as_str())) else {
20 +
            continue;
21 +
        };
22 +
        if p.get("source").is_some() {
23 +
            out.insert((name.to_string(), version.to_string()));
24 +
        }
25 +
    }
26 +
    Ok(out)
27 +
}
28 +
29 +
/// A crate list as `flow deps list` and the image's `crates.txt` write it:
30 +
/// `NAME VERSION` a line; blank lines and `#` comments are skipped.
31 +
pub fn from_list(text: &str) -> BTreeSet<Crate> {
32 +
    text.lines()
33 +
        .map(str::trim)
34 +
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
35 +
        .filter_map(|l| {
36 +
            let mut w = l.split_whitespace();
37 +
            Some((w.next()?.to_string(), w.next()?.to_string()))
38 +
        })
39 +
        .collect()
40 +
}
41 +
42 +
/// The crates of a `cargo vendor` directory (the image's `/opt/vendor`):
43 +
/// each subdirectory's `Cargo.toml` names its package and version.
44 +
pub fn from_vendor(dir: &Path) -> Result<BTreeSet<Crate>, String> {
45 +
    let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
46 +
    let mut out = BTreeSet::new();
47 +
    for e in entries.flatten() {
48 +
        let manifest = e.path().join("Cargo.toml");
49 +
        let Ok(text) = std::fs::read_to_string(&manifest) else { continue };
50 +
        let Ok(v) = text.parse::<toml::Value>() else { continue };
51 +
        let pkg = v.get("package");
52 +
        let field = |k: &str| pkg.and_then(|p| p.get(k)).and_then(|x| x.as_str()).map(str::to_string);
53 +
        if let (Some(n), Some(ver)) = (field("name"), field("version")) {
54 +
            out.insert((n, ver));
55 +
        }
56 +
    }
57 +
    Ok(out)
58 +
}
59 +
60 +
/// What `need` lacks from `have`, each with the versions of that crate
61 +
/// `have` does hold (empty when it holds none).
62 +
pub fn missing(need: &BTreeSet<Crate>, have: &BTreeSet<Crate>) -> Vec<(Crate, Vec<String>)> {
63 +
    let mut versions: BTreeMap<&str, Vec<String>> = BTreeMap::new();
64 +
    for (n, v) in have {
65 +
        versions.entry(n.as_str()).or_default().push(v.clone());
66 +
    }
67 +
    need.iter().filter(|c| !have.contains(*c)).map(|c| (c.clone(), versions.get(c.0.as_str()).cloned().unwrap_or_default())).collect()
68 +
}
69 +
70 +
/// A crate list in the form [`from_list`] reads.
71 +
pub fn render_list(crates: &BTreeSet<Crate>) -> String {
72 +
    crates.iter().map(|(n, v)| format!("{n} {v}\n")).collect()
73 +
}
74 +
75 +
#[cfg(test)]
76 +
mod tests {
77 +
    use super::*;
78 +
79 +
    const LOCK: &str = r#"
80 +
version = 4
81 +
82 +
[[package]]
83 +
name = "mine"
84 +
version = "0.1.0"
85 +
dependencies = ["rustls"]
86 +
87 +
[[package]]
88 +
name = "rustls"
89 +
version = "0.23.45"
90 +
source = "registry+https://github.com/rust-lang/crates.io-index"
91 +
92 +
[[package]]
93 +
name = "tokio-rustls"
94 +
version = "0.26.5"
95 +
source = "registry+https://github.com/rust-lang/crates.io-index"
96 +
97 +
[[package]]
98 +
name = "deranged"
99 +
version = "0.5.8"
100 +
source = "registry+https://github.com/rust-lang/crates.io-index"
101 +
"#;
102 +
103 +
    #[test]
104 +
    fn names_what_the_image_lacks_and_what_it_has_instead() {
105 +
        let need = from_lock(LOCK).unwrap();
106 +
        assert_eq!(need.len(), 3, "the workspace's own package is not vendored");
107 +
        let have = from_list("# the image\nrustls 0.23.45\ntokio-rustls 0.26.4\n\n");
108 +
        let m = missing(&need, &have);
109 +
        assert_eq!(
110 +
            m,
111 +
            vec![(("deranged".into(), "0.5.8".into()), vec![]), (("tokio-rustls".into(), "0.26.5".into()), vec!["0.26.4".into()]),]
112 +
        );
113 +
        assert!(missing(&need, &need).is_empty());
114 +
        assert_eq!(from_list(&render_list(&need)), need);
115 +
        assert!(from_lock("not = [toml").is_err());
116 +
    }
117 +
118 +
    #[test]
119 +
    fn reads_a_vendor_directory() {
120 +
        let dir = tempfile::tempdir().unwrap();
121 +
        for (d, n, v) in [("rustls-0.23.45", "rustls", "0.23.45"), ("pre-1.0.0-rc.1", "pre", "1.0.0-rc.1")] {
122 +
            std::fs::create_dir(dir.path().join(d)).unwrap();
123 +
            std::fs::write(dir.path().join(d).join("Cargo.toml"), format!("[package]\nname = \"{n}\"\nversion = \"{v}\"\n")).unwrap();
124 +
        }
125 +
        std::fs::create_dir(dir.path().join("junk")).unwrap();
126 +
        let got = from_vendor(dir.path()).unwrap();
127 +
        assert_eq!(got, from_list("rustls 0.23.45\npre 1.0.0-rc.1\n"));
128 +
    }
129 +
}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Added: `flow deps check` (#101).** Jobs have no network, so every crate a `Cargo.lock` needs must be vendored in the build image at that version. `flow deps check [Cargo.lock]` says whether it is, before the push: each missing crate with the versions the image does have and the `cargo update --precise` that pins one, exit 1. It reads the image's crate list (`--list`), a vendor directory (`--vendor`), or the lock files the image is built from (`--image-lock`, repeatable); inside a job, the image's own. `flow deps list` prints the set.
8 +
- **Changed: the build image publishes its crate list.** `images/rust/build.sh` writes `crates.txt` (`NAME VERSION` a line) beside the tarball and at `/opt/crates.txt` in the image.
7 9
- **What a runner is running, for its host (oxygenhub-android#8):** `flow::runner::running()` lists the jobs this runner is running now (`owner/name #run job`), each from just before it starts until it ends, so the Android app can show them on its card and notification.
8 10
- **`flow` builds and runs on FreeBSD (#99):** FreeBSD 15.1 with pkg's Rust 1.98 builds `flow` with the WASM tier (wasmtime runs there); the `noatime` check is Linux's only, as on Haiku. A FreeBSD machine can be a WASM runner (`flow tiers`: wasm available; the microvm and syscall tiers say they are Linux's).
9 11
…
131 unchanged lines
modified crates/flow/src/lib.rs +1 −0
…
4 unchanged lines
5 5
pub mod cache;
6 6
pub mod checkpoint;
7 7
pub mod cpio;
8 +
pub mod deps;
8 9
pub mod egress;
9 10
pub mod facts;
10 11
/// The job-starting code `flow-init` uses in a microVM, shared with the
…
24 unchanged lines
modified crates/flow/src/main.rs +89 −0
…
72 unchanged lines
73 73
}
74 74
75 75
#[derive(Subcommand)]
76 +
enum DepsCmd {
77 +
    /// Whether every crate a Cargo.lock needs is in the image at that
78 +
    /// version; exit 1 naming each missing one and the versions it has
79 +
    Check {
80 +
        /// The lock file (default: Cargo.lock)
81 +
        #[arg(default_value = "Cargo.lock")]
82 +
        lock: PathBuf,
83 +
        #[command(flatten)]
84 +
        image: DepsSource,
85 +
    },
86 +
    /// Print the image's crates as `NAME VERSION` lines (its crates.txt)
87 +
    List {
88 +
        #[command(flatten)]
89 +
        image: DepsSource,
90 +
    },
91 +
}
92 +
93 +
/// Where the image's crate set comes from: its crate list (the crates.txt
94 +
/// published with the image), a vendor directory, or the lock files it is
95 +
/// built from; inside a job, /opt/vendor.
96 +
#[derive(clap::Args)]
97 +
#[group(multiple = false)]
98 +
struct DepsSource {
99 +
    /// The image's crate list (crates.txt, written by images/rust/build.sh)
100 +
    #[arg(long, value_name = "FILE")]
101 +
    list: Option<PathBuf>,
102 +
    /// A cargo vendor directory (default inside a job: /opt/crates.txt, else /opt/vendor)
103 +
    #[arg(long, value_name = "DIR")]
104 +
    vendor: Option<PathBuf>,
105 +
    /// The lock files the image vendors (oxygen's, oxygenhub's,
106 +
    /// oxygenflow's), as images/rust/build.sh does (repeatable)
107 +
    #[arg(long = "image-lock", value_name = "LOCK")]
108 +
    locks: Vec<PathBuf>,
109 +
}
110 +
111 +
impl DepsSource {
112 +
    fn crates(&self) -> Result<BTreeSet<flow::deps::Crate>> {
113 +
        let read = |p: &Path| std::fs::read_to_string(p).with_context(|| p.display().to_string());
114 +
        if let Some(f) = &self.list {
115 +
            return Ok(flow::deps::from_list(&read(f)?));
116 +
        }
117 +
        if !self.locks.is_empty() {
118 +
            let mut all = BTreeSet::new();
119 +
            for l in &self.locks {
120 +
                all.extend(flow::deps::from_lock(&read(l)?).map_err(|e| anyhow::anyhow!("{}: {e}", l.display()))?);
121 +
            }
122 +
            return Ok(all);
123 +
        }
124 +
        // Inside a job: the image's own list, else its vendor directory.
125 +
        if self.vendor.is_none() && Path::new("/opt/crates.txt").is_file() {
126 +
            return Ok(flow::deps::from_list(&read(Path::new("/opt/crates.txt"))?));
127 +
        }
128 +
        let dir = self.vendor.clone().unwrap_or_else(|| PathBuf::from("/opt/vendor"));
129 +
        if self.vendor.is_none() && !dir.is_dir() {
130 +
            bail!("say what the image holds: --list crates.txt (published with the image), --vendor DIR, or --image-lock for each lock file it is built from; inside a job, /opt/vendor is read");
131 +
        }
132 +
        flow::deps::from_vendor(&dir).map_err(|e| anyhow::anyhow!(e))
133 +
    }
134 +
}
135 +
136 +
#[derive(Subcommand)]
76 137
enum Cmd {
77 138
    /// Validate a pipeline and print its jobs and hash
78 139
    Check {
…
2 unchanged lines
81 142
    },
82 143
    /// Print a file's BLAKE3, for pinning in flow.toml
83 144
    Hash { file: PathBuf },
145 +
    /// The crates the build image vendors, for jobs with no network (#101)
146 +
    Deps {
147 +
        #[command(subcommand)]
148 +
        cmd: DepsCmd,
149 +
    },
84 150
    /// Rootfs images for sandboxes
85 151
    Rootfs {
86 152
        #[command(subcommand)]
…
106 unchanged lines
193 259
        }
194 260
        Cmd::Wasm { spec } => std::process::exit(wasm::wasm_main(&spec)),
195 261
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
262 +
        Cmd::Deps { cmd: DepsCmd::List { image } } => {
263 +
            print!("{}", flow::deps::render_list(&image.crates()?));
264 +
            Ok(())
265 +
        }
266 +
        Cmd::Deps { cmd: DepsCmd::Check { lock, image } } => {
267 +
            let text = std::fs::read_to_string(&lock).with_context(|| lock.display().to_string())?;
268 +
            let need = flow::deps::from_lock(&text).map_err(|e| anyhow::anyhow!("{}: {e}", lock.display()))?;
269 +
            let missing = flow::deps::missing(&need, &image.crates()?);
270 +
            if missing.is_empty() {
271 +
                println!("deps: all {} crates of {} are in the image", need.len(), lock.display());
272 +
                return Ok(());
273 +
            }
274 +
            eprintln!("deps: {} of {} crates of {} are not in the image:", missing.len(), need.len(), lock.display());
275 +
            for ((name, version), has) in &missing {
276 +
                let instead = match has.as_slice() {
277 +
                    [] => "the image has no version of it".to_string(),
278 +
                    vs => format!("the image has {}: cargo update -p {name}@{version} --precise {}", vs.join(", "), vs.last().unwrap()),
279 +
                };
280 +
                eprintln!("  {name} {version}  ({instead})");
281 +
            }
282 +
            eprintln!("pin a version the image has, or rebuild the image with this workspace's lock (images/rust/build.sh)");
283 +
            std::process::exit(1);
284 +
        }
196 285
        Cmd::Hash { file } => {
197 286
            println!("{}", home::hash_file(&file)?);
198 287
            Ok(())
…
449 unchanged lines
modified images/rust/Dockerfile +2 −0
…
64 unchanged lines
65 65
# Cargo reads /.cargo/config.toml for anything under /, so /src uses the
66 66
# vendored crates; CARGO_HOME stays the default $HOME/.cargo (/tmp).
67 67
COPY vendor /opt/vendor
68 +
# What it vendors, `NAME VERSION` a line: `flow deps check --list` (#101).
69 +
COPY crates.txt /opt/crates.txt
68 70
COPY cargo-config.toml /.cargo/config.toml
69 71
70 72
# Dependencies prebuilt (backlog F6b2): each workspace's recipe (its
…
21 unchanged lines
modified images/rust/build.sh +8 −2
…
17 unchanged lines
18 18
    --sync "$dev/oxygenhub/Cargo.toml" \
19 19
    --sync "$dev/oxygenflow/Cargo.toml" \
20 20
    "$here/vendor" >/dev/null
21 +
# The image's crates as `NAME VERSION` lines (#101): at /opt/crates.txt in
22 +
# the image and beside the tarball, for `flow deps check --list` to read.
23 +
for m in "$here"/vendor/*/Cargo.toml; do
24 +
    awk -F'"' '/^\[package\]/{p=1;next} /^\[/{p=0} p&&/^name = /{n=$2} p&&/^version = /{v=$2} END{print n" "v}' "$m"
25 +
done | sort > "$here/crates.txt"
21 26
# npm packages of the two extensions, into a cache `npm ci --offline` reads.
22 27
rm -rf "${here:?}/npm-cache"
23 28
for e in oxygen-vscode oxygenhub-vscode; do
…
12 unchanged lines
36 41
# Readable by any user: a job runs as its workspace's owner on the runner
37 42
# host, which differs between runners (oxygen#59: 0640 files from Earth's
38 43
# umask failed plex's dist job).
39 -
chmod -R a+rX "$here/vendor" "$here/npm-cache" "$here/warm"
44 +
chmod -R a+rX "$here/vendor" "$here/npm-cache" "$here/warm" "$here/crates.txt"
40 45
docker build -q -t oxygenflow-build \
41 46
    --build-arg NODE_VERSION=$NODE_VERSION --build-arg NODE_SHA256=$NODE_SHA256 \
42 47
    --build-arg ZIG_VERSION=$ZIG_VERSION --build-arg ZIG_SHA256=$ZIG_SHA256 "$here"
43 48
id=$(docker create oxygenflow-build)
44 49
docker export "$id" -o "$out"
45 50
docker rm "$id" >/dev/null
46 -
rm -rf "${here:?}/vendor" "${here:?}/npm-cache" "${here:?}/warm"
51 +
cp "$here/crates.txt" "${out%.tar}.crates.txt"
52 +
rm -rf "${here:?}/vendor" "${here:?}/npm-cache" "${here:?}/warm" "${here:?}/crates.txt"
47 53
echo "$out"