diff --git a/Cargo.lock b/Cargo.lock index b65a238..362d0e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -1061,6 +1070,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -5881,6 +5901,7 @@ dependencies = [ "clap", "flate2", "futures", + "indicatif", "indoc", "libc", "libfalcon", @@ -5889,6 +5910,7 @@ dependencies = [ "rack-init-config", "reqwest 0.13.4", "serde_json", + "sha2", "slog", "sprockets-tls-test-utils", "tar", @@ -5897,6 +5919,7 @@ dependencies = [ "voxel-config", "wicketd-commission-client", "wicketd-commission-types-versions", + "zip", ] [[package]] @@ -6604,6 +6627,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/voxel-config/src/config.rs b/voxel-config/src/config.rs index f37a576..9c32867 100644 --- a/voxel-config/src/config.rs +++ b/voxel-config/src/config.rs @@ -228,11 +228,12 @@ pub struct Falcon { #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct SpCfg { - /// Path to the sp-emu binary (illumos) that runs the fleet on the falcon - /// host. Required for --emu. + /// sp-emu binary override for the fleet on the falcon host. Unset, launch + /// takes sp-emu from PATH, else fetches voxel's pinned buildomat build + /// into ~/.cache/voxel. pub emu_bin: Option, - /// Path to the faux-mgs binary. Optional; the operator `sp` commands need - /// it, launch itself does not. + /// faux-mgs binary override; PATH, then the pinned build, like sp-emu. + /// The operator `sp` commands need it, launch itself does not. pub faux_mgs: Option, } diff --git a/voxel/Cargo.toml b/voxel/Cargo.toml index af02929..f9d006e 100644 --- a/voxel/Cargo.toml +++ b/voxel/Cargo.toml @@ -34,7 +34,15 @@ serde_json = "1" # Unpack TUF control-plane composites (GNU tar format; illumos tar rejects it). tar = "0.4" flate2 = "1" +# Read TUF repo zips in process (no unzip on the host). tufaceous writes +# members stored or deflated; flate2's default backend (pulled in above) +# serves the deflate path. +zip = { version = "4.6", default-features = false, features = ["deflate-flate2"] } reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +# Fetch prebuilt binaries (sp-emu, faux-mgs) from buildomat: sha256 verified +# against the published digest, progress bar while streaming. +sha2 = "0.10" +indicatif = "0.18" libc = "0.2" # Sprockets/trust-quorum test keys - generated at launch (replaces a4x2's diff --git a/voxel/src/cpbuild.rs b/voxel/src/cpbuild.rs index caa960e..25b686a 100644 --- a/voxel/src/cpbuild.rs +++ b/voxel/src/cpbuild.rs @@ -754,7 +754,7 @@ fn stage_gz_from_phase2( const OMICRON_RAW_URL: &str = "https://raw.githubusercontent.com/oxidecomputer/omicron"; -const BUILDOMAT_URL: &str = +pub(crate) const BUILDOMAT_URL: &str = "https://buildomat.eng.oxide.computer/public/file/oxidecomputer"; fn raw_cache(voxel_image: &Utf8Path, sha: &str) -> Utf8PathBuf { diff --git a/voxel/src/main.rs b/voxel/src/main.rs index a50b143..dc19e93 100644 --- a/voxel/src/main.rs +++ b/voxel/src/main.rs @@ -54,11 +54,11 @@ mod wicket_setup; )] struct Cli { /// voxel.toml to use (default: ~/.config/voxel/voxel.toml, then /etc/voxel/voxel.toml). - #[arg(long, global = true, env = "VOXEL_CONFIG")] + #[arg(long, global = true, env = "VOXEL_CONFIG", value_parser = abs_path)] config: Option, /// Project root that cargo-bay/ and .falcon/ live under. - #[arg(long, global = true, env = "VOXEL_WORKDIR")] + #[arg(long, global = true, env = "VOXEL_WORKDIR", value_parser = abs_path)] workdir: Option, /// Topology (falcon deployment) name. @@ -70,7 +70,7 @@ struct Cli { dataset: Option, /// Build root for `image create` (default: `$HOME/voxel-builds`). - #[arg(long, global = true)] + #[arg(long, global = true, value_parser = abs_path)] build_root: Option, #[command(subcommand)] @@ -100,7 +100,7 @@ enum Cmd { /// /// For trying a hubris build before it ships. The rack then reports a /// release it is not running, so say so wherever that is claimed. - #[arg(long, value_name = "DIR")] + #[arg(long, value_name = "DIR", value_parser = abs_path)] sp_firmware: Option, }, /// (debug) Print the wicketd RSS config body that `--wicket-setup` would PUT, @@ -108,6 +108,7 @@ enum Cmd { #[command(hide = true)] WicketDryrun { /// Path to a generated config-rss.toml. + #[arg(value_parser = abs_path)] config_rss: Utf8PathBuf, /// Per-rack sled count (the bootstrap slot set). #[arg(default_value_t = 4)] @@ -186,7 +187,12 @@ enum Cmd { reference: Option, /// Use an existing Omicron checkout without fetching or changing it. - #[arg(long, value_name = "PATH", conflicts_with = "reference")] + #[arg( + long, + value_name = "PATH", + conflicts_with = "reference", + value_parser = abs_path + )] source: Option, /// Rack to target (1-based). @@ -226,7 +232,10 @@ enum ConfigCmd { /// Set a dotted scalar key, e.g. `topology.sleds 3`. Set { key: String, value: String }, /// Validate and install a prepared voxel.toml. - Load { file: Utf8PathBuf }, + Load { + #[arg(value_parser = abs_path)] + file: Utf8PathBuf, + }, } #[derive(Subcommand)] @@ -246,18 +255,23 @@ enum ImageCmd { commit: Option, /// Build from an existing omicron checkout/worktree AS-IS (host build, /// for dev): skips clone + checkout so your working-tree edits are built. - #[arg(long)] + #[arg(long, value_parser = abs_path)] src: Option, /// Build the image from this TUF repo's artifacts with no omicron /// compile: zones + corpus byte exact, GZ software from the host OS /// phase 2 payload, switch zone recomposed for softnpu. - #[arg(long, value_name = "REPO_ZIP")] + #[arg(long, value_name = "REPO_ZIP", value_parser = abs_path)] from_tuf: Option, /// With --from-tuf: an omicron-sled-agent package tar built with /// switch-softnpu, staged in place of the phase 2 sled-agent. The /// standard-image binary hardwires scrimlet = tofino ASIC, so softnpu /// scrimlets need this build. - #[arg(long, value_name = "PKG_TAR", requires = "from_tuf")] + #[arg( + long, + value_name = "PKG_TAR", + requires = "from_tuf", + value_parser = abs_path + )] sled_agent: Option, }, /// Export an image bundle to a file for distribution. @@ -268,6 +282,7 @@ enum ImageCmd { /// Image name (e.g. `voxel-cp-a3fee0ec`). name: String, /// Output file (default `.zfs.zst`, or `.raw.xz` with --raw). + #[arg(value_parser = abs_path)] out: Option, /// Portable raw disk image (`dd | xz`) instead of a zfs stream. #[arg(long)] @@ -276,6 +291,7 @@ enum ImageCmd { /// Import an image bundle (`.zfs.zst` or `.raw.xz`) from `image export`. Import { /// File to import (name is derived from it). + #[arg(value_parser = abs_path)] file: Utf8PathBuf, }, /// Remove an image bundle (`zfs destroy /img/`). @@ -348,6 +364,7 @@ enum ImageCmd { #[command(hide = true)] RenderSmf { /// Path to the omicron checkout root. + #[arg(value_parser = abs_path)] omicron_root: Utf8PathBuf, /// Number of gimlet SPs to simulate (sp-sim). #[arg(long, default_value_t = 4)] @@ -487,8 +504,10 @@ enum SpCmd { /// Flash a hubris `.zip` into an sp-emu slot-A flash file (offline). Flash { /// Hubris image archive (e.g. build-gimlet-c-image-default.zip). + #[arg(value_parser = abs_path)] image: Utf8PathBuf, /// Output flash file. + #[arg(value_parser = abs_path)] out: Utf8PathBuf, }, /// Re-flash a live SP (or the shared RoT) and restart its sp-emu service. @@ -500,6 +519,7 @@ enum SpCmd { /// Target: `sidecar` | `gN` | a port | `rot`. target: String, /// Hubris `.zip` (SP) or raw oxide-rot-1 flash image (target `rot`). + #[arg(value_parser = abs_path)] image: Utf8PathBuf, #[arg(long, default_value = "switch0")] switch: String, @@ -603,6 +623,7 @@ enum RepoCmd { /// waiting out TUF replication. Run after the repo upload. Seed { /// The TUF repo zip that was uploaded. + #[arg(value_parser = abs_path)] repo: Utf8PathBuf, }, } @@ -628,9 +649,17 @@ fn load_config(path: &Utf8Path) -> anyhow::Result { Ok(cfg) } -/// Make a path absolute against the current directory. +/// clap parser for path arguments. main chdirs to the workdir before +/// dispatching, so relative paths must be resolved while parsing, against +/// the directory voxel was invoked from. +fn abs_path(s: &str) -> Result { + Ok(absolutize(Utf8PathBuf::from(s))) +} + +/// Make a path absolute against the current directory, dropping `.` +/// components so `./x` reads as `/x`. fn absolutize(p: Utf8PathBuf) -> Utf8PathBuf { - if p.is_absolute() { + let abs = if p.is_absolute() { p } else { let cwd = std::env::current_dir() @@ -638,7 +667,10 @@ fn absolutize(p: Utf8PathBuf) -> Utf8PathBuf { .and_then(|d| Utf8PathBuf::try_from(d).ok()) .unwrap_or_default(); cwd.join(p) - } + }; + abs.components() + .filter(|c| !matches!(c, camino::Utf8Component::CurDir)) + .collect() } /// Discover the `voxel.toml` to use, as an absolute path. Order: explicit diff --git a/voxel/src/repocmd.rs b/voxel/src/repocmd.rs index 12bda9e..f6c7892 100644 --- a/voxel/src/repocmd.rs +++ b/voxel/src/repocmd.rs @@ -215,30 +215,16 @@ fn discover_stores(name: &str, ip: &str) -> Result { Ok(Sled { name: name.to_string(), ip: ip.to_string(), pools, have }) } -/// Stream one zip member into the staging dir under its sha name, -/// returning its size. +/// Copy one repo member into the staging dir under its sha name, returning +/// its size. fn stage_member( repo: &Utf8Path, member: &str, staging: &Utf8Path, sha: &str, ) -> Result { - let mut unzip = Command::new("unzip") - .args(["-p", repo.as_str(), member]) - .stdout(Stdio::piped()) - .spawn() - .context("spawn unzip -p")?; - let mut out = unzip.stdout.take().context("unzip stdout")?; - let dest = staging.join(sha); - let mut file = - fs::File::create(&dest).with_context(|| format!("create {dest}"))?; - let n = std::io::copy(&mut out, &mut file) - .with_context(|| format!("stage {member}"))?; - let status = unzip.wait().context("wait for unzip")?; - if !status.success() { - bail!("unzip -p {member} exited with {status}"); - } - Ok(n) + crate::tufrepo::extract_from(repo, member, &staging.join(sha)) + .with_context(|| format!("stage {member}")) } /// An ssh command with voxel's usual empty-root-password access. diff --git a/voxel/src/sp_cmd.rs b/voxel/src/sp_cmd.rs index 9a6f71a..a3ad894 100644 --- a/voxel/src/sp_cmd.rs +++ b/voxel/src/sp_cmd.rs @@ -135,19 +135,13 @@ fn switch_target( } /// The faux-mgs to drive the fleet with: the copy staged beside the rack's -/// fleet, else `[sp].faux_mgs`. +/// fleet, else `[sp].faux_mgs`, else the pinned buildomat build. fn faux_bin(cfg: &VoxelConfig, rack: usize) -> anyhow::Result { let staged = crate::topo::sp_fleet_dir(rack).join("sp-emu/faux-mgs"); if staged.exists() { return Ok(staged); } - match cfg.sp.faux_mgs.as_deref().map(Utf8PathBuf::from) { - Some(p) if p.exists() => Ok(p), - _ => bail!( - "no faux-mgs for the SP fleet: set [sp].faux_mgs to the faux-mgs \ - binary (management-gateway-service)" - ), - } + crate::sp_host::ensure_faux_mgs(cfg) } /// Run a faux-mgs verb against the rack's host fleet. The fleet runs here, so @@ -496,6 +490,34 @@ fn ipcc_req(bin: &Utf8Path, ctl: &Utf8Path, command: &str) -> IpccReply { IpccReply::Reply(text) } +/// Pack a dump directory for `humility hydrate`: dump.json and the 0x*.bin +/// memory regions at the zip root, written in process. +fn zip_dump(dump_dir: &Utf8Path, dest: &Utf8Path) -> anyhow::Result<()> { + let mut names = vec!["dump.json".to_string()]; + for e in std::fs::read_dir(dump_dir) + .with_context(|| format!("list {dump_dir}"))? + { + let n = e?.file_name().to_string_lossy().into_owned(); + if n.starts_with("0x") && n.ends_with(".bin") { + names.push(n); + } + } + names.sort(); + let file = std::fs::File::create(dest) + .with_context(|| format!("create {dest}"))?; + let mut zip = zip::ZipWriter::new(file); + for n in &names { + zip.start_file(n, zip::write::SimpleFileOptions::default()) + .with_context(|| format!("add {n} to {dest}"))?; + let mut src = std::fs::File::open(dump_dir.join(n)) + .with_context(|| format!("open {dump_dir}/{n}"))?; + std::io::copy(&mut src, &mut zip) + .with_context(|| format!("write {n} into {dest}"))?; + } + zip.finish().with_context(|| format!("finish {dest}"))?; + Ok(()) +} + /// `voxel sp dump [--ringbuf]` - force + decode a crash dump of one live /// emulated SP. sp-emu writes a humility-hydrate RAM snapshot on demand: when /// `/.trigger` appears it dumps RAM (flash comes from the archive) @@ -620,18 +642,7 @@ async fn sp_dump( std::thread::sleep(std::time::Duration::from_millis(500)); waited_ms += 500; } - // humility hydrate reads dump.json + 0x*.bin from the zip root. Local sh, so - // the glob is the shell's own with no nested quoting to survive. - let zipped = std::process::Command::new("sh") - .arg("-c") - .arg(format!("cd {dump_dir} && zip -q dump.zip dump.json 0x*.bin")) - .status() - .with_context(|| format!("zip the dump in {dump_dir}"))?; - if !zipped.success() { - return Err(anyhow!( - "zipping the dump in {dump_dir} failed ({zipped})" - )); - } + zip_dump(&dump_dir, &zip_local)?; let hydrated = dump_dir.join("hydrated.dump"); // humility hydrate refuses to overwrite its `-o` target, so clear a stale @@ -774,35 +785,27 @@ fn field(out: &str, label: &str) -> String { // --- artifact commands (was `Ls`, now `Ready`; flash/build unchanged) ------- -fn present(p: &str) -> bool { - Utf8Path::new(p).exists() -} - -fn show(name: &str, val: Option<&str>) { - match val { - Some(p) => { - println!( - " {name:<14} {p} [{}]", - if present(p) { "present" } else { "MISSING" } - ) +fn ready(cfg: &VoxelConfig) { + println!( + "sp-emu binaries ([sp] overrides, else PATH, else pinned buildomat \ + builds):" + ); + let emu = crate::sp_host::ensure_emu_bin(cfg); + let faux = crate::sp_host::ensure_faux_mgs(cfg); + for (name, r) in [("emu_bin", &emu), ("faux_mgs", &faux)] { + match r { + Ok(p) => println!(" {name:<14} {p}"), + Err(e) => println!(" {name:<14} unavailable ({e:#})"), } - None => println!(" {name:<14} (unset)"), } -} - -fn ready(cfg: &VoxelConfig) { - let sp = &cfg.sp; - println!("sp-emu binaries ([sp] in voxel.toml):"); - show("emu_bin", sp.emu_bin.as_deref()); - show("faux_mgs", sp.faux_mgs.as_deref()); // Firmware is not listed: an image built with --from-tuf carries the // release's own, and --sp-firmware overrides it for one launch. println!( "\n`voxel launch --emu` ready: {}", - if sp.emu_bin.as_deref().map(present).unwrap_or(false) { + if emu.is_ok() { "yes (firmware comes from the image, or --sp-firmware)" } else { - "no - set [sp].emu_bin to the sp-emu binary" + "no - see above; set [sp].emu_bin or put sp-emu on PATH" } ); } @@ -812,14 +815,12 @@ fn flash( image: &Utf8Path, out: &Utf8Path, ) -> anyhow::Result<()> { - let emu_bin = cfg.sp.emu_bin.as_deref().ok_or_else(|| { - anyhow!("[sp].emu_bin is not set (path to the sp-emu binary)") - })?; + let emu_bin = crate::sp_host::ensure_emu_bin(cfg)?; if !image.exists() { return Err(anyhow!("image not found: {}", image)); } eprintln!("[voxel] flashing {} -> {}", image, out); - let status = std::process::Command::new(emu_bin) + let status = std::process::Command::new(&emu_bin) .env("SP_EMU_FLASH", out) .args(["flash", "a"]) .arg(image) @@ -853,3 +854,35 @@ fn build(commit: &str) -> anyhow::Result<()> { fn build_sp_script() -> anyhow::Result { crate::util::locate_script("VOXEL_BUILD_SP", "build-sp.sh") } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + /// zip_dump packs dump.json and the 0x*.bin regions at the zip root and + /// nothing else. + #[test] + fn zip_dump_packs_json_and_regions() { + let dir = crate::util::temp_dir() + .join(format!("voxel-zipdump-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("dump.json"), "{}").unwrap(); + std::fs::write(dir.join("0x20000000.bin"), [1u8; 4096]).unwrap(); + std::fs::write(dir.join("0x24000000.bin"), [2u8; 16]).unwrap(); + std::fs::write(dir.join(".done"), "").unwrap(); + std::fs::write(dir.join("hydrated.dump"), "x").unwrap(); + let dest = dir.join("dump.zip"); + zip_dump(&dir, &dest).unwrap(); + + let mut zip = + zip::ZipArchive::new(std::fs::File::open(&dest).unwrap()).unwrap(); + let names: Vec = zip.file_names().map(str::to_string).collect(); + assert_eq!(names, ["0x20000000.bin", "0x24000000.bin", "dump.json"]); + let mut buf = Vec::new(); + zip.by_name("0x20000000.bin").unwrap().read_to_end(&mut buf).unwrap(); + assert_eq!(buf, vec![1u8; 4096]); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/voxel/src/sp_host.rs b/voxel/src/sp_host.rs index f54abcc..af900c1 100644 --- a/voxel/src/sp_host.rs +++ b/voxel/src/sp_host.rs @@ -14,9 +14,12 @@ //! the staged state all carry the rack index, so tearing one rack down leaves a //! co-resident rack running. -use anyhow::{Context, bail}; +use anyhow::{Context, anyhow, bail}; use camino::Utf8Path; +use indicatif::{ProgressBar, ProgressStyle}; +use std::future::Future; use std::process::{Command, Stdio}; +use std::time::Duration; /// SMF service backing the fleet; one instance per SP per rack. const SVC: &str = "svc:/oxide/voxel-sp-emu"; @@ -194,6 +197,317 @@ pub(crate) fn fleet_dir(rack: usize) -> camino::Utf8PathBuf { } } +/// The repo's pins.toml, embedded so a shipped voxel binary carries its own +/// pins. Each entry names a buildomat-published binary and the rev to fetch. +const PINS: &str = include_str!("../../pins.toml"); + +/// One pins.toml entry. +struct Pin { + repo: String, + series: String, + rev: String, + artifact: String, +} + +/// Look up one entry of the embedded pins.toml. +fn pin(name: &str) -> anyhow::Result { + let doc: toml::Table = PINS.parse().context("parse embedded pins.toml")?; + let entry = doc + .get(name) + .and_then(|v| v.as_table()) + .with_context(|| format!("pins.toml has no [{name}]"))?; + let field = |key: &str| -> anyhow::Result { + entry + .get(key) + .and_then(|v| v.as_str()) + .map(str::to_string) + .with_context(|| format!("pins.toml [{name}] missing {key}")) + }; + let p = Pin { + repo: field("repo")?, + series: field("series")?, + rev: field("rev")?, + artifact: field("artifact")?, + }; + if p.rev.len() != 40 || !p.rev.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("pins.toml [{name}] rev is not a full git sha: {}", p.rev); + } + Ok(p) +} + +/// The sp-emu to run the fleet with: [sp].emu_bin, else sp-emu on PATH, +/// else the pinned buildomat build, fetched once into ~/.cache/voxel. +pub(crate) fn ensure_emu_bin( + cfg: &voxel_config::VoxelConfig, +) -> anyhow::Result { + resolve_bin(cfg.sp.emu_bin.as_deref(), "emu_bin", "sp-emu") +} + +/// The faux-mgs for the operator sp commands: [sp].faux_mgs, else faux-mgs +/// on PATH, else the pinned buildomat build (published gzipped). +pub(crate) fn ensure_faux_mgs( + cfg: &voxel_config::VoxelConfig, +) -> anyhow::Result { + resolve_bin(cfg.sp.faux_mgs.as_deref(), "faux_mgs", "faux-mgs") +} + +/// One fleet binary by precedence: the `[sp].` override, `name` on +/// PATH, then the pinned buildomat build (`name` is also its pins.toml key). +fn resolve_bin( + override_path: Option<&str>, + key: &str, + name: &str, +) -> anyhow::Result { + if let Some(p) = override_path { + let p = camino::Utf8PathBuf::from(p); + if !p.is_file() { + bail!("[sp].{key} does not exist: {p}"); + } + return Ok(p); + } + if let Some(p) = find_in_path(name) { + return Ok(p); + } + fetch_buildomat_bin(&pin(name)?, key).with_context(|| { + format!( + "no {name}: [sp].{key} is unset, none on PATH, and the pinned \ + build could not be fetched" + ) + }) +} + +/// `name` as an executable file on PATH, the way a shell would find it. +fn find_in_path(name: &str) -> Option { + find_in_path_list(&std::env::var_os("PATH")?, name) +} + +fn find_in_path_list( + path: &std::ffi::OsStr, + name: &str, +) -> Option { + use std::os::unix::fs::PermissionsExt; + std::env::split_paths(path) + .filter(|d| !d.as_os_str().is_empty()) + .map(|d| d.join(name)) + .find(|p| { + p.metadata().is_ok_and(|m| { + m.is_file() && m.permissions().mode() & 0o111 != 0 + }) + }) + .and_then(|p| camino::Utf8PathBuf::from_path_buf(p).ok()) +} + +/// Fetch one published buildomat binary into a rev-keyed cache under +/// ~/.cache/voxel and verify it against its .sha256.txt sibling. A .gz +/// artifact is hash-checked as published, then decompressed. The final name +/// appears only once the file is verified and executable, so an interrupted +/// fetch is never taken for a cached binary. +fn fetch_buildomat_bin( + p: &Pin, + key: &str, +) -> anyhow::Result { + use std::os::unix::fs::PermissionsExt; + let Pin { repo, series, rev, artifact } = p; + let home = std::env::var("HOME").context("HOME not set")?; + let bin_name = artifact.strip_suffix(".gz").unwrap_or(artifact); + let dir = camino::Utf8PathBuf::from(home) + .join(".cache/voxel/bins") + .join(format!("{repo}-{}", &rev[..12])); + let bin = dir.join(bin_name); + if bin.exists() { + return Ok(bin); + } + std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {dir}"))?; + let url = format!( + "{}/{repo}/{series}/{rev}/{artifact}", + crate::cpbuild::BUILDOMAT_URL + ); + eprintln!( + "[voxel] fetching {bin_name} @ {} ([sp].{key} or PATH overrides)", + &rev[..12] + ); + let want = fetch_text(&format!("{url}.sha256.txt"), FETCH)?; + let want = want.split_whitespace().next().unwrap_or("").to_string(); + let fetched = dir.join(format!("{artifact}.part")); + let got = download(&url, &fetched, FETCH)?; + if got != want { + let _ = std::fs::remove_file(&fetched); + bail!("{artifact} sha256 {got} != published {want}"); + } + let staged = if artifact.ends_with(".gz") { + let unpacked = dir.join(format!("{bin_name}.part")); + let gz = std::fs::File::open(&fetched) + .with_context(|| format!("open {fetched}"))?; + let mut out = std::fs::File::create(&unpacked) + .with_context(|| format!("create {unpacked}"))?; + std::io::copy(&mut flate2::read::GzDecoder::new(gz), &mut out) + .with_context(|| format!("gunzip {fetched}"))?; + let _ = std::fs::remove_file(&fetched); + unpacked + } else { + fetched + }; + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {staged}"))?; + std::fs::rename(&staged, &bin) + .with_context(|| format!("move {staged} to {bin}"))?; + Ok(bin) +} + +/// Retry and connect bounds for a fetch. +#[derive(Clone, Copy)] +struct Fetch { + attempts: u32, + connect_timeout: Duration, +} + +/// Buildomat fetch bounds: an unreachable host fails in under a minute +/// rather than holding a launch in TCP retries. +const FETCH: Fetch = + Fetch { attempts: 3, connect_timeout: Duration::from_secs(15) }; + +fn http_client(f: Fetch) -> anyhow::Result { + reqwest::Client::builder() + .connect_timeout(f.connect_timeout) + .timeout(Duration::from_secs(3600)) + .build() + .context("http client") +} + +/// Run an async fetch to completion from sync code. The callers already sit +/// on the tokio runtime, so this gets its own thread and runtime instead of +/// blocking the outer one. +fn run_fetch( + fut: impl Future> + Send, +) -> anyhow::Result { + std::thread::scope(|s| { + s.spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("fetch runtime")? + .block_on(fut) + }) + .join() + .map_err(|_| anyhow!("fetch thread panicked"))? + }) +} + +/// GET a small text body, retried. +fn fetch_text(url: &str, f: Fetch) -> anyhow::Result { + run_fetch(async { + let client = http_client(f)?; + let mut last = None; + for attempt in 1..=f.attempts { + let sent = client.get(url).send().await; + match sent.and_then(|r| r.error_for_status()) { + Ok(r) => { + return r + .text() + .await + .with_context(|| format!("read {url}")); + } + Err(e) => last = Some(e), + } + if attempt < f.attempts { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + Err(anyhow!( + "GET {url} failed after {} attempts: {}", + f.attempts, + last.expect("at least one attempt") + )) + }) +} + +/// Stream `url` into `dest` behind a progress bar, returning the body's +/// sha256 hex. Retried whole; a partial `dest` is overwritten next attempt. +fn download(url: &str, dest: &Utf8Path, f: Fetch) -> anyhow::Result { + run_fetch(async { + let client = http_client(f)?; + let mut last = None; + for attempt in 1..=f.attempts { + match stream_to_file(&client, url, dest).await { + Ok(sha) => return Ok(sha), + Err(e) => { + eprintln!( + "[voxel] fetch attempt {attempt}/{} failed: {e:#}", + f.attempts + ); + last = Some(e); + } + } + if attempt < f.attempts { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + Err(last.expect("at least one attempt")).with_context(|| { + format!("GET {url} failed after {} attempts", f.attempts) + }) + }) +} + +async fn stream_to_file( + client: &reqwest::Client, + url: &str, + dest: &Utf8Path, +) -> anyhow::Result { + use sha2::Digest; + use std::io::Write; + let mut resp = client + .get(url) + .send() + .await + .and_then(|r| r.error_for_status()) + .with_context(|| format!("GET {url}"))?; + let pb = match resp.content_length() { + Some(len) => { + let pb = ProgressBar::new(len); + pb.set_style( + ProgressStyle::with_template( + "[{elapsed_precise}] {bar:40.cyan/blue} \ + {bytes}/{total_bytes} {bytes_per_sec}", + ) + .context("progress template")? + .progress_chars("##-"), + ); + pb + } + None => { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::with_template( + "[{elapsed_precise}] {spinner} {bytes} {bytes_per_sec}", + ) + .context("progress template")?, + ); + pb + } + }; + let mut file = std::fs::File::create(dest) + .with_context(|| format!("create {dest}"))?; + let mut hash = sha2::Sha256::new(); + let mut total = 0u64; + while let Some(chunk) = + resp.chunk().await.with_context(|| format!("read {url}"))? + { + file.write_all(&chunk).with_context(|| format!("write {dest}"))?; + hash.update(&chunk); + total += chunk.len() as u64; + pb.inc(chunk.len() as u64); + } + // The bar is transient; leave one durable line behind it. + let secs = pb.elapsed().as_secs_f64(); + pb.finish_and_clear(); + eprintln!( + "[voxel] fetched {} ({} MiB in {secs:.1}s)", + dest.file_name().unwrap_or(dest.as_str()).trim_end_matches(".part"), + total >> 20 + ); + Ok(format!("{:x}", hash.finalize())) +} + /// The sp-emu binary driving a rack's fleet. pub(crate) fn emu_bin(rack: usize) -> anyhow::Result { let bin = fleet_dir(rack).join("sp-emu"); @@ -312,8 +626,8 @@ pub(crate) fn up( let bin = dir.join("sp-emu"); if !bin.exists() { bail!( - "--emu needs an sp-emu binary on the host: set [sp].emu_bin \ - (the fleet runs here now, not in the switch zone)" + "--emu needs an sp-emu binary staged at {bin}: launch stages \ + [sp].emu_bin, sp-emu on PATH, or the pinned buildomat build" ); } // IPv6 refuses a global address on a link with no link-local ("Can't assign @@ -509,3 +823,59 @@ pub(crate) fn down_all(cfg: &voxel_config::VoxelConfig) { down(cfg, rack); } } + +#[cfg(test)] +mod tests { + // Every pins.toml entry must parse and carry a full git sha, so a bad + // pin fails in CI rather than at fetch time on a user's box. + #[test] + fn pins_parse() { + super::pin("sp-emu").unwrap(); + super::pin("faux-mgs").unwrap(); + } + + /// PATH lookup takes the first executable regular file, skipping dirs + /// that lack the name or hold a non-executable one. + #[test] + fn path_lookup_wants_an_executable() { + use std::os::unix::fs::PermissionsExt; + let base = crate::util::temp_dir() + .join(format!("voxel-pathlookup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (a, b, c) = (base.join("a"), base.join("b"), base.join("c")); + for d in [&a, &b, &c] { + std::fs::create_dir_all(d).unwrap(); + } + std::fs::write(b.join("sp-emu"), "#!/bin/sh\n").unwrap(); + std::fs::write(c.join("sp-emu"), "#!/bin/sh\n").unwrap(); + std::fs::set_permissions( + c.join("sp-emu"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let path = std::env::join_paths([&a, &b, &c]).unwrap(); + assert_eq!( + super::find_in_path_list(&path, "sp-emu"), + Some(c.join("sp-emu")) + ); + assert_eq!(super::find_in_path_list(&path, "faux-mgs"), None); + std::fs::remove_dir_all(&base).ok(); + } + + /// A download from an unreachable host fails within the configured + /// bounds instead of hanging in TCP retries. + #[test] + fn download_fails_fast_when_unreachable() { + let dest = crate::util::temp_dir() + .join(format!("voxel-dl-{}.part", std::process::id())); + let f = super::Fetch { + attempts: 2, + connect_timeout: std::time::Duration::from_secs(1), + }; + let t0 = std::time::Instant::now(); + let r = super::download("https://10.255.255.1/nothing", &dest, f); + let _ = std::fs::remove_file(&dest); + assert!(r.is_err()); + assert!(t0.elapsed() < std::time::Duration::from_secs(10)); + } +} diff --git a/voxel/src/topo.rs b/voxel/src/topo.rs index 7e29e51..22a722a 100644 --- a/voxel/src/topo.rs +++ b/voxel/src/topo.rs @@ -678,18 +678,21 @@ fn stage_sp_emu( fs::create_dir_all(&out)?; // The host fleet needs the binary and archives here: there is no baked // in-guest copy to fall back on now that it runs outside the switch zone. - // sp_host reports the missing binary rather than silently starting nothing. - let Some(emu_bin) = cfg.sp.emu_bin.as_deref() else { - return Ok(()); - }; - fs::copy(emu_bin, out.join("sp-emu")) + // [sp].emu_bin overrides; unset fetches the pinned buildomat build. + let emu_bin = crate::sp_host::ensure_emu_bin(cfg)?; + fs::copy(&emu_bin, out.join("sp-emu")) .with_context(|| format!("stage sp-emu binary from {emu_bin}"))?; - // Stage `faux-mgs` (the MGS client) alongside it when configured, so - // `voxel sp ls/state/exec` can talk to the live SPs from inside the switch - // zone. Optional: the operator `sp` commands need it; launch itself doesn't. - if let Some(faux) = cfg.sp.faux_mgs.as_deref() { - fs::copy(faux, out.join("faux-mgs")) - .with_context(|| format!("stage faux-mgs from {faux}"))?; + // Stage `faux-mgs` (the MGS client) alongside it, so `voxel sp + // ls/state/exec` can talk to the live SPs. The operator commands need it, + // launch itself does not, so an unavailable faux-mgs only warns. + match crate::sp_host::ensure_faux_mgs(cfg) { + Ok(faux) => { + fs::copy(&faux, out.join("faux-mgs")) + .with_context(|| format!("stage faux-mgs from {faux}"))?; + } + Err(e) => eprintln!( + "[voxel] faux-mgs unavailable ({e:#}); sp commands will not work" + ), } // Stage the RoT image so each SP can run oxide-rot-1 in-process over sprot // (sp-emu 1.x runs the RoT inside the SP process, not as a separate service). diff --git a/voxel/src/tufrepo.rs b/voxel/src/tufrepo.rs index 51c9c51..19ed875 100644 --- a/voxel/src/tufrepo.rs +++ b/voxel/src/tufrepo.rs @@ -4,22 +4,25 @@ //! Read a TUF repo zip as an image source: the control plane zones, the //! measurement corpus, the host OS phase 2 payload, and the omicron commit -//! the repo was built from. Targets are streamed out with `unzip -p`; only -//! the index is held in memory. Composites are GNU tar format, which illumos -//! tar rejects, so streams are unpacked in process. +//! the repo was built from. Members are streamed out of the zip in process +//! (no unzip binary on the host); only the index is held in memory. +//! Composites are GNU tar format, which illumos tar rejects, so those streams +//! are unpacked in process too. use anyhow::{Context, Result, bail}; use camino::{Utf8Path, Utf8PathBuf}; -use std::fs; +use std::fs::{self, File}; use std::io::{Read, Seek, SeekFrom, Write}; -use std::process::{Child, ChildStdout, Command, Stdio}; +use zip::ZipArchive; +use zip::read::ZipFile; /// `BootImageHeader` magic + fixed size (nexus_sled_agent_shared); the phase 2 /// artifact is this header followed by a raw ZFS pool image. const BOOT_IMAGE_MAGIC: u32 = 0x1deb0075; const BOOT_IMAGE_HEADER_SIZE: usize = 4096; -type MemberArchive = tar::Archive>; +type MemberArchive<'a> = + tar::Archive>>; /// A parsed TUF repo zip (`tufaceous` v1 layout: `repo/targets/.`). pub(crate) struct TufRepoSource { @@ -72,7 +75,9 @@ impl TufRepoSource { if !path.exists() { bail!("TUF repo {path} not found"); } - let members = zip_members(path)?; + let mut zip = open_zip(path)?; + let members: Vec = + zip.file_names().map(str::to_string).collect(); // Prefer the v1 index; every repo that carries v2 carries v1 too. let index = members .iter() @@ -83,7 +88,10 @@ impl TufRepoSource { .with_context(|| { format!("{path} has no artifacts index under repo/targets/") })?; - let raw = zip_read(path, index)?; + let mut raw = Vec::new(); + member(&mut zip, index)? + .read_to_end(&mut raw) + .with_context(|| format!("read {index}"))?; let json: serde_json::Value = serde_json::from_slice(&raw) .with_context(|| format!("parse {index}"))?; let system_version = json @@ -162,7 +170,8 @@ impl TufRepoSource { dir: &Utf8Path, ) -> Result> { fs::create_dir_all(dir).with_context(|| format!("mkdir {dir}"))?; - let (mut child, mut archive) = self.member_tar(&self.control_plane)?; + let mut zip = self.open()?; + let mut archive = member_tar(&mut zip, &self.control_plane)?; let mut names = Vec::new(); for entry in archive.entries().context("read control plane composite entries")? @@ -183,7 +192,6 @@ impl TufRepoSource { .with_context(|| format!("unpack {name} into {dir}"))?; names.push(name); } - wait_ok(&mut child, &self.control_plane)?; if names.is_empty() { bail!("control plane composite in {} carried no zones", self.path); } @@ -199,10 +207,9 @@ impl TufRepoSource { bail!("{} has no measurement_corpus targets", self.path); } fs::create_dir_all(dir).with_context(|| format!("mkdir {dir}"))?; - for (member, target) in &self.corpus { - let bytes = zip_read(&self.path, member)?; - fs::write(dir.join(target), bytes) - .with_context(|| format!("write corpus {target}"))?; + let mut zip = self.open()?; + for (m, target) in &self.corpus { + extract_member(&mut zip, m, &dir.join(target))?; } Ok(self.corpus.len()) } @@ -216,7 +223,8 @@ impl TufRepoSource { boot_image_dest: &Utf8Path, phase1_dest: &Utf8Path, ) -> Result<(u64, u64)> { - let (mut child, mut archive) = self.member_tar(&self.host)?; + let mut zip = self.open()?; + let mut archive = member_tar(&mut zip, &self.host)?; let mut boot_image = None; let mut phase1 = None; for entry in archive.entries().context("read host composite entries")? { @@ -261,10 +269,6 @@ impl TufRepoSource { break; } } - // Entries can follow; drop the reader so unzip sees EPIPE instead of - // blocking on a full pipe under wait(). - drop(archive); - wait_ok(&mut child, &self.host)?; let need = |v: Option, what: &str| { v.with_context(|| { format!("host artifact in {} carries no {what}", self.path) @@ -293,23 +297,24 @@ impl TufRepoSource { }) }; + let mut zip = self.open()?; let gimlet = dir.join(format!("sp-{GIMLET_BOARD}.zip")); - zip_extract(&self.path, find("gimlet_sp", GIMLET_BOARD)?, &gimlet)?; + extract_member(&mut zip, find("gimlet_sp", GIMLET_BOARD)?, &gimlet)?; let sidecar = dir.join(format!("sp-{SIDECAR_BOARD}.zip")); - zip_extract(&self.path, find("switch_sp", SIDECAR_BOARD)?, &sidecar)?; + extract_member(&mut zip, find("switch_sp", SIDECAR_BOARD)?, &sidecar)?; let bootleby = dir.join("bootleby.zip"); let boot_name = format!("gimlet_rot_bootloader-{BOOTLOADER_VARIANT}"); - zip_extract( - &self.path, + extract_member( + &mut zip, find("gimlet_rot_bootloader", &boot_name)?, &bootleby, )?; // The RoT composite holds archive-a.zip and archive-b.zip; slot A is // what launch flashes, and bootleby verifies it. - let rot_member = find("gimlet_rot", ROT_VARIANT)?.to_string(); - let (mut child, mut archive) = self.member_tar(&rot_member)?; + let rot_member = find("gimlet_rot", ROT_VARIANT)?; + let mut archive = member_tar(&mut zip, rot_member)?; let mut rot_a = None; for entry in archive.entries().context("read RoT composite entries")? { let mut entry = entry?; @@ -325,8 +330,6 @@ impl TufRepoSource { rot_a = Some(dest); break; } - drop(archive); - wait_ok(&mut child, &rot_member)?; let rot_a = rot_a.with_context(|| { format!("{ROT_VARIANT} in {} carries no archive-a.zip", self.path) })?; @@ -348,31 +351,69 @@ impl TufRepoSource { /// Every `repo/targets/.` member with its sha. pub(crate) fn target_members(&self) -> Result> { let mut v = Vec::new(); - for m in zip_members(&self.path)? { + for m in self.open()?.file_names() { let Some(base) = m.strip_prefix("repo/targets/") else { continue; }; let Some((sha, _)) = base.split_once('.') else { continue }; if sha.len() == 64 && sha.chars().all(|c| c.is_ascii_hexdigit()) { - v.push((sha.to_string(), m)); + v.push((sha.to_string(), m.to_string())); } } Ok(v) } - /// Stream one composite member as a tar archive. - fn member_tar(&self, member: &str) -> Result<(Child, MemberArchive)> { - let mut child = Command::new("unzip") - .args(["-p", self.path.as_str(), member]) - .stdout(Stdio::piped()) - .spawn() - .context("spawn unzip -p")?; - let stdout = child.stdout.take().context("unzip stdout")?; - let archive = tar::Archive::new(flate2::read::GzDecoder::new(stdout)); - Ok((child, archive)) + fn open(&self) -> Result> { + open_zip(&self.path) } } +/// Open a repo zip; the central directory is parsed here, members are read +/// on demand. +fn open_zip(path: &Utf8Path) -> Result> { + let file = File::open(path).with_context(|| format!("open {path}"))?; + ZipArchive::new(file).with_context(|| format!("read {path} (not a zip?)")) +} + +/// A streaming reader over one member. +fn member<'a>( + zip: &'a mut ZipArchive, + name: &str, +) -> Result> { + zip.by_name(name).with_context(|| format!("zip member {name}")) +} + +/// Stream one composite member as a tar archive. +fn member_tar<'a>( + zip: &'a mut ZipArchive, + name: &str, +) -> Result> { + let file = member(zip, name)?; + Ok(tar::Archive::new(flate2::read::GzDecoder::new(file))) +} + +/// Stream one member to `dest`, as is, returning its size. +fn extract_member( + zip: &mut ZipArchive, + name: &str, + dest: &Utf8Path, +) -> Result { + let mut src = member(zip, name)?; + let mut out = + File::create(dest).with_context(|| format!("create {dest}"))?; + std::io::copy(&mut src, &mut out) + .with_context(|| format!("write {name} to {dest}")) +} + +/// Copy one member of the repo zip at `path` to `dest`, returning its size. +pub(crate) fn extract_from( + path: &Utf8Path, + name: &str, + dest: &Utf8Path, +) -> Result { + extract_member(&mut open_zip(path)?, name, dest) +} + /// Copy `boot_image` minus its 4096 byte header to `dest`: the raw ZFS pool /// image, lofi mountable for the global zone software lift. pub(crate) fn strip_boot_image_header( @@ -389,58 +430,6 @@ pub(crate) fn strip_boot_image_header( .with_context(|| format!("write phase 2 payload to {dest}")) } -fn wait_ok(child: &mut Child, member: &str) -> Result<()> { - let status = child.wait().context("wait for unzip")?; - // The tar reader stops at the archive's logical end; unzip may still be - // writing zip padding and exit on EPIPE, which is not a failure here. - if !status.success() && status.code().is_some_and(|c| c != 141) { - bail!("unzip -p {member} exited with {status}"); - } - Ok(()) -} - -/// `unzip -Z1`: one member path per line. -fn zip_members(path: &Utf8Path) -> Result> { - let out = Command::new("unzip") - .args(["-Z1", path.as_str()]) - .output() - .context("run unzip -Z1")?; - if !out.status.success() { - bail!("unzip -Z1 {path} failed (not a zip?)"); - } - Ok(String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::to_string) - .collect()) -} - -/// Stream one zip member to `dest`, as is. -fn zip_extract(path: &Utf8Path, member: &str, dest: &Utf8Path) -> Result<()> { - let mut child = Command::new("unzip") - .args(["-p", path.as_str(), member]) - .stdout(Stdio::piped()) - .spawn() - .context("spawn unzip -p")?; - let mut out = child.stdout.take().context("unzip stdout")?; - let mut file = - fs::File::create(dest).with_context(|| format!("create {dest}"))?; - std::io::copy(&mut out, &mut file) - .with_context(|| format!("write {member} to {dest}"))?; - wait_ok(&mut child, member) -} - -/// `unzip -p`: stream one member. -fn zip_read(path: &Utf8Path, member: &str) -> Result> { - let out = Command::new("unzip") - .args(["-p", path.as_str(), member]) - .output() - .with_context(|| format!("unzip -p {member}"))?; - if !out.status.success() { - bail!("unzip -p {path} {member} failed"); - } - Ok(out.stdout) -} - /// Parse the short omicron sha out of `+git`. fn commit_of_version(system_version: &str) -> Result { let sha = system_version