OXYGENHUB
◆ main oxygenflow / crates / flow / src / main.rs
32.4 KiB · Rust · last changed flow deps check: is every crate of a Cargo.lock in the image (closes #101) 1 day 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
5cf1e8ae Andy McCall ↶ 4
use flow::{facts, home, images, microvm, runner, syscall, wait, wasm, workspace};
e0918b88 Andy McCall 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)]
2d025bbf Andy McCall ↶ 16
#[command(name = "flow", version = env!("OXY_VERSION_LONG"), about = "OxygenFlow: hermetic pipelines for Oxygen repositories")]
e0918b88 Andy McCall 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)]
f98a8f78 Andy McCall ↶ 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
    },
62829d15 Andy McCall ↶ 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,
4dd392d1 Andy McCall ↶ 47
    /// Ask this home's running runner to drain: claim nothing more, finish
48
    /// and report what it holds, then stop (as SIGTERM does on Linux; on
49
    /// Windows, the way to stop a runner without losing its job)
50
    Drain,
85275e1e Andy McCall ↶ 51
    /// Install a new flow build in place of this one (docs/jetstream.md
52
    /// §8.2): check it, keep this one as flow.prev, and have the running
53
    /// runner leave with notice so its unit starts the new build. A build
54
    /// that does not reach the hub goes back to flow.prev by itself
55
    Update {
56
        /// The new flow binary
57
        #[arg(long)]
58
        from: PathBuf,
59
        /// Seconds the running runner has to finish what it holds; jobs
60
        /// that will not finish in time are handed back with their checkpoints
61
        #[arg(long, default_value_t = 600)]
62
        notice: u64,
63
        /// Check the build and say what would happen, changing nothing
64
        #[arg(long)]
65
        dry_run: bool,
66
        /// Install a build older than this one
67
        #[arg(long)]
68
        allow_older: bool,
69
        /// The installed binary to replace (default: this one)
70
        #[arg(long)]
71
        exe: Option<PathBuf>,
72
    },
