OXYGENHUB

Coverage / crates/flow/src/main.rs 34.1%

126 of 369 lines run, 14 of 106 functions · run #181 on main · run missed

1
//! `flow`: check pipelines, run them locally, and run jobs for OxygenHub
2
//! (docs/spec.md, docs/m0.md).
3
4
use flow::{facts, home, images, microvm, runner, syscall, wait, wasm, workspace};
5
6
use anyhow::{bail, Context, Result};
7
use clap::{Parser, Subcommand};
8
use flow_core::api::Record;
9
use flow_core::{Pipeline, Runtime, PIPELINE_PATH};
10
use home::Home;
11
use std::collections::BTreeSet;
12
use std::path::{Path, PathBuf};
13
use std::time::Duration;
14
15
#[derive(Parser)]
16
#[command(name = "flow", version = env!("OXY_VERSION_LONG"), about = "OxygenFlow: hermetic pipelines for Oxygen repositories")]
17
struct Cli {
18
    /// The Flow home (default $FLOW_HOME, else ~/.local/share/flow).
19
    #[arg(long, global = true)]
20
    home: Option<PathBuf>,
21
    #[command(subcommand)]
22
    cmd: Cmd,
23
}
24
25
#[derive(Subcommand)]
26
enum RunnerCmd {
27
    /// Register this runner with a hub using a one-time token from its Admin → Runners page
28
    Register {
29
        /// The hub, e.g. https://earth.example:7343
30
        hub: String,
31
        /// The registration token (read from stdin when not given)
32
        #[arg(long)]
33
        token: Option<String>,
34
        /// The runner's name (default: the host name)
35
        #[arg(long)]
36
        name: Option<String>,
37
        /// The hub's CA certificate, when self-signed
38
        #[arg(long)]
39
        ca: Option<PathBuf>,
40
        /// A label the runner offers (repeatable)
41
        #[arg(long)]
42
        label: Vec<String>,
43
    },
44
    /// Measure this machine as a runner does: its speed score (100 on the
45
    /// reference machine) and class, free disk and load (spec §11.1)
46
    Bench,
47
}
48
49
#[derive(Subcommand)]
50
enum Cmd {
51
    /// Validate a pipeline and print its jobs and hash
52
    Check {
53
        /// The pipeline file (default: .flow/pipeline.kdl)
54
        file: Option<PathBuf>,
55
    },
56
    /// Print a file's BLAKE3, for pinning in flow.toml
57
    Hash { file: PathBuf },
58
    /// Rootfs images for sandboxes
59
    Rootfs {
60
        #[command(subcommand)]
61
        cmd: RootfsCmd,
62
    },
63
    /// Run the pipeline on this directory's files, locally
64
    Run {
65
        /// Only these jobs (and what they depend on)
66
        jobs: Vec<String>,
67
        /// The directory to build (default: the current one)
68
        #[arg(long, default_value = ".")]
69
        dir: PathBuf,
70
        /// Minutes before a job is stopped
71
        #[arg(long, default_value_t = 60)]
72
        timeout: u64,
73
        /// A variable for every job, as the hub's settings would give it;
74
        /// the pipeline's own `env` wins
75
        #[arg(long = "var", value_name = "NAME=VALUE")]
76
        vars: Vec<String>,
77
        /// A secret a job's `secret` block names, as the hub would supply
78
        /// it; mounted as a file, never in the environment
79
        #[arg(long = "secret", value_name = "NAME=VALUE")]
80
        secrets: Vec<String>,
81
    },
82
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
83
    /// runner.toml `flow runner register` wrote)
84
    Runner {
85
        #[command(subcommand)]
86
        cmd: Option<RunnerCmd>,
87
    },
88
    /// Follow the checks of a pushed state on OxygenHub until they finish:
89
    /// exit 0 when all passed, 1 when one failed, 2 at the time limit.
90
    /// Uses $OXY_TOKEN and $OXY_CA as oxy does.
91
    Wait {
92
        /// The repository's URL on the hub (https://hub/owner/name)
93
        url: String,
94
        /// The state hash the push moved the bookmark to (`oxy push --json` prints it)
95
        state: String,
96
        /// Minutes to wait at most
97
        #[arg(long, default_value_t = 60)]
98
        timeout: u64,
99
    },
100
    /// Show which isolation tiers this machine can run jobs in, and why
101
    /// the others cannot
102
    Tiers,
103
    /// The syscall tier's sandbox helper, started by the runner for each job
104
    #[command(name = "__sandbox", hide = true)]
105
    Sandbox { spec: PathBuf },
106
    /// Check that this machine can run the syscall tier
107
    #[command(name = "__sandbox-probe", hide = true)]
108
    SandboxProbe,
109
    /// The WASM tier's helper, started by the runner for each job
110
    #[command(name = "__wasm", hide = true)]
111
    Wasm { spec: PathBuf },
112
}
113
114
#[derive(Subcommand)]
115
enum RootfsCmd {
116
    /// Make an ext4 rootfs image from a root file system tarball and store it by hash
117
    Import {
118
        tarball: PathBuf,
119
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
120
        #[arg(long)]
121
        sha256: Option<String>,
122
        /// The image's own environment, NAME=value (repeatable): what a
123
        /// Dockerfile's ENV sets and `docker export` loses. Jobs start from
124
        /// it; their sandbox's and their own `env` override it
125
        #[arg(long = "env", value_name = "NAME=VALUE")]
126
        env: Vec<String>,
127
    },
128
    /// List the images this home holds
129
    List,
130
    /// Send an image to a hub, so its runners fetch it instead of having it
131
    /// copied to them: only the chunks the hub lacks travel. Signs in as
132
    /// `oh` is signed in to that hub, or with $OXY_TOKEN and $OXY_CA
133
    Push {
134
        /// The image's hash, as pipelines pin it (cas://blob/HASH)
135
        hash: String,
136
        /// The hub (default: $OH_HUB, else the runner's, from runner.toml or flow.toml)
137
        #[arg(long)]
138
        hub: Option<String>,
139
    },
140
    /// Fetch an image a hub holds into this home, as a runner does for a
141
    /// job whose image it lacks. Signs in as `oh` is, or with $OXY_TOKEN
142
    Pull {
143
        hash: String,
144
        #[arg(long)]
145
        hub: Option<String>,
146
    },
147
}
148
1495×
fn main() {
1505×
    let cli = Cli::parse();
1515×
    if let Err(e) = run(cli) {
1522×
        eprintln!("flow: {e:#}");
1532×
        std::process::exit(1);
1543×
    }
1553×
}
156
1575×
fn run(cli: Cli) -> Result<()> {
1580×
    match cli.cmd {
1590×
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
1600×
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
1610×
        Cmd::Wasm { spec } => std::process::exit(wasm::wasm_main(&spec)),
1620×
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
1630×
        Cmd::Hash { file } => {
1640×
            println!("{}", home::hash_file(&file)?);
1650×
            Ok(())
166
        }
1670×
        Cmd::Rootfs { cmd } => {
1680×
            let home = Home::locate(cli.home.as_deref())?;
1690×
            match cmd {
1700×
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
1710×
                RootfsCmd::Push { hash, hub } => {
1720×
                    let client = image_client(&home, hub)?;
1730×
                    eprintln!("Sending image {} …", &hash[..12.min(hash.len())]);
1740×
                    let m = client.push(&home, &hash)?;
1750×
                    println!(
176
                        "Sent image {}: {} chunks the hub lacked, {} in {:.1} s",
1770×
                        &hash[..12],
178
                        m.chunks,
1790×
                        images::human(m.bytes),
1800×
                        m.elapsed.as_secs_f64()
181
                    );
1820×
                    Ok(())
183
                }
1840×
                RootfsCmd::Pull { hash, hub } => {
1850×
                    let client = image_client(&home, hub)?;
1860×
                    match client.fetch(&home, &hash)? {
1870×
                        Some(m) => {
1880×
                            println!(
189
                                "Fetched image {}: {} chunks, {} in {:.1} s",
1900×
                                &hash[..12],
191
                                m.chunks,
1920×
                                images::human(m.bytes),
1930×
                                m.elapsed.as_secs_f64()
194
                            );
1950×
                            Ok(())
196
                        }
1970×
                        None => anyhow::bail!("the hub does not hold image {}; send it with flow rootfs push", &hash[..12.min(hash.len())]),
198
                    }
199
                }
200
                RootfsCmd::List => {
2010×
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
2020×
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
2030×
                        if let Some(h) = e.file_name().to_string_lossy().strip_suffix(".ext4") {
2040×
                            println!("cas://blob/{h}  {} MiB", size >> 20);
2050×
                        }
206
                    }
2070×
                    Ok(())
208
                }
209
            }
210
        }
2112×
        Cmd::Run { jobs, dir, timeout, vars, secrets } => {
2122×
            let home = Home::locate(cli.home.as_deref())?;
2134×
            let pairs = |what: &str, list: &[String]| {
2144×
                list.iter()
2154×
                    .map(|e| {
2160×
                        e.split_once('=')
2170×
                            .map(|(k, v)| (k.to_string(), v.to_string()))
2180×
                            .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE"))
2190×
                    })
2204×
                    .collect::<Result<Vec<_>>>()
2214×
            };
2222×
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
2232×
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
224
        }
225
        Cmd::Runner { cmd: Some(RunnerCmd::Bench) } => {
2260×
            let home = Home::locate(cli.home.as_deref())?;
2270×
            let (speed, ms) = facts::bench();
2280×
            let f = facts::now(&home, Some(speed));
2290×
            println!("speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
2300×
            println!("free disk {} MiB at {}", f.free_disk_mib.unwrap_or(0), home.root.display());
2310×
            println!("load {:.2}", f.load_x100.unwrap_or(0) as f64 / 100.0);
2320×
            if let Some(k) = f.kernel {
2330×
                println!("kernel {k}");
2340×
            }
2350×
            Ok(())
236
        }
2370×
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
2380×
            let home = Home::locate(cli.home.as_deref())?;
2390×
            if std::env::var_os("OXY_HOME").is_none() {
2400×
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
2410×
            }
2420×
            let name = name.unwrap_or_else(|| {
2430×
                std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into())
2440×
            });
2450×
            let token = match token {
2460×
                Some(t) => t,
247
                None => {
2480×
                    eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:");
2490×
                    let mut line = String::new();
2500×
                    std::io::stdin().read_line(&mut line)?;
2510×
                    line.trim().to_string()
252
                }
253
            };
2540×
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
2550×
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
2560×
            Ok(())
257
        }
