OXYGENHUB
# f98a8f7881e0 oxygenflow / crates / flow / src / main.rs
11.8 KiB · Rust · last changed Register runners with a token; describe them in claims 1 month 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;
5
mod home;
6
mod microvm;
7
mod runner;
9e8f4a56 Andy McCall ↶ 8
mod wait;
e0918b88 Andy McCall 9
mod workspace;
10
11
use anyhow::{bail, Context, Result};
12
use clap::{Parser, Subcommand};
13
use flow_core::api::Record;
14
use flow_core::{Pipeline, Runtime, PIPELINE_PATH};
15
use home::Home;
16
use std::collections::BTreeSet;
17
use std::path::{Path, PathBuf};
18
use std::time::Duration;
19
20
#[derive(Parser)]
21
#[command(name = "flow", version, about = "OxygenFlow: hermetic pipelines for Oxygen repositories")]
22
struct Cli {
23
    /// The Flow home (default $FLOW_HOME, else ~/.local/share/flow).
24
    #[arg(long, global = true)]
25
    home: Option<PathBuf>,
26
    #[command(subcommand)]
27
    cmd: Cmd,
28
}
29
30
#[derive(Subcommand)]
f98a8f78 Andy McCall ↶ 31
enum RunnerCmd {
32
    /// Register this runner with a hub using a one-time token from its Admin → Runners page
33
    Register {
34
        /// The hub, e.g. https://earth.example:7343
35
        hub: String,
36
        /// The registration token (read from stdin when not given)
37
        #[arg(long)]
38
        token: Option<String>,
39
        /// The runner's name (default: the host name)
40
        #[arg(long)]
41
        name: Option<String>,
42
        /// The hub's CA certificate, when self-signed
43
        #[arg(long)]
44
        ca: Option<PathBuf>,
45
        /// A label the runner offers (repeatable)
46
        #[arg(long)]
47
        label: Vec<String>,
48
    },
49
}
50
51
#[derive(Subcommand)]
e0918b88 Andy McCall 52
enum Cmd {
53
    /// Validate a pipeline and print its jobs and hash
54
    Check {
55
        /// The pipeline file (default: .flow/pipeline.kdl)
56
        file: Option<PathBuf>,
57
    },
58
    /// Print a file's BLAKE3, for pinning in flow.toml
59
    Hash { file: PathBuf },
60
    /// Rootfs images for sandboxes
61
    Rootfs {
62
        #[command(subcommand)]
63
        cmd: RootfsCmd,
64
    },
65
    /// Run the pipeline on this directory's files, locally
66
    Run {
67
        /// Only these jobs (and what they depend on)
68
        jobs: Vec<String>,
69
        /// The directory to build (default: the current one)
70
        #[arg(long, default_value = ".")]
71
        dir: PathBuf,
72
        /// Minutes before a job is stopped
73
        #[arg(long, default_value_t = 60)]
74
        timeout: u64,
f98a8f78 Andy McCall ↶ 75
    },
76
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
77
    /// runner.toml `flow runner register` wrote)
78
    Runner {
79
        #[command(subcommand)]
80
        cmd: Option<RunnerCmd>,
e0918b88 Andy McCall 81
    },
9e8f4a56 Andy McCall ↶ 82
    /// Follow the checks of a pushed state on OxygenHub until they finish:
83
    /// exit 0 when all passed, 1 when one failed, 2 at the time limit.
84
    /// Uses $OXY_TOKEN and $OXY_CA as oxy does.
85
    Wait {
86
        /// The repository's URL on the hub (https://hub/owner/name)
87
        url: String,
88
        /// The state hash the push moved the bookmark to (`oxy push --json` prints it)
89
        state: String,
90
        /// Minutes to wait at most
91
        #[arg(long, default_value_t = 60)]
92
        timeout: u64,
93
    },
e0918b88 Andy McCall 94
}
95
96
#[derive(Subcommand)]
97
enum RootfsCmd {
98
    /// Make an ext4 rootfs image from a root file system tarball and store it by hash
99
    Import {
100
        tarball: PathBuf,
101
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
102
        #[arg(long)]
103
        sha256: Option<String>,
104
    },
105
    /// List the images this home holds
106
    List,
107
}
108
109
fn main() {
110
    let cli = Cli::parse();
111
    if let Err(e) = run(cli) {
112
        eprintln!("flow: {e:#}");
113
        std::process::exit(1);
114
    }
115
}
116
117
fn run(cli: Cli) -> Result<()> {
118
    match cli.cmd {
119
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
120
        Cmd::Hash { file } => {
121
            println!("{}", home::hash_file(&file)?);
122
            Ok(())
123
        }
124
        Cmd::Rootfs { cmd } => {
125
            let home = Home::locate(cli.home.as_deref())?;
126
            match cmd {
127
                RootfsCmd::Import { tarball, sha256 } => rootfs_import(&home, &tarball, sha256.as_deref()),
128
                RootfsCmd::List => {
129
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
130
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
131
                        if let Some(h) = e.file_name().to_string_lossy().strip_suffix(".ext4") {
132
                            println!("cas://blob/{h}  {} MiB", size >> 20);
133
                        }
134
                    }
135
                    Ok(())
136
                }
137
            }
138
        }
139
        Cmd::Run { jobs, dir, timeout } => {
140
            let home = Home::locate(cli.home.as_deref())?;
141
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60))
f98a8f78 Andy McCall ↶ 142
        }