f98a8f78 Andy McCall ↶ 73
}
74
75
#[derive(Subcommand)]
f3b5b86e Andy McCall ↶ 76
enum DepsCmd {
77
    /// Whether every crate a Cargo.lock needs is in the image at that
78
    /// version; exit 1 naming each missing one and the versions it has
79
    Check {
80
        /// The lock file (default: Cargo.lock)
81
        #[arg(default_value = "Cargo.lock")]
82
        lock: PathBuf,
83
        #[command(flatten)]
84
        image: DepsSource,
85
    },
86
    /// Print the image's crates as `NAME VERSION` lines (its crates.txt)
87
    List {
88
        #[command(flatten)]
89
        image: DepsSource,
90
    },
91
}
92
93
/// Where the image's crate set comes from: its crate list (the crates.txt
94
/// published with the image), a vendor directory, or the lock files it is
95
/// built from; inside a job, /opt/vendor.
96
#[derive(clap::Args)]
97
#[group(multiple = false)]
98
struct DepsSource {
99
    /// The image's crate list (crates.txt, written by images/rust/build.sh)
100
    #[arg(long, value_name = "FILE")]
101
    list: Option<PathBuf>,
102
    /// A cargo vendor directory (default inside a job: /opt/crates.txt, else /opt/vendor)
103
    #[arg(long, value_name = "DIR")]
104
    vendor: Option<PathBuf>,
105
    /// The lock files the image vendors (oxygen's, oxygenhub's,
106
    /// oxygenflow's), as images/rust/build.sh does (repeatable)
107
    #[arg(long = "image-lock", value_name = "LOCK")]
108
    locks: Vec<PathBuf>,
109
}
110
111
impl DepsSource {
112
    fn crates(&self) -> Result<BTreeSet<flow::deps::Crate>> {
113
        let read = |p: &Path| std::fs::read_to_string(p).with_context(|| p.display().to_string());
114
        if let Some(f) = &self.list {
115
            return Ok(flow::deps::from_list(&read(f)?));
116
        }
117
        if !self.locks.is_empty() {
118
            let mut all = BTreeSet::new();
119
            for l in &self.locks {
120
                all.extend(flow::deps::from_lock(&read(l)?).map_err(|e| anyhow::anyhow!("{}: {e}", l.display()))?);
121
            }
122
            return Ok(all);
123
        }
124
        // Inside a job: the image's own list, else its vendor directory.
125
        if self.vendor.is_none() && Path::new("/opt/crates.txt").is_file() {
126
            return Ok(flow::deps::from_list(&read(Path::new("/opt/crates.txt"))?));
127
        }
128
        let dir = self.vendor.clone().unwrap_or_else(|| PathBuf::from("/opt/vendor"));
129
        if self.vendor.is_none() && !dir.is_dir() {
130
            bail!("say what the image holds: --list crates.txt (published with the image), --vendor DIR, or --image-lock for each lock file it is built from; inside a job, /opt/vendor is read");
131
        }
132
        flow::deps::from_vendor(&dir).map_err(|e| anyhow::anyhow!(e))
133
    }
134
}
135
136
#[derive(Subcommand)]
e0918b88 Andy McCall 137
enum Cmd {
138
    /// Validate a pipeline and print its jobs and hash
139
    Check {
140
        /// The pipeline file (default: .flow/pipeline.kdl)
141
        file: Option<PathBuf>,
142
    },
143
    /// Print a file's BLAKE3, for pinning in flow.toml
144
    Hash { file: PathBuf },
f3b5b86e Andy McCall ↶ 145
    /// The crates the build image vendors, for jobs with no network (#101)
146
    Deps {
147
        #[command(subcommand)]
148
        cmd: DepsCmd,
149
    },
e0918b88 Andy McCall 150
    /// Rootfs images for sandboxes
151
    Rootfs {
152
        #[command(subcommand)]
153
        cmd: RootfsCmd,
154
    },
155
    /// Run the pipeline on this directory's files, locally
156
    Run {
157
        /// Only these jobs (and what they depend on)
158
        jobs: Vec<String>,
159
        /// The directory to build (default: the current one)
160
        #[arg(long, default_value = ".")]
161
        dir: PathBuf,
162
        /// Minutes before a job is stopped
163
        #[arg(long, default_value_t = 60)]
164
        timeout: u64,
c58efc6a Andy McCall ↶ 165
        /// A variable for every job, as the hub's settings would give it;
166
        /// the pipeline's own `env` wins
167
        #[arg(long = "var", value_name = "NAME=VALUE")]
168
        vars: Vec<String>,
e2c12561 Andy McCall ↶ 169
        /// A secret a job's `secret` block names, as the hub would supply
170
        /// it; mounted as a file, never in the environment
171
        #[arg(long = "secret", value_name = "NAME=VALUE")]
172
        secrets: Vec<String>,
f98a8f78 Andy McCall ↶ 173
    },
174
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
175
    /// runner.toml `flow runner register` wrote)
176
    Runner {
177
        #[command(subcommand)]
178
        cmd: Option<RunnerCmd>,
e0918b88 Andy McCall 179
    },
9e8f4a56 Andy McCall ↶ 180
    /// Follow the checks of a pushed state on OxygenHub until they finish:
181
    /// exit 0 when all passed, 1 when one failed, 2 at the time limit.
182
    /// Uses $OXY_TOKEN and $OXY_CA as oxy does.
183
    Wait {
184
        /// The repository's URL on the hub (https://hub/owner/name)
185
        url: String,
186
        /// The state hash the push moved the bookmark to (`oxy push --json` prints it)
187
        state: String,
188
        /// Minutes to wait at most
189
        #[arg(long, default_value_t = 60)]
190
        timeout: u64,
191
    },
ea56c56e Andy McCall ↶ 192
    /// Show which isolation tiers this machine can run jobs in, and why
193
    /// the others cannot
194
    Tiers,
195
    /// The syscall tier's sandbox helper, started by the runner for each job
196
    #[command(name = "__sandbox", hide = true)]
197
    Sandbox { spec: PathBuf },
198
    /// Check that this machine can run the syscall tier
199
    #[command(name = "__sandbox-probe", hide = true)]
200
    SandboxProbe,
494a1b9a Andy McCall ↶ 201
    /// Rename a program that cannot rename its own file (Windows; #83)
202
    #[command(name = "__rename", hide = true)]
203
    Rename { from: PathBuf, to: PathBuf },
28cc603f Andy McCall ↶ 204
    /// The WASM tier's helper, started by the runner for each job
205
    #[command(name = "__wasm", hide = true)]
206
    Wasm { spec: PathBuf },
e0918b88 Andy McCall 207
}
208
209
#[derive(Subcommand)]
210
enum RootfsCmd {
211
    /// Make an ext4 rootfs image from a root file system tarball and store it by hash
212
    Import {
213
        tarball: PathBuf,
214
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
215
        #[arg(long)]
216
        sha256: Option<String>,
a72abeee Andy McCall ↶ 217
        /// The image's own environment, NAME=value (repeatable): what a
218
        /// Dockerfile's ENV sets and `docker export` loses. Jobs start from
219
        /// it; their sandbox's and their own `env` override it
220
        #[arg(long = "env", value_name = "NAME=VALUE")]
221
        env: Vec<String>,
e0918b88 Andy McCall 222
    },
223
    /// List the images this home holds
224
    List,
4cf0c99a Andy McCall ↶ 225
    /// Send an image to a hub, so its runners fetch it instead of having it
e38bb032 Andy McCall ↶ 226
    /// copied to them: only the chunks the hub lacks travel. Signs in as
227
    /// `oh` is signed in to that hub, or with $OXY_TOKEN and $OXY_CA
4cf0c99a Andy McCall ↶ 228
    Push {
229
        /// The image's hash, as pipelines pin it (cas://blob/HASH)
230
        hash: String,
e38bb032 Andy McCall ↶ 231
        /// The hub (default: $OH_HUB, else the runner's, from runner.toml or flow.toml)
4cf0c99a Andy McCall ↶ 232
        #[arg(long)]
233
        hub: Option<String>,
234
    },
235
    /// Fetch an image a hub holds into this home, as a runner does for a
e38bb032 Andy McCall ↶ 236
    /// job whose image it lacks. Signs in as `oh` is, or with $OXY_TOKEN
4cf0c99a Andy McCall ↶ 237
    Pull {
238
        hash: String,
239
        #[arg(long)]
240
        hub: Option<String>,
241
    },
e0918b88 Andy McCall 242
}
243
244
fn main() {
245
    let cli = Cli::parse();
246
    if let Err(e) = run(cli) {
247
        eprintln!("flow: {e:#}");
248
        std::process::exit(1);
249
    }
250
}
251
252
fn run(cli: Cli) -> Result<()> {
253
    match cli.cmd {
ea56c56e Andy McCall ↶ 254
        Cmd::Sandbox { spec } => std::process::exit(syscall::sandbox_main(&spec)),
255
        Cmd::SandboxProbe => std::process::exit(syscall::probe_main()),
494a1b9a Andy McCall ↶ 256
        Cmd::Rename { from, to } => {
257
            flow::portable::rename(&from, &to).with_context(|| format!("renaming {} to {}", from.display(), to.display()))?;
258
            Ok(())
259
        }
28cc603f Andy McCall ↶ 260
        Cmd::Wasm { spec } => std::process::exit(wasm::wasm_main(&spec)),
e0918b88 Andy McCall 261
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
f3b5b86e Andy McCall ↶ 262
        Cmd::Deps { cmd: DepsCmd::List { image } } => {
263
            print!("{}", flow::deps::render_list(&image.crates()?));
264
            Ok(())
265
        }
266
        Cmd::Deps { cmd: DepsCmd::Check { lock, image } } => {
267
            let text = std::fs::read_to_string(&lock).with_context(|| lock.display().to_string())?;
268
            let need = flow::deps::from_lock(&text).map_err(|e| anyhow::anyhow!("{}: {e}", lock.display()))?;
269
            let missing = flow::deps::missing(&need, &image.crates()?);
270
            if missing.is_empty() {
271
                println!("deps: all {} crates of {} are in the image", need.len(), lock.display());
272
                return Ok(());
273
            }
274
            eprintln!("deps: {} of {} crates of {} are not in the image:", missing.len(), need.len(), lock.display());
275
            for ((name, version), has) in &missing {
276
                let instead = match has.as_slice() {
277
                    [] => "the image has no version of it".to_string(),
278
                    vs => format!("the image has {}: cargo update -p {name}@{version} --precise {}", vs.join(", "), vs.last().unwrap()),
279
                };
280
                eprintln!("  {name} {version}  ({instead})");
281
            }
282
            eprintln!("pin a version the image has, or rebuild the image with this workspace's lock (images/rust/build.sh)");
283
            std::process::exit(1);
284
        }
e0918b88 Andy McCall 285
        Cmd::Hash { file } => {
286
            println!("{}", home::hash_file(&file)?);
287
            Ok(())
288
        }
289
        Cmd::Rootfs { cmd } => {
290
            let home = Home::locate(cli.home.as_deref())?;
291
            match cmd {
a72abeee Andy McCall ↶ 292
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
4cf0c99a Andy McCall ↶ 293
                RootfsCmd::Push { hash, hub } => {
294
                    let client = image_client(&home, hub)?;
295
                    eprintln!("Sending image {} …", &hash[..12.min(hash.len())]);
296
                    let m = client.push(&home, &hash)?;
297
                    println!(
298
                        "Sent image {}: {} chunks the hub lacked, {} in {:.1} s",
299
                        &hash[..12],
300
                        m.chunks,
301
                        images::human(m.bytes),
302
                        m.elapsed.as_secs_f64()
303
                    );
304
                    Ok(())
305
                }
306
                RootfsCmd::Pull { hash, hub } => {
307
                    let client = image_client(&home, hub)?;
308
                    match client.fetch(&home, &hash)? {
309
                        Some(m) => {
310
                            println!(
311
                                "Fetched image {}: {} chunks, {} in {:.1} s",
312
                                &hash[..12],
313
                                m.chunks,
314
                                images::human(m.bytes),
315
                                m.elapsed.as_secs_f64()
316
                            );
317
                            Ok(())
318
                        }
319
                        None => anyhow::bail!("the hub does not hold image {}; send it with flow rootfs push", &hash[..12.min(hash.len())]),
320
                    }
321
                }
e0918b88 Andy McCall 322
                RootfsCmd::List => {
323
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
324
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
325
                        if let Some(h) = e.file_name().to_string_lossy().strip_suffix(".ext4") {
326
                            println!("cas://blob/{h}  {} MiB", size >> 20);
327
                        }
328
                    }
329
                    Ok(())
330
                }
331
            }
332
        }
e2c12561 Andy McCall ↶ 333
        Cmd::Run { jobs, dir, timeout, vars, secrets } => {
e0918b88 Andy McCall 334
            let home = Home::locate(cli.home.as_deref())?;
e2c12561 Andy McCall ↶ 335
            let pairs = |what: &str, list: &[String]| {
336
                list.iter()
337
                    .map(|e| {
338
                        e.split_once('=')
339
                            .map(|(k, v)| (k.to_string(), v.to_string()))
340
                            .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE"))
341
                    })
342
                    .collect::<Result<Vec<_>>>()
343
            };
344
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
345
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
f98a8f78 Andy McCall ↶ 346
        }
62829d15 Andy McCall ↶ 347
        Cmd::Runner { cmd: Some(RunnerCmd::Bench) } => {
348
            let home = Home::locate(cli.home.as_deref())?;
349
            let (speed, ms) = facts::bench();
350
            let f = facts::now(&home, Some(speed));
351
            println!("speed {speed} (class {}), benchmark {ms} ms", flow_core::api::Facts::class_of(speed));
352
            println!("free disk {} MiB at {}", f.free_disk_mib.unwrap_or(0), home.root.display());
a63d3dcd Andy McCall ↶ 353
            match f.load_x100 {
354
                Some(l) => println!("load {:.2}", l as f64 / 100.0),
355
                None => println!("load not measured here"),
356
            }
62829d15 Andy McCall ↶ 357
            if let Some(k) = f.kernel {
358
                println!("kernel {k}");
359
            }
360
            Ok(())
361
        }
f98a8f78 Andy McCall ↶ 362
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
363
            let home = Home::locate(cli.home.as_deref())?;
364
            if std::env::var_os("OXY_HOME").is_none() {
365
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
366
            }
367
            let name = name.unwrap_or_else(|| {
368
                std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into())
369
            });