258
        Cmd::Runner { cmd: None } => {
2590×
            let home = Home::locate(cli.home.as_deref())?;
260
            // The runner's oxy state (identity, user config) stays in its home.
2610×
            if std::env::var_os("OXY_HOME").is_none() {
2620×
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
2630×
            }
2640×
            runner::serve(&home)
265
        }
266
        Cmd::Tiers => {
2673×
            let home = Home::locate(cli.home.as_deref())?;
2683×
            match runner::tiers(&home) {
2693×
                Ok((have, missing)) => {
2703×
                    for t in have {
2713×
                        println!("{t}: available");
2723×
                    }
2736×
                    for why in missing {
2746×
                        println!("{}", why.replacen(": ", ": unavailable: ", 1));
2756×
                    }
276
                }
2770×
                Err(why) => println!("{why}"),
278
            }
2793×
            Ok(())
280
        }
2810×
        Cmd::Wait { url, state, timeout } => {
2820×
            let token = std::env::var("OXY_TOKEN").ok();
2830×
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
2840×
            let code =
2850×
                wait::wait(&url, &state, token.as_deref(), ca.as_deref(), Duration::from_secs(timeout * 60), Duration::from_secs(15))?;
2860×
            std::process::exit(code);
287
        }
288
    }
2895×
}
290
291
/// The hub image commands talk to: `--hub`, else $OH_HUB, else the
292
/// runner's. The token: $OXY_TOKEN, else the login `oh auth login` (or
293
/// `oxy auth login`) saved for that hub in Oxygen's credential store
294
/// (oxygenflow#44), else the runner's own.
2950×
fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> {
2960×
    let runner = home.config.runner.clone();
2970×
    let hub = hub
2980×
        .or_else(|| std::env::var("OH_HUB").ok().filter(|h| !h.is_empty()))
2990×
        .or_else(|| runner.as_ref().map(|r| r.hub.clone()))
3000×
        .context("name the hub with --hub https://HUB (or set OH_HUB)")?;
3010×
    let (saved_token, saved_ca) = oxy_core::credentials::Credentials::user().lookup(&hub);
3020×
    let token = std::env::var("OXY_TOKEN")
3030×
        .ok()
3040×
        .filter(|t| !t.is_empty())
3050×
        .or(saved_token)
3060×
        .or_else(|| runner.as_ref().map(|r| r.token.clone()))
3070×
        .with_context(|| format!("no token for {hub}: sign in with `oh auth login --hub {hub}`, or set OXY_TOKEN"))?;
3080×
    let ca = std::env::var_os("OXY_CA").map(PathBuf::from).or(saved_ca).or_else(|| runner.as_ref().and_then(|r| r.ca.clone()));
3090×
    images::Client::new(&hub, &token, ca.as_deref())
3100×
}
311
3122×
fn load(file: &Path) -> Result<Pipeline> {
3132×
    let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
3142×
    Pipeline::parse(&src).map_err(|p| anyhow::anyhow!("{}", not_valid(file, &p.to_string())))
3152×
}
316
317
/// Why a pipeline did not parse. When it names something this flow does
318
/// not know (a node, an output, an event), the pipeline may simply be
319
/// newer than this flow (oxygenflow#43): say so, with this flow's version.
3202×
fn not_valid(file: &Path, problems: &str) -> String {
3212×
    if problems.contains("unknown ") {
3221×
        format!(
323
            "{} uses something this flow ({}) does not know:\n{problems}\n\
324
             If the pipeline was written for a newer OxygenFlow, update flow (build it from oxygenflow main); \
325
             otherwise fix the lines named.",
3261×
            file.display(),
327
            env!("OXY_VERSION_LONG")
328
        )
329
    } else {
3301×
        format!("{} is not valid:\n{problems}", file.display())
331
    }
3322×
}
333
3340×
fn check(file: &Path) -> Result<()> {
3350×
    let p = load(file)?;
3360×
    let depths = p.depths();
3370×
    for j in p.order() {
3380×
        let sb = &p.sandboxes[&j.sandbox];
3390×
        let after = if j.depends_on.is_empty() { String::new() } else { format!(" after {}", j.depends_on.join(", ")) };
3400×
        println!("{}{}  {} [{}]{after}", "  ".repeat(depths[j.name.as_str()]), j.name, j.exec.join(" "), sb.runtime.as_str());
341
    }
3420×
    println!("pipeline {}", hex::encode(p.hash()));
3430×
    Ok(())
3440×
}
345
346
/// The image environment file flow-init reads (`/etc/flow/env`): one
347
/// `NAME=value` per line, names checked as a pipeline's are.
3485×
fn image_env_file(env: &[String]) -> Result<String> {
3495×
    let mut out = String::new();
3506×
    for e in env {
3516×
        let Some((k, v)) = e.split_once('=') else { bail!("--env {e:?}: expected NAME=value") };
35216×
        let ok = !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
3535×
        if !ok || v.contains('\n') || v.contains('\0') {
3542×
            bail!("--env {e:?}: a name is letters, digits and _, and a value is one line");
3553×
        }
3563×
        out.push_str(&format!("{k}={v}\n"));
357
    }
3582×
    Ok(out)
3595×
}
360
361
/// The architecture an ELF file was built for, by its header.
3620×
fn elf_arch(path: &Path) -> Option<&'static str> {
3630×
    let mut head = [0u8; 20];
