OXYGENHUB
◆ push-uses-login oxygenflow / crates / flow / src / main.rs
24.2 KiB · Rust · last changed flow rootfs push and pull use the login oh already saved 24 days ago source blame history raw
e0918b88 Andy McCall 1
//! `flow`: check pipelines, run them locally, and run jobs for OxygenHub
2
//! (docs/spec.md, docs/m0.md).
3
4
mod cpio;
62829d15 Andy McCall ↶ 5
mod facts;
ea56c56e Andy McCall ↶ 6
/// The job-starting code `flow-init` uses in a microVM, shared with the
7
/// syscall tier.
8
#[path = "../../flow-init/src/job.rs"]
9
mod guest;
e0918b88 Andy McCall 10
mod home;
4cf0c99a Andy McCall ↶ 11
mod images;
e0918b88 Andy McCall 12
mod microvm;
a67b97e9 Andy McCall ↶ 13
mod outputs;
e0918b88 Andy McCall 14
mod runner;
ea56c56e Andy McCall ↶ 15
mod syscall;
9e8f4a56 Andy McCall ↶ 16
mod wait;
e0918b88 Andy McCall 17
mod workspace;
18
19
use anyhow::{bail, Context, Result};
20
use clap::{Parser, Subcommand};
21
use flow_core::api::Record;
22
use flow_core::{Pipeline, Runtime, PIPELINE_PATH};
23
use home::Home;
24
use std::collections::BTreeSet;
25
use std::path::{Path, PathBuf};
26
use std::time::Duration;
27
28
#[derive(Parser)]
2d025bbf Andy McCall ↶ 29
#[command(name = "flow", version = env!("OXY_VERSION_LONG"), about = "OxygenFlow: hermetic pipelines for Oxygen repositories")]
e0918b88 Andy McCall 30
struct Cli {
31
    /// The Flow home (default $FLOW_HOME, else ~/.local/share/flow).
32
    #[arg(long, global = true)]
33
    home: Option<PathBuf>,
34
    #[command(subcommand)]
35
    cmd: Cmd,
36
}
37
38
#[derive(Subcommand)]
f98a8f78 Andy McCall ↶ 39
enum RunnerCmd {
40
    /// Register this runner with a hub using a one-time token from its Admin → Runners page
41
    Register {
42
        /// The hub, e.g. https://earth.example:7343
43
        hub: String,
44
        /// The registration token (read from stdin when not given)
45
        #[arg(long)]
46
        token: Option<String>,
47
        /// The runner's name (default: the host name)
48
        #[arg(long)]
49
        name: Option<String>,
50
        /// The hub's CA certificate, when self-signed
51
        #[arg(long)]
52
        ca: Option<PathBuf>,
53
        /// A label the runner offers (repeatable)
54
        #[arg(long)]
55
        label: Vec<String>,
56
    },
62829d15 Andy McCall ↶ 57
    /// Measure this machine as a runner does: its speed score (100 on the
58
    /// reference machine) and class, free disk and load (spec §11.1)
59
    Bench,
f98a8f78 Andy McCall ↶ 60
}
61
62
#[derive(Subcommand)]
e0918b88 Andy McCall 63
enum Cmd {
64
    /// Validate a pipeline and print its jobs and hash
65
    Check {
66
        /// The pipeline file (default: .flow/pipeline.kdl)
67
        file: Option<PathBuf>,
68
    },
69
    /// Print a file's BLAKE3, for pinning in flow.toml
70
    Hash { file: PathBuf },
71
    /// Rootfs images for sandboxes
72
    Rootfs {
73
        #[command(subcommand)]
74
        cmd: RootfsCmd,
75
    },
76
    /// Run the pipeline on this directory's files, locally
77
    Run {
78
        /// Only these jobs (and what they depend on)
79
        jobs: Vec<String>,
80
        /// The directory to build (default: the current one)
81
        #[arg(long, default_value = ".")]
82
        dir: PathBuf,
83
        /// Minutes before a job is stopped
84
        #[arg(long, default_value_t = 60)]
85
        timeout: u64,
c58efc6a Andy McCall ↶ 86
        /// A variable for every job, as the hub's settings would give it;
87
        /// the pipeline's own `env` wins
88
        #[arg(long = "var", value_name = "NAME=VALUE")]
89
        vars: Vec<String>,
e2c12561 Andy McCall ↶ 90
        /// A secret a job's `secret` block names, as the hub would supply
91
        /// it; mounted as a file, never in the environment
92
        #[arg(long = "secret", value_name = "NAME=VALUE")]
93
        secrets: Vec<String>,
f98a8f78 Andy McCall ↶ 94
    },
95
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
96
    /// runner.toml `flow runner register` wrote)
97
    Runner {
98
        #[command(subcommand)]
99
        cmd: Option<RunnerCmd>,
e0918b88 Andy McCall 100
    },
9e8f4a56 Andy McCall ↶ 101
    /// Follow the checks of a pushed state on OxygenHub until they finish:
102
    /// exit 0 when all passed, 1 when one failed, 2 at the time limit.
103
    /// Uses $OXY_TOKEN and $OXY_CA as oxy does.
104
    Wait {
105
        /// The repository's URL on the hub (https://hub/owner/name)
106
        url: String,
107
        /// The state hash the push moved the bookmark to (`oxy push --json` prints it)
108
        state: String,
109
        /// Minutes to wait at most
110
        #[arg(long, default_value_t = 60)]
111
        timeout: u64,
112
    },
ea56c56e Andy McCall ↶ 113
    /// Show which isolation tiers this machine can run jobs in, and why
114
    /// the others cannot
115
    Tiers,
116
    /// The syscall tier's sandbox helper, started by the runner for each job
117
    #[command(name = "__sandbox", hide = true)]
118
    Sandbox { spec: PathBuf },
119
    /// Check that this machine can run the syscall tier
120
    #[command(name = "__sandbox-probe", hide = true)]
121
    SandboxProbe,
e0918b88 Andy McCall 122
}
123
124
#[derive(Subcommand)]
125
enum RootfsCmd {
126
    /// Make an ext4 rootfs image from a root file system tarball and store it by hash
127
    Import {
128
        tarball: PathBuf,
129
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
130
        #[arg(long)]
131
        sha256: Option<String>,
a72abeee Andy McCall ↶ 132
        /// The image's own environment, NAME=value (repeatable): what a
133
        /// Dockerfile's ENV sets and `docker export` loses. Jobs start from
134
        /// it; their sandbox's and their own `env` override it
135
        #[arg(long = "env", value_name = "NAME=VALUE")]
136
        env: Vec<String>,
e0918b88 Andy McCall 137
    },
138
    /// List the images this home holds
139
    List,
4cf0c99a Andy McCall ↶ 140
    /// Send an image to a hub, so its runners fetch it instead of having it
e38bb032 Andy McCall ↶ 141
    /// copied to them: only the chunks the hub lacks travel. Signs in as
142
    /// `oh` is signed in to that hub, or with $OXY_TOKEN and $OXY_CA
4cf0c99a Andy McCall ↶ 143
    Push {
144
        /// The image's hash, as pipelines pin it (cas://blob/HASH)
145
        hash: String,
e38bb032 Andy McCall ↶ 146
        /// The hub (default: $OH_HUB, else the runner's, from runner.toml or flow.toml)
4cf0c99a Andy McCall ↶ 147
        #[arg(long)]
148
        hub: Option<String>,
149
    },
150
    /// Fetch an image a hub holds into this home, as a runner does for a
e38bb032 Andy McCall ↶ 151
    /// job whose image it lacks. Signs in as `oh` is, or with $OXY_TOKEN
4cf0c99a Andy McCall ↶ 152
    Pull {
153
        hash: String,
154
        #[arg(long)]
155
        hub: Option<String>,
156
    },
e0918b88 Andy McCall 157
}
158
159
fn main() {
160
    let cli = Cli::parse();
161
    if let Err(e) = run(cli) {
162
        eprintln!("flow: {e:#}");
163
        std::process::exit(1);
164
    }
165
}
166
167
fn run(cli: Cli) -> Result<()> {
168
    match cli.cmd {
ea56c56e Andy McCall ↶ 169
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
170
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
e0918b88 Andy McCall 171
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
172
        Cmd::Hash { file } => {
173
            println!("{}", home::hash_file(&file)?);
174
            Ok(())
175
        }
176
        Cmd::Rootfs { cmd } => {
177
            let home = Home::locate(cli.home.as_deref())?;
178
            match cmd {
a72abeee Andy McCall ↶ 179
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
4cf0c99a Andy McCall ↶ 180
                RootfsCmd::Push { hash, hub } => {
181
                    let client = image_client(&home, hub)?;
182
                    eprintln!("Sending image {} …", &hash[..12.min(hash.len())]);
183
                    let m = client.push(&home, &hash)?;
184
                    println!(
185
                        "Sent image {}: {} chunks the hub lacked, {} in {:.1} s",
186
                        &hash[..12],
187
                        m.chunks,
188
                        images::human(m.bytes),
189
                        m.elapsed.as_secs_f64()
190
                    );
191
                    Ok(())
192
                }
193
                RootfsCmd::Pull { hash, hub } => {
194
                    let client = image_client(&home, hub)?;
195
                    match client.fetch(&home, &hash)? {
196
                        Some(m) => {
197
                            println!(
198
                                "Fetched image {}: {} chunks, {} in {:.1} s",
199
                                &hash[..12],
200
                                m.chunks,
201
                                images::human(m.bytes),
202
                                m.elapsed.as_secs_f64()
203
                            );
204
                            Ok(())
205
                        }
206
                        None => anyhow::bail!("the hub does not hold image {}; send it with flow rootfs push", &hash[..12.min(hash.len())]),
207
                    }
208
                }
e0918b88 Andy McCall 209
                RootfsCmd::List => {
210
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
211
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
212
                        if let Some(h) = e.file_name().to_string_lossy().strip_suffix(".ext4") {
213
                            println!("cas://blob/{h}  {} MiB", size >> 20);
214
                        }
215
                    }
216
                    Ok(())
217
                }
218
            }
219
        }
e2c12561 Andy McCall ↶ 220
        Cmd::Run { jobs, dir, timeout, vars, secrets } => {
e0918b88 Andy McCall 221
            let home = Home::locate(cli.home.as_deref())?;
e2c12561 Andy McCall ↶ 222
            let pairs = |what: &str, list: &[String]| {
223
                list.iter()
224
                    .map(|e| {
225
                        e.split_once('=')
226
                            .map(|(k, v)| (k.to_string(), v.to_string()))
227
                            .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE"))
228
                    })
229
                    .collect::<Result<Vec<_>>>()
230
            };
231
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
232
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
f98a8f78 Andy McCall ↶ 233
        }
62829d15 Andy McCall ↶ 234
        Cmd::Runner { cmd: Some(RunnerCmd::Bench) } => {
235
            let home = Home::locate(cli.home.as_deref())?;
236
            let (speed, ms) = facts::bench();
237
            let f = facts::now(&home, Some(speed));
238
            println!("speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
239
            println!("free disk {} MiB at {}", f.free_disk_mib.unwrap_or(0), home.root.display());
240
            println!("load {:.2}", f.load_x100.unwrap_or(0) as f64 / 100.0);
241
            if let Some(k) = f.kernel {
242
                println!("kernel {k}");
243
            }
244
            Ok(())
245
        }
f98a8f78 Andy McCall ↶ 246
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
247
            let home = Home::locate(cli.home.as_deref())?;
248
            if std::env::var_os("OXY_HOME").is_none() {
249
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
250
            }
251
            let name = name.unwrap_or_else(|| {
252
                std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into())
253
            });
254
            let token = match token {
255
                Some(t) => t,
256
                None => {
257
                    eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:");
258
                    let mut line = String::new();
259
                    std::io::stdin().read_line(&mut line)?;
260
                    line.trim().to_string()
261
                }
262
            };
263
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
264
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
265
            Ok(())
e0918b88 Andy McCall 266
        }