370
            let token = match token {
371
                Some(t) => t,
372
                None => {
373
                    eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:");
374
                    let mut line = String::new();
375
                    std::io::stdin().read_line(&mut line)?;
376
                    line.trim().to_string()
377
                }
378
            };
379
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
380
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
85275e1e Andy McCall ↶ 381
            Ok(())
382
        }
4dd392d1 Andy McCall ↶ 383
        Cmd::Runner { cmd: Some(RunnerCmd::Drain) } => {
384
            let home = Home::locate(cli.home.as_deref())?;
385
            if flow::update::ask_to_drain(&home.root)? {
386
                println!("asked the runner to drain: it claims nothing more and stops once its jobs are done and reported");
387
            } else {
388
                println!("no runner of this home is running; the next one starts afresh");
389
            }
390
            Ok(())
391
        }
85275e1e Andy McCall ↶ 392
        Cmd::Runner { cmd: Some(RunnerCmd::Update { from, notice, dry_run, allow_older, exe }) } => {
393
            let home = Home::locate(cli.home.as_deref())?;
394
            let exe = match exe {
395
                Some(e) => e,
396
                None => std::env::current_exe().context("finding this flow binary")?,
397
            };
398
            let done = flow::update::install(&home, &exe, &from, Duration::from_secs(notice), dry_run, allow_older)?;
399
            if dry_run {
400
                println!(
401
                    "flow {} checks out: it would replace flow {} at {} (dry run: nothing changed)",
402
                    done.to,
403
                    done.from,
404
                    done.exe.display()
405
                );
406
                return Ok(());
407
            }
408
            println!("flow {} installed at {}; flow {} kept as {}", done.to, done.exe.display(), done.from, flow::update::PREV);
409
            match done.told {
410
                Some(pid) => println!("the runner (pid {pid}) leaves within {notice} s, then its unit starts flow {}", done.to),
411
                None => println!(
412
                    "no runner of this home was told (none runs, or one from before runner updates, which does not listen): \
413
                     restart its unit, e.g. `systemctl restart --no-block flow-runner`; it drains and starts flow {}",
414
                    done.to
415
                ),
416
            }
f98a8f78 Andy McCall ↶ 417
            Ok(())
e0918b88 Andy McCall 418
        }