3640×
    std::io::Read::read_exact(&mut std::fs::File::open(path).ok()?, &mut head).ok()?;
3650×
    jet::rootfs::elf_arch(&head)
3660×
}
367
368
/// The architecture of an unpacked rootfs: its shell's, or the first
369
/// program's found under bin.
3700×
fn tree_arch(tree: &Path) -> Option<&'static str> {
3710×
    for rel in ["bin/sh", "bin/busybox", "usr/bin/env", "bin/bash", "usr/bin/sh"] {
3720×
        let p = tree.join(rel);
3730×
        let p = std::fs::canonicalize(&p).unwrap_or(p);
3740×
        if let Some(a) = elf_arch(&p) {
3750×
            return Some(a);
3760×
        }
377
    }
3780×
    None
3790×
}
380
3810×
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>, env: &[String]) -> Result<()> {
3820×
    let env_file = image_env_file(env)?;
3830×
    if let Some(want) = sha256 {
3840×
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
3850×
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
3860×
        if got != want.to_lowercase() {
3870×
            bail!("{} has SHA-256 {got}, expected {want}", tarball.display());
3880×
        }
3890×
    }
3900×
    let tmp = home.root.join("work").join(format!("rootfs-{}", std::process::id()));
3910×
    let _ = std::fs::remove_dir_all(&tmp);
3920×
    let tree = tmp.join("tree");
3930×
    std::fs::create_dir_all(&tree)?;
3940×
    let result = (|| -> Result<String> {
3950×
        let status = std::process::Command::new("tar").arg("-xf").arg(tarball).arg("-C").arg(&tree).arg("--no-same-owner").status()?;
3960×
        if !status.success() {
3970×
            bail!("tar could not unpack {}", tarball.display());
3980×
        }
399
        // The image's architecture must be this machine's: there is no
400
        // emulation tier (spec §11.2).
4010×
        let arch = tree_arch(&tree).unwrap_or("unknown");
4020×
        if arch != "unknown" && arch != std::env::consts::ARCH {
4030×
            bail!(
404
                "{} is a {arch} image; this runner is {}, and jobs here run no other architecture",
4050×
                tarball.display(),
406
                std::env::consts::ARCH
407
            );
4080×
        }
409
        // Mount points flow-init needs (docs/m0.md §2).
4100×
        for d in ["src", "tmp", "proc", "dev", "run"] {
4110×
            std::fs::create_dir_all(tree.join(d))?;
412
        }
4130×
        if !env_file.is_empty() {
4140×
            std::fs::create_dir_all(tree.join("etc/flow"))?;
4150×
            std::fs::write(tree.join("etc/flow/env"), &env_file)?;
4160×
        }
4170×
        let used: u64 = ignore::WalkBuilder::new(&tree)
4180×
            .hidden(false)
4190×
            .ignore(false)
4200×
            .git_ignore(false)
4210×
            .build()
4220×
            .flatten()
4230×
            .filter_map(|e| e.metadata().ok())
4240×
            .map(|m| m.len().max(4096))
4250×
            .sum();
4260×
        let mib = used * 13 / 10 / (1 << 20) + 64;
4270×
        let img = tmp.join("rootfs.ext4");
4280×
        let status = std::process::Command::new("mkfs.ext4")
4290×
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0", "-d"])
4300×
            .arg(&tree)
4310×
            .arg(&img)
4320×
            .arg(format!("{mib}M"))
4330×
            .status()
4340×
            .context("run mkfs.ext4")?;
4350×
        if !status.success() {
4360×
            bail!("mkfs.ext4 failed");
4370×
        }
4380×
        let hash = home::hash_file(&img)?;
4390×
        std::fs::rename(&img, home.rootfs(&hash))?;
4400×
        let manifest = serde_json::json!({ "arch": if arch == "unknown" { std::env::consts::ARCH } else { arch }, "imported_ms": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) });
4410×
        std::fs::write(home.image_manifest(&hash), manifest.to_string())?;
4420×
        Ok(hash)
443
    })();