f98a8f78 Andy McCall ↶ 267
        Cmd::Runner { cmd: None } => {
e0918b88 Andy McCall 268
            let home = Home::locate(cli.home.as_deref())?;
269
            // The runner's oxy state (identity, user config) stays in its home.
270
            if std::env::var_os("OXY_HOME").is_none() {
271
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
272
            }
273
            runner::serve(&home)
9e8f4a56 Andy McCall ↶ 274
        }
ea56c56e Andy McCall ↶ 275
        Cmd::Tiers => {
276
            let home = Home::locate(cli.home.as_deref())?;
277
            match runner::tiers(&home) {
278
                Ok((have, missing)) => {
279
                    for t in have {
280
                        println!("{t}: available");
281
                    }
282
                    for why in missing {
283
                        println!("{}", why.replacen(": ", ": unavailable: ", 1));
284
                    }
285
                }
286
                Err(why) => println!("{why}"),
287
            }
288
            Ok(())
289
        }
9e8f4a56 Andy McCall ↶ 290
        Cmd::Wait { url, state, timeout } => {
291
            let token = std::env::var("OXY_TOKEN").ok();
292
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
293
            let code =
294
                wait::wait(&url, &state, token.as_deref(), ca.as_deref(), Duration::from_secs(timeout * 60), Duration::from_secs(15))?;
295
            std::process::exit(code);
e0918b88 Andy McCall 296
        }
