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