4440×
    let _ = std::fs::remove_dir_all(&tmp);
4450×
    let hash = result?;
4460×
    println!("rootfs \"cas://blob/{hash}\"");
4470×
    Ok(())
4480×
}
449
4502×
fn run_local(
4512×
    home: &Home,
4522×
    dir: &Path,
4532×
    only: &[String],
4542×
    timeout: Duration,
4552×
    vars: &[(String, String)],
4562×
    secrets: &[(String, String)],
4572×
) -> Result<()> {
4582×
    let p = load(&dir.join(PIPELINE_PATH))?;
459
    // The named jobs and everything they depend on.
4602×
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
4612×
    for name in only {
4620×
        if !p.jobs.contains_key(name) {
4630×
            bail!("no job {name:?} in the pipeline");
4640×
        }
465
    }
4662×
    if !only.is_empty() {
467
        loop {
4680×
            let more: Vec<String> = wanted.iter().flat_map(|j| p.jobs[j].depends_on.clone()).filter(|d| !wanted.contains(d)).collect();
4690×
            if more.is_empty() {
4700×
                break;
4710×
            }
4720×
            wanted.extend(more);
473
        }
4742×
    }
4752×
    let scratch = home.root.join("work").join(format!("local-{}", std::process::id()));
4762×
    let _ = std::fs::remove_dir_all(&scratch);
4772×
    std::fs::create_dir_all(scratch.join("tree"))?;
4782×
    workspace::copy_tree(dir, &scratch.join("tree"))?;
4792×
    let mut failed: BTreeSet<String> = BTreeSet::new();
4802×
    let mut worst = 0;
48112×
    for job in p.order() {
48212×
        if !only.is_empty() && !wanted.contains(&job.name) {
4830×
            continue;
48412×
        }
48512×
        if let Some(d) = job.depends_on.iter().find(|d| failed.contains(*d)) {
4860×
            println!("── {} skipped: {d} failed", job.name);
4870×
            failed.insert(job.name.clone());
4880×
            continue;
48912×
        }
49012×
        let sb = &p.sandboxes[&job.sandbox];
49112×
        println!("── {} ({})", job.name, job.exec.join(" "));
49212×
        let js = scratch.join(&job.name);
49312×
        std::fs::create_dir_all(&js)?;
49412×
        let job = &job
49512×
            .in_sandbox(sb)
49612×
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
49712×
            .with_flow_env(vars);
498
        // Identities come from a hub: a local run has none.
49912×
        for i in &job.identities {
5000×
            println!("── {}: identity {:?} is not available locally; {} will be missing", job.name, i.audience, i.mount_path);
5010×
        }
502
        // Each secret the job declares must be given, as the hub would.
50312×
        let inputs = job
50412×
            .secrets
50512×
            .iter()
50612×
            .map(|s| {
5070×
                let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone());
5080×
                value
5090×
                    .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value })
5100×
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
5110×
            })