297
    }
4cf0c99a Andy McCall ↶ 298
}
299
e38bb032 Andy McCall ↶ 300
/// The hub image commands talk to: `--hub`, else $OH_HUB, else the
301
/// runner's. The token: $OXY_TOKEN, else the login `oh auth login` (or
302
/// `oxy auth login`) saved for that hub in Oxygen's credential store
303
/// (oxygenflow#44), else the runner's own.
4cf0c99a Andy McCall ↶ 304
fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> {
305
    let runner = home.config.runner.clone();
e38bb032 Andy McCall ↶ 306
    let hub = hub
307
        .or_else(|| std::env::var("OH_HUB").ok().filter(|h| !h.is_empty()))
308
        .or_else(|| runner.as_ref().map(|r| r.hub.clone()))
309
        .context("name the hub with --hub https://HUB (or set OH_HUB)")?;
310
    let (saved_token, saved_ca) = oxy_core::credentials::Credentials::user().lookup(&hub);
4cf0c99a Andy McCall ↶ 311
    let token = std::env::var("OXY_TOKEN")
312
        .ok()
e38bb032 Andy McCall ↶ 313
        .filter(|t| !t.is_empty())
314
        .or(saved_token)
4cf0c99a Andy McCall ↶ 315
        .or_else(|| runner.as_ref().map(|r| r.token.clone()))
e38bb032 Andy McCall ↶ 316
        .with_context(|| format!("no token for {hub}: sign in with `oh auth login --hub {hub}`, or set OXY_TOKEN"))?;
317
    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()));