f98a8f78 Andy McCall ↶ 419
        Cmd::Runner { cmd: None } => {
e0918b88 Andy McCall 420
            let home = Home::locate(cli.home.as_deref())?;
85275e1e Andy McCall ↶ 421
            // A new build on trial that keeps failing to start goes back to
422
            // the one before, which then runs in its place (#85).
423
            if let flow::update::Start::RolledBack(p) = flow::update::on_start(&home.root)? {
3554afef Andy McCall ↶ 424
                #[cfg(unix)]
425
                {
426
                    use std::os::unix::process::CommandExt;
427
                    let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
428
                    bail!("starting flow {} again after the rollback: {err}", p.from);
429
                }
494a1b9a Andy McCall ↶ 430
                // Windows has no exec: run the restored build and end with it.
3554afef Andy McCall ↶ 431
                #[cfg(not(unix))]
494a1b9a Andy McCall ↶ 432
                {
433
                    let status = std::process::Command::new(&p.exe)
434
                        .args(std::env::args_os().skip(1))
435
                        .status()
436
                        .with_context(|| format!("starting flow {} again after the rollback", p.from))?;
437
                    std::process::exit(status.code().unwrap_or(1));
438
                }
85275e1e Andy McCall ↶ 439
            }
e0918b88 Andy McCall 440
            // The runner's oxy state (identity, user config) stays in its home.
441
            if std::env::var_os("OXY_HOME").is_none() {
442
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
443
            }
444
            runner::serve(&home)
9e8f4a56 Andy McCall ↶ 445
        }