51212×
            .collect::<Result<Vec<_>>>()?;
513
        // Each job starts from the same files: jobs do not share writes.
51412×
        let out = if sb.runtime == Runtime::Wasm {
51512×
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
51617×
            wasm::run(sb, job, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r))?
5170×
        } else if sb.runtime == Runtime::Syscall {
5180×
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
5190×
            let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy());
5200×
            syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)?
521
        } else {
5220×
            workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
5230×
            microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?
524
        };
52512×
        let _ = workspace::remove_tree(&js);
52612×
        match out.exit_code {
5278×
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
5284×
            other => {
5294×
                println!(
530
                    "── {} failed ({}) in {} ms",
531
                    job.name,
5324×
                    other.map(|c| format!("exit {c}")).unwrap_or_else(|| "no exit code".into()),
533
                    out.duration_ms
534
                );
5354×
                failed.insert(job.name.clone());
5364×
                worst = 1;
537
            }
538
        }
539
    }
5402×
    let _ = workspace::remove_tree(&scratch);
5412×
    if worst != 0 {
5422×
        std::process::exit(worst);
5430×
    }
5440×
    Ok(())
5450×
}
546
54717×
fn print_record(r: &Record) {
54817×
    match r.stream.as_str() {
54917×
        "out" => println!("   {}", r.text),
55013×
        "err" => eprintln!("   {}", r.text),
55110×
        s => eprintln!("   [{s}] {}", r.text),
552
    }
55317×
}
554
555
#[cfg(test)]
556
mod tests {
557
    #[test]
5581×
    fn a_pipeline_newer_than_flow_is_named_as_such() {
5591×
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 92: unknown output `sbom`; outputs are …");
5601×
        assert!(m.contains("does not know") && m.contains(env!("OXY_VERSION_LONG")) && m.contains("newer OxygenFlow"), "{m}");
5611×
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 3: a job needs a sandbox");
5621×
        assert!(m.contains("is not valid") && !m.contains("newer"), "{m}");
5631×
    }
564
565
    use super::image_env_file;
566
567
    #[test]
5681×
    fn image_environments_are_checked_lines() {
5691×
        let f = image_env_file(&["PATH=/opt/bin:/usr/bin".into(), "X16EMU=/opt/x16emu".into(), "EMPTY=".into()]).unwrap();
5701×
        assert_eq!(f, "PATH=/opt/bin:/usr/bin\nX16EMU=/opt/x16emu\nEMPTY=\n");
5711×
        assert!(image_env_file(&["NOEQUALS".into()]).is_err());
5721×
        assert!(image_env_file(&["1X=y".into()]).is_err());
5731×
        assert!(image_env_file(&["X=two\nlines".into()]).is_err());
5741×
        assert_eq!(image_env_file(&[]).unwrap(), "");
5751×
    }
576
}