4cf0c99a Andy McCall ↶ 318
    images::Client::new(&hub, &token, ca.as_deref())
e0918b88 Andy McCall 319
}
320
321
fn load(file: &Path) -> Result<Pipeline> {
322
    let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
3b9dddc9 Andy McCall ↶ 323
    Pipeline::parse(&src).map_err(|p| anyhow::anyhow!("{}", not_valid(file, &p.to_string())))
e0918b88 Andy McCall 324
}
325
3b9dddc9 Andy McCall ↶ 326
/// Why a pipeline did not parse. When it names something this flow does
327
/// not know (a node, an output, an event), the pipeline may simply be
328
/// newer than this flow (oxygenflow#43): say so, with this flow's version.
329
fn not_valid(file: &Path, problems: &str) -> String {
330
    if problems.contains("unknown ") {
331
        format!(
332
            "{} uses something this flow ({}) does not know:\n{problems}\n\
333
             If the pipeline was written for a newer OxygenFlow, update flow (build it from oxygenflow main); \
334
             otherwise fix the lines named.",
335
            file.display(),
336
            env!("OXY_VERSION_LONG")
337
        )
338
    } else {
339
        format!("{} is not valid:\n{problems}", file.display())
340
    }
341
}
342
e0918b88 Andy McCall 343
fn check(file: &Path) -> Result<()> {
344
    let p = load(file)?;
345
    let depths = p.depths();
346
    for j in p.order() {
347
        let sb = &p.sandboxes[&j.sandbox];
348
        let after = if j.depends_on.is_empty() { String::new() } else { format!(" after {}", j.depends_on.join(", ")) };
349
        println!("{}{}  {} [{}]{after}", "  ".repeat(depths[j.name.as_str()]), j.name, j.exec.join(" "), sb.runtime.as_str());
350
    }
351
    println!("pipeline {}", hex::encode(p.hash()));
352
    Ok(())
353
}
354
a72abeee Andy McCall ↶ 355
/// The image environment file flow-init reads (`/etc/flow/env`): one
356
/// `NAME=value` per line, names checked as a pipeline's are.
357
fn image_env_file(env: &[String]) -> Result<String> {
358
    let mut out = String::new();
359
    for e in env {
360
        let Some((k, v)) = e.split_once('=') else { bail!("--env {e:?}: expected NAME=value") };
361
        let ok = !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
362
        if !ok || v.contains('\n') || v.contains('\0') {
363
            bail!("--env {e:?}: a name is letters, digits and _, and a value is one line");
364
        }
365
        out.push_str(&format!("{k}={v}\n"));
366
    }
367
    Ok(out)
368
}
369
1abadd4a Andy McCall ↶ 370
/// The architecture an ELF file was built for, by its header.
371
fn elf_arch(path: &Path) -> Option<&'static str> {
372
    let bytes = std::fs::read(path).ok()?;