ea56c56e Andy McCall ↶ 446
        Cmd::Tiers => {
447
            let home = Home::locate(cli.home.as_deref())?;
448
            match runner::tiers(&home) {
449
                Ok((have, missing)) => {
450
                    for t in have {
451
                        println!("{t}: available");
452
                    }
453
                    for why in missing {
454
                        println!("{}", why.replacen(": ", ": unavailable: ", 1));
455
                    }
456
                }
457
                Err(why) => println!("{why}"),
458
            }
459
            Ok(())
460
        }
9e8f4a56 Andy McCall ↶ 461
        Cmd::Wait { url, state, timeout } => {
462
            let token = std::env::var("OXY_TOKEN").ok();
463
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
464
            let code =
465
                wait::wait(&url, &state, token.as_deref(), ca.as_deref(), Duration::from_secs(timeout * 60), Duration::from_secs(15))?;
466
            std::process::exit(code);
e0918b88 Andy McCall 467
        }
468
    }
4cf0c99a Andy McCall ↶ 469
}
470
e38bb032 Andy McCall ↶ 471
/// The hub image commands talk to: `--hub`, else $OH_HUB, else the
472
/// runner's. The token: $OXY_TOKEN, else the login `oh auth login` (or
473
/// `oxy auth login`) saved for that hub in Oxygen's credential store
474
/// (oxygenflow#44), else the runner's own.
4cf0c99a Andy McCall ↶ 475
fn image_client(home: &Home, hub: Option<String>) -> Result<images::Client> {
476
    let runner = home.config.runner.clone();
e38bb032 Andy McCall ↶ 477
    let hub = hub
478
        .or_else(|| std::env::var("OH_HUB").ok().filter(|h| !h.is_empty()))
479
        .or_else(|| runner.as_ref().map(|r| r.hub.clone()))
480
        .context("name the hub with --hub https://HUB (or set OH_HUB)")?;
481
    let (saved_token, saved_ca) = oxy_core::credentials::Credentials::user().lookup(&hub);
4cf0c99a Andy McCall ↶ 482
    let token = std::env::var("OXY_TOKEN")
483
        .ok()
e38bb032 Andy McCall ↶ 484
        .filter(|t| !t.is_empty())
485
        .or(saved_token)
4cf0c99a Andy McCall ↶ 486
        .or_else(|| runner.as_ref().map(|r| r.token.clone()))
e38bb032 Andy McCall ↶ 487
        .with_context(|| format!("no token for {hub}: sign in with `oh auth login --hub {hub}`, or set OXY_TOKEN"))?;
488
    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 ↶ 489
    images::Client::new(&hub, &token, ca.as_deref())
e0918b88 Andy McCall 490
}
491
492
fn load(file: &Path) -> Result<Pipeline> {
493
    let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
3b9dddc9 Andy McCall ↶ 494
    Pipeline::parse(&src).map_err(|p| anyhow::anyhow!("{}", not_valid(file, &p.to_string())))
e0918b88 Andy McCall 495
}
496
3b9dddc9 Andy McCall ↶ 497
/// Why a pipeline did not parse. When it names something this flow does
498
/// not know (a node, an output, an event), the pipeline may simply be
499
/// newer than this flow (oxygenflow#43): say so, with this flow's version.
500
fn not_valid(file: &Path, problems: &str) -> String {
501
    if problems.contains("unknown ") {
502
        format!(
503
            "{} uses something this flow ({}) does not know:\n{problems}\n\
504
             If the pipeline was written for a newer OxygenFlow, update flow (build it from oxygenflow main); \
505
             otherwise fix the lines named.",
506
            file.display(),
507
            env!("OXY_VERSION_LONG")
508
        )
509
    } else {
510
        format!("{} is not valid:\n{problems}", file.display())
511
    }
512
}
513
e0918b88 Andy McCall 514
fn check(file: &Path) -> Result<()> {
515
    let p = load(file)?;
516
    let depths = p.depths();
517
    for j in p.order() {
518
        let sb = &p.sandboxes[&j.sandbox];
519
        let after = if j.depends_on.is_empty() { String::new() } else { format!(" after {}", j.depends_on.join(", ")) };
520
        println!("{}{}  {} [{}]{after}", "  ".repeat(depths[j.name.as_str()]), j.name, j.exec.join(" "), sb.runtime.as_str());
521
    }
522
    println!("pipeline {}", hex::encode(p.hash()));
523
    Ok(())
524
}
525
a72abeee Andy McCall ↶ 526
/// The image environment file flow-init reads (`/etc/flow/env`): one
527
/// `NAME=value` per line, names checked as a pipeline's are.
528
fn image_env_file(env: &[String]) -> Result<String> {
529
    let mut out = String::new();
530
    for e in env {
531
        let Some((k, v)) = e.split_once('=') else { bail!("--env {e:?}: expected NAME=value") };
532
        let ok = !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
533
        if !ok || v.contains('\n') || v.contains('\0') {
534
            bail!("--env {e:?}: a name is letters, digits and _, and a value is one line");
535
        }
536
        out.push_str(&format!("{k}={v}\n"));
537
    }
538
    Ok(out)
539
}
540
1abadd4a Andy McCall ↶ 541
/// The architecture an ELF file was built for, by its header.
542
fn elf_arch(path: &Path) -> Option<&'static str> {
0077c748 Andy McCall ↶ 543
    let mut head = [0u8; 20];