143
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
144
            let home = Home::locate(cli.home.as_deref())?;
145
            if std::env::var_os("OXY_HOME").is_none() {
146
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
147
            }
148
            let name = name.unwrap_or_else(|| {
149
                std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into())
150
            });
151
            let token = match token {
152
                Some(t) => t,
153
                None => {
154
                    eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:");
155
                    let mut line = String::new();
156
                    std::io::stdin().read_line(&mut line)?;
157
                    line.trim().to_string()
158
                }
159
            };
160
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
161
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
162
            Ok(())
e0918b88 Andy McCall 163
        }
f98a8f78 Andy McCall ↶ 164
        Cmd::Runner { cmd: None } => {
e0918b88 Andy McCall 165
            let home = Home::locate(cli.home.as_deref())?;
166
            // The runner's oxy state (identity, user config) stays in its home.
167
            if std::env::var_os("OXY_HOME").is_none() {
168
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
169
            }
170
            runner::serve(&home)
9e8f4a56 Andy McCall ↶ 171
        }
172
        Cmd::Wait { url, state, timeout } => {
173
            let token = std::env::var("OXY_TOKEN").ok();
174
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
175
            let code =
176
                wait::wait(&url, &state, token.as_deref(), ca.as_deref(), Duration::from_secs(timeout * 60), Duration::from_secs(15))?;
177
            std::process::exit(code);
e0918b88 Andy McCall 178
        }
179
    }
