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" |