544
    std::io::Read::read_exact(&mut std::fs::File::open(path).ok()?, &mut head).ok()?;
545
    jet::rootfs::elf_arch(&head)
1abadd4a Andy McCall ↶ 546
}
547
548
/// The architecture of an unpacked rootfs: its shell's, or the first
549
/// program's found under bin.
550
fn tree_arch(tree: &Path) -> Option<&'static str> {
551
    for rel in ["bin/sh", "bin/busybox", "usr/bin/env", "bin/bash", "usr/bin/sh"] {
552
        let p = tree.join(rel);
553
        let p = std::fs::canonicalize(&p).unwrap_or(p);
554
        if let Some(a) = elf_arch(&p) {
555
            return Some(a);
556
        }
557
    }
558
    None
559
}
560
a72abeee Andy McCall ↶ 561
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>, env: &[String]) -> Result<()> {
562
    let env_file = image_env_file(env)?;
e0918b88 Andy McCall 563
    if let Some(want) = sha256 {
564
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
565
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
566
        if got != want.to_lowercase() {
567
            bail!("{} has SHA-256 {got}, expected {want}", tarball.display());
568
        }
569
    }
570
    let tmp = home.root.join("work").join(format!("rootfs-{}", std::process::id()));
571
    let _ = std::fs::remove_dir_all(&tmp);
572
    let tree = tmp.join("tree");
573
    std::fs::create_dir_all(&tree)?;
574
    let result = (|| -> Result<String> {
575
        let status = std::process::Command::new("tar").arg("-xf").arg(tarball).arg("-C").arg(&tree).arg("--no-same-owner").status()?;
576
        if !status.success() {
577
            bail!("tar could not unpack {}", tarball.display());
1abadd4a Andy McCall ↶ 578
        }
579
        // The image's architecture must be this machine's: there is no
580
        // emulation tier (spec §11.2).
581
        let arch = tree_arch(&tree).unwrap_or("unknown");
582
        if arch != "unknown" && arch != std::env::consts::ARCH {
583
            bail!(
584
                "{} is a {arch} image; this runner is {}, and jobs here run no other architecture",
585
                tarball.display(),
586
                std::env::consts::ARCH
587
            );
e0918b88 Andy McCall 588
        }
589
        // Mount points flow-init needs (docs/m0.md §2).
590
        for d in ["src", "tmp", "proc", "dev", "run"] {
591
            std::fs::create_dir_all(tree.join(d))?;
a72abeee Andy McCall ↶ 592
        }
593
        if !env_file.is_empty() {
594
            std::fs::create_dir_all(tree.join("etc/flow"))?;
595
            std::fs::write(tree.join("etc/flow/env"), &env_file)?;
e0918b88 Andy McCall 596
        }
597
        let used: u64 = ignore::WalkBuilder::new(&tree)
598
            .hidden(false)
599
            .ignore(false)
600
            .git_ignore(false)
601
            .build()
602
            .flatten()
603
            .filter_map(|e| e.metadata().ok())
604
            .map(|m| m.len().max(4096))
605
            .sum();
606
        let mib = used * 13 / 10 / (1 << 20) + 64;
607
        let img = tmp.join("rootfs.ext4");
608
        let status = std::process::Command::new("mkfs.ext4")
609
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0", "-d"])
610
            .arg(&tree)
611
            .arg(&img)
612
            .arg(format!("{mib}M"))
613
            .status()
614
            .context("run mkfs.ext4")?;
615
        if !status.success() {
616
            bail!("mkfs.ext4 failed");
617
        }
618
        let hash = home::hash_file(&img)?;
619
        std::fs::rename(&img, home.rootfs(&hash))?;
1abadd4a Andy McCall ↶ 620
        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) });