373
    if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" {
374
        return None;
375
    }
376
    let little = bytes[5] == 1;
377
    let machine = if little { u16::from_le_bytes([bytes[18], bytes[19]]) } else { u16::from_be_bytes([bytes[18], bytes[19]]) };
378
    Some(match machine {
379
        0x3E => "x86_64",
380
        0xB7 => "aarch64",
381
        0xF3 => "riscv64",
382
        0x03 => "x86",
383
        0x28 => "arm",
384
        0x15 => "powerpc64",
385
        0x16 => "s390x",
386
        _ => return None,
387
    })
388
}
389
390
/// The architecture of an unpacked rootfs: its shell's, or the first
391
/// program's found under bin.
392
fn tree_arch(tree: &Path) -> Option<&'static str> {
393
    for rel in ["bin/sh", "bin/busybox", "usr/bin/env", "bin/bash", "usr/bin/sh"] {
394
        let p = tree.join(rel);
395
        let p = std::fs::canonicalize(&p).unwrap_or(p);
396
        if let Some(a) = elf_arch(&p) {
397
            return Some(a);
398
        }
399
    }
400
    None
401
}
402
a72abeee Andy McCall ↶ 403
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>, env: &[String]) -> Result<()> {
404
    let env_file = image_env_file(env)?;
e0918b88 Andy McCall 405
    if let Some(want) = sha256 {
406
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
407
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
408
        if got != want.to_lowercase() {
409
            bail!("{} has SHA-256 {got}, expected {want}", tarball.display());
410
        }
411
    }
412
    let tmp = home.root.join("work").join(format!("rootfs-{}", std::process::id()));
413
    let _ = std::fs::remove_dir_all(&tmp);
414
    let tree = tmp.join("tree");
415
    std::fs::create_dir_all(&tree)?;
416
    let result = (|| -> Result<String> {
417
        let status = std::process::Command::new("tar").arg("-xf").arg(tarball).arg("-C").arg(&tree).arg("--no-same-owner").status()?;
418
        if !status.success() {
419
            bail!("tar could not unpack {}", tarball.display());
1abadd4a Andy McCall ↶ 420
        }
421
        // The image's architecture must be this machine's: there is no
422
        // emulation tier (spec §11.2).
423
        let arch = tree_arch(&tree).unwrap_or("unknown");
424
        if arch != "unknown" && arch != std::env::consts::ARCH {
425
            bail!(
426
                "{} is a {arch} image; this runner is {}, and jobs here run no other architecture",
427
                tarball.display(),
428
                std::env::consts::ARCH
429
            );
e0918b88 Andy McCall 430
        }
431
        // Mount points flow-init needs (docs/m0.md §2).
432
        for d in ["src", "tmp", "proc", "dev", "run"] {
433
            std::fs::create_dir_all(tree.join(d))?;
a72abeee Andy McCall ↶ 434
        }
435
        if !env_file.is_empty() {
436
            std::fs::create_dir_all(tree.join("etc/flow"))?;
437
            std::fs::write(tree.join("etc/flow/env"), &env_file)?;
e0918b88 Andy McCall 438
        }
439
        let used: u64 = ignore::WalkBuilder::new(&tree)
440
            .hidden(false)
441
            .ignore(false)
442
            .git_ignore(false)
443
            .build()
444
            .flatten()
445
            .filter_map(|e| e.metadata().ok())
446
            .map(|m| m.len().max(4096))
447
            .sum();
448
        let mib = used * 13 / 10 / (1 << 20) + 64;
449
        let img = tmp.join("rootfs.ext4");
450
        let status = std::process::Command::new("mkfs.ext4")
451
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0", "-d"])
452
            .arg(&tree)
453
            .arg(&img)
454
            .arg(format!("{mib}M"))
455
            .status()
456
            .context("run mkfs.ext4")?;
457
        if !status.success() {
458
            bail!("mkfs.ext4 failed");
459
        }
460
        let hash = home::hash_file(&img)?;
461
        std::fs::rename(&img, home.rootfs(&hash))?;
1abadd4a Andy McCall ↶ 462
        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) });
