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