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