463
        std::fs::write(home.image_manifest(&hash), manifest.to_string())?;
e0918b88 Andy McCall 464
        Ok(hash)
465
    })();
466
    let _ = std::fs::remove_dir_all(&tmp);
467
    let hash = result?;
468
    println!("rootfs \"cas://blob/{hash}\"");
469
    Ok(())
470
}
471
e2c12561 Andy McCall ↶ 472
fn run_local(
473
    home: &Home,
474
    dir: &Path,
475
    only: &[String],
476
    timeout: Duration,
477
    vars: &[(String, String)],
478
    secrets: &[(String, String)],
479
) -> Result<()> {
e0918b88 Andy McCall 480
    let p = load(&dir.join(PIPELINE_PATH))?;
481
    // The named jobs and everything they depend on.
482
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
483
    for name in only {
484
        if !p.jobs.contains_key(name) {
485
            bail!("no job {name:?} in the pipeline");
486
        }
487
    }
488
    if !only.is_empty() {
489
        loop {
490
            let more: Vec<String> = wanted.iter().flat_map(|j| p.jobs[j].depends_on.clone()).filter(|d| !wanted.contains(d)).collect();
491
            if more.is_empty() {
492
                break;
493
            }
494
            wanted.extend(more);
495
        }
496
    }
497
    let scratch = home.root.join("work").join(format!("local-{}", std::process::id()));
498
    let _ = std::fs::remove_dir_all(&scratch);
499
    std::fs::create_dir_all(scratch.join("tree"))?;
500
    workspace::copy_tree(dir, &scratch.join("tree"))?;
501
    let mut failed: BTreeSet<String> = BTreeSet::new();
502
    let mut worst = 0;
503
    for job in p.order() {
504
        if !only.is_empty() && !wanted.contains(&job.name) {
505
            continue;
506
        }
507
        if let Some(d) = job.depends_on.iter().find(|d| failed.contains(*d)) {
508
            println!("── {} skipped: {d} failed", job.name);
509
            failed.insert(job.name.clone());
510
            continue;
511
        }
512
        let sb = &p.sandboxes[&job.sandbox];
ea56c56e Andy McCall ↶ 513
        if sb.runtime == Runtime::Wasm {
e0918b88 Andy McCall 514
            bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str());
515
        }
516
        println!("── {} ({})", job.name, job.exec.join(" "));
517
        let js = scratch.join(&job.name);
518
        std::fs::create_dir_all(&js)?;
c58efc6a Andy McCall ↶ 519
        let job = &job
520
            .in_sandbox(sb)
521
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
522
            .with_flow_env(vars);
720eb819 Andy McCall ↶ 523
        // Identities come from a hub: a local run has none.
524
        for i in &job.identities {
525
            println!("── {}: identity {:?} is not available locally; {} will be missing", job.name, i.audience, i.mount_path);
526
        }
e2c12561 Andy McCall ↶ 527
        // Each secret the job declares must be given, as the hub would.
528
        let inputs = job
529
            .secrets
530
            .iter()
531
            .map(|s| {
532
                let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone());
533
                value
534
                    .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value })