621
        std::fs::write(home.image_manifest(&hash), manifest.to_string())?;
e0918b88 Andy McCall 622
        Ok(hash)
623
    })();
624
    let _ = std::fs::remove_dir_all(&tmp);
625
    let hash = result?;
626
    println!("rootfs \"cas://blob/{hash}\"");
627
    Ok(())
628
}
629
e2c12561 Andy McCall ↶ 630
fn run_local(
631
    home: &Home,
632
    dir: &Path,
633
    only: &[String],
634
    timeout: Duration,
635
    vars: &[(String, String)],
636
    secrets: &[(String, String)],
637
) -> Result<()> {
e0918b88 Andy McCall 638
    let p = load(&dir.join(PIPELINE_PATH))?;
639
    // The named jobs and everything they depend on.
640
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
641
    for name in only {
642
        if !p.jobs.contains_key(name) {
643
            bail!("no job {name:?} in the pipeline");
644
        }
645
    }
646
    if !only.is_empty() {
647
        loop {
648
            let more: Vec<String> = wanted.iter().flat_map(|j| p.jobs[j].depends_on.clone()).filter(|d| !wanted.contains(d)).collect();
649
            if more.is_empty() {
650
                break;
651
            }
652
            wanted.extend(more);
653
        }
654
    }
655
    let scratch = home.root.join("work").join(format!("local-{}", std::process::id()));
656
    let _ = std::fs::remove_dir_all(&scratch);
657
    std::fs::create_dir_all(scratch.join("tree"))?;
658
    workspace::copy_tree(dir, &scratch.join("tree"))?;
659
    let mut failed: BTreeSet<String> = BTreeSet::new();
660
    let mut worst = 0;
661
    for job in p.order() {
662
        if !only.is_empty() && !wanted.contains(&job.name) {
663
            continue;
664
        }
665
        if let Some(d) = job.depends_on.iter().find(|d| failed.contains(*d)) {
666
            println!("── {} skipped: {d} failed", job.name);
667
            failed.insert(job.name.clone());
668
            continue;
669
        }
670
        let sb = &p.sandboxes[&job.sandbox];
671
        println!("── {} ({})", job.name, job.exec.join(" "));
672
        let js = scratch.join(&job.name);
673
        std::fs::create_dir_all(&js)?;
c58efc6a Andy McCall ↶ 674
        let job = &job
675
            .in_sandbox(sb)
676
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
677
            .with_flow_env(vars);
720eb819 Andy McCall ↶ 678
        // Identities come from a hub: a local run has none.
679
        for i in &job.identities {
680
            println!("── {}: identity {:?} is not available locally; {} will be missing", job.name, i.audience, i.mount_path);
681
        }
e2c12561 Andy McCall ↶ 682
        // Each secret the job declares must be given, as the hub would.
683
        let inputs = job
684
            .secrets
685
            .iter()
686
            .map(|s| {
687
                let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone());
688
                value
689
                    .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value })
690
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
691
            })