180
}
181
182
fn load(file: &Path) -> Result<Pipeline> {
183
    let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
184
    Pipeline::parse(&src).map_err(|p| anyhow::anyhow!("{} is not valid:\n{p}", file.display()))
185
}
186
187
fn check(file: &Path) -> Result<()> {
188
    let p = load(file)?;
189
    let depths = p.depths();
190
    for j in p.order() {
191
        let sb = &p.sandboxes[&j.sandbox];
192
        let after = if j.depends_on.is_empty() { String::new() } else { format!(" after {}", j.depends_on.join(", ")) };
193
        println!("{}{}  {} [{}]{after}", "  ".repeat(depths[j.name.as_str()]), j.name, j.exec.join(" "), sb.runtime.as_str());
194
    }
195
    println!("pipeline {}", hex::encode(p.hash()));
196
    Ok(())
197
}
198
199
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>) -> Result<()> {
200
    if let Some(want) = sha256 {
201
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
202
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
203
        if got != want.to_lowercase() {
204
            bail!("{} has SHA-256 {got}, expected {want}", tarball.display());
205
        }
206
    }
207
    let tmp = home.root.join("work").join(format!("rootfs-{}", std::process::id()));
208
    let _ = std::fs::remove_dir_all(&tmp);
209
    let tree = tmp.join("tree");
210
    std::fs::create_dir_all(&tree)?;
211
    let result = (|| -> Result<String> {
212
        let status = std::process::Command::new("tar").arg("-xf").arg(tarball).arg("-C").arg(&tree).arg("--no-same-owner").status()?;
213
        if !status.success() {
214
            bail!("tar could not unpack {}", tarball.display());
215
        }
216
        // Mount points flow-init needs (docs/m0.md §2).
217
        for d in ["src", "tmp", "proc", "dev", "run"] {
218
            std::fs::create_dir_all(tree.join(d))?;
219
        }
220
        let used: u64 = ignore::WalkBuilder::new(&tree)
221
            .hidden(false)
222
            .ignore(false)
223
            .git_ignore(false)
224
            .build()
225
            .flatten()
226
            .filter_map(|e| e.metadata().ok())
227
            .map(|m| m.len().max(4096))
228
            .sum();
229
        let mib = used * 13 / 10 / (1 << 20) + 64;
230
        let img = tmp.join("rootfs.ext4");
231
        let status = std::process::Command::new("mkfs.ext4")
232
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0", "-d"])
233
            .arg(&tree)
234
            .arg(&img)
235
            .arg(format!("{mib}M"))
236
            .status()
237
            .context("run mkfs.ext4")?;
238
        if !status.success() {
239
            bail!("mkfs.ext4 failed");
240
        }
241
        let hash = home::hash_file(&img)?;
242
        std::fs::rename(&img, home.rootfs(&hash))?;
243
        Ok(hash)
244
    })();
245
    let _ = std::fs::remove_dir_all(&tmp);
246
    let hash = result?;
247
    println!("rootfs \"cas://blob/{hash}\"");
248
    Ok(())
249
}
250
251
fn run_local(home: &Home, dir: &Path, only: &[String], timeout: Duration) -> Result<()> {
252
    let p = load(&dir.join(PIPELINE_PATH))?;
253
    // The named jobs and everything they depend on.
254
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
255
    for name in only {
256
        if !p.jobs.contains_key(name) {
257
            bail!("no job {name:?} in the pipeline");
258
        }
259
    }
260
    if !only.is_empty() {
261
        loop {
262
            let more: Vec<String> = wanted.iter().flat_map(|j| p.jobs[j].depends_on.clone()).filter(|d| !wanted.contains(d)).collect();
263
            if more.is_empty() {
264
                break;
265
            }
266
            wanted.extend(more);
267
        }
268
    }
269
    let scratch = home.root.join("work").join(format!("local-{}", std::process::id()));
270
    let _ = std::fs::remove_dir_all(&scratch);
271
    std::fs::create_dir_all(scratch.join("tree"))?;
272
    workspace::copy_tree(dir, &scratch.join("tree"))?;
273
    let mut failed: BTreeSet<String> = BTreeSet::new();
274
    let mut worst = 0;
275
    for job in p.order() {
276
        if !only.is_empty() && !wanted.contains(&job.name) {
277
            continue;
278
        }
279
        if let Some(d) = job.depends_on.iter().find(|d| failed.contains(*d)) {
280
            println!("── {} skipped: {d} failed", job.name);
281
            failed.insert(job.name.clone());
282
            continue;
283
        }
284
        let sb = &p.sandboxes[&job.sandbox];
285
        if sb.runtime != Runtime::Microvm {
286
            bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str());
287
        }
288
        println!("── {} ({})", job.name, job.exec.join(" "));
289
        let js = scratch.join(&job.name);
290
        std::fs::create_dir_all(&js)?;
291
        // Each job starts from the same files: jobs do not share writes.
292
        workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
293
        let out = microvm::run(home, sb, job, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
294
        let _ = std::fs::remove_dir_all(&js);
295
        match out.exit_code {
296
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
297
            other => {
298
                println!(
299
                    "── {} failed ({}) in {} ms",
300
                    job.name,
301
                    other.map(|c| format!("exit {c}")).unwrap_or_else(|| "no exit code".into()),
302
                    out.duration_ms
303
                );
304
                failed.insert(job.name.clone());
305
                worst = 1;
306
            }
307
        }
308
    }
309
    let _ = std::fs::remove_dir_all(&scratch);
310
    if worst != 0 {
311
        std::process::exit(worst);
312
    }
313
    Ok(())
314
}
315
316
fn print_record(r: &Record) {
317
    match r.stream.as_str() {
318
        "out" => println!("   {}", r.text),
319
        "err" => eprintln!("   {}", r.text),
320
        s => eprintln!("   [{s}] {}", r.text),
321
    }
322
}