535
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
536
            })
537
            .collect::<Result<Vec<_>>>()?;
ea56c56e Andy McCall ↶ 538
        // Each job starts from the same files: jobs do not share writes.
539
        let out = if sb.runtime == Runtime::Syscall {
540
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
541
            let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy());
542
            syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)?
543
        } else {
544
            workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
545
            microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?
546
        };
547
        let _ = workspace::remove_tree(&js);
e0918b88 Andy McCall 548
        match out.exit_code {
549
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
550
            other => {
551
                println!(
552
                    "── {} failed ({}) in {} ms",
553
                    job.name,
554
                    other.map(|c| format!("exit {c}")).unwrap_or_else(|| "no exit code".into()),
555
                    out.duration_ms
556
                );
557
                failed.insert(job.name.clone());
558
                worst = 1;
559
            }
560
        }
561
    }
ea56c56e Andy McCall ↶ 562
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 563
    if worst != 0 {
564
        std::process::exit(worst);
565
    }
566
    Ok(())
567
}
568
569
fn print_record(r: &Record) {
570
    match r.stream.as_str() {
571
        "out" => println!("   {}", r.text),
572
        "err" => eprintln!("   {}", r.text),
573
        s => eprintln!("   [{s}] {}", r.text),
a72abeee Andy McCall ↶ 574
    }
575
}
576
577
#[cfg(test)]
578
mod tests {
3b9dddc9 Andy McCall ↶ 579
    #[test]
580
    fn a_pipeline_newer_than_flow_is_named_as_such() {
581
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 92: unknown output `sbom`; outputs are …");
582
        assert!(m.contains("does not know") && m.contains(env!("OXY_VERSION_LONG")) && m.contains("newer OxygenFlow"), "{m}");
583
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 3: a job needs a sandbox");
584
        assert!(m.contains("is not valid") && !m.contains("newer"), "{m}");
585
    }
586
a72abeee Andy McCall ↶ 587
    use super::image_env_file;
588
589
    #[test]
590
    fn image_environments_are_checked_lines() {
591
        let f = image_env_file(&["PATH=/opt/bin:/usr/bin".into(), "X16EMU=/opt/x16emu".into(), "EMPTY=".into()]).unwrap();
592
        assert_eq!(f, "PATH=/opt/bin:/usr/bin\nX16EMU=/opt/x16emu\nEMPTY=\n");
593
        assert!(image_env_file(&["NOEQUALS".into()]).is_err());
594
        assert!(image_env_file(&["1X=y".into()]).is_err());
595
        assert!(image_env_file(&["X=two\nlines".into()]).is_err());
596
        assert_eq!(image_env_file(&[]).unwrap(), "");
e0918b88 Andy McCall 597
    }
598
}