692
            .collect::<Result<Vec<_>>>()?;
ea56c56e Andy McCall ↶ 693
        // Each job starts from the same files: jobs do not share writes.
28cc603f Andy McCall ↶ 694
        let out = if sb.runtime == Runtime::Wasm {
695
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
696
            wasm::run(sb, job, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r))?
697
        } else if sb.runtime == Runtime::Syscall {
ea56c56e Andy McCall ↶ 698
            workspace::copy_tree(&scratch.join("tree"), &js.join("tree"))?;
699
            let group = format!("local-{}-{}", std::process::id(), js.file_name().unwrap_or_default().to_string_lossy());
700
            syscall::run(home, sb, job, &inputs, &js.join("tree"), &js, timeout, &mut |r: Record| print_record(&r), &group)?
701
        } else {
702
            workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
703
            microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?
704
        };
705
        let _ = workspace::remove_tree(&js);
e0918b88 Andy McCall 706
        match out.exit_code {
707
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
708
            other => {
709
                println!(
710
                    "── {} failed ({}) in {} ms",
711
                    job.name,
712
                    other.map(|c| format!("exit {c}")).unwrap_or_else(|| "no exit code".into()),
713
                    out.duration_ms
714
                );
715
                failed.insert(job.name.clone());
716
                worst = 1;
717
            }
718
        }
719
    }
ea56c56e Andy McCall ↶ 720
    let _ = workspace::remove_tree(&scratch);
e0918b88 Andy McCall 721
    if worst != 0 {
722
        std::process::exit(worst);
723
    }
724
    Ok(())
725
}
726
727
fn print_record(r: &Record) {
728
    match r.stream.as_str() {
729
        "out" => println!("   {}", r.text),
730
        "err" => eprintln!("   {}", r.text),
731
        s => eprintln!("   [{s}] {}", r.text),
a72abeee Andy McCall ↶ 732
    }
733
}
734
735
#[cfg(test)]
736
mod tests {
3b9dddc9 Andy McCall ↶ 737
    #[test]
738
    fn a_pipeline_newer_than_flow_is_named_as_such() {
739
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 92: unknown output `sbom`; outputs are …");
740
        assert!(m.contains("does not know") && m.contains(env!("OXY_VERSION_LONG")) && m.contains("newer OxygenFlow"), "{m}");
741
        let m = super::not_valid(std::path::Path::new(".flow/pipeline.kdl"), "line 3: a job needs a sandbox");
742
        assert!(m.contains("is not valid") && !m.contains("newer"), "{m}");
743
    }
744
a72abeee Andy McCall ↶ 745
    use super::image_env_file;
746
747
    #[test]
748
    fn image_environments_are_checked_lines() {
749
        let f = image_env_file(&["PATH=/opt/bin:/usr/bin".into(), "X16EMU=/opt/x16emu".into(), "EMPTY=".into()]).unwrap();
750
        assert_eq!(f, "PATH=/opt/bin:/usr/bin\nX16EMU=/opt/x16emu\nEMPTY=\n");
751
        assert!(image_env_file(&["NOEQUALS".into()]).is_err());
752
        assert!(image_env_file(&["1X=y".into()]).is_err());
753
        assert!(image_env_file(&["X=two\nlines".into()]).is_err());
754
        assert_eq!(image_env_file(&[]).unwrap(), "");
e0918b88 Andy McCall 755
    }
756
}