OXYGENHUB
# e2c125610f43 oxygenflow / crates / flow / src / main.rs
15.4 KiB · Rust · last changed Secrets mount as files: secret blocks, the claim's secrets, flow-init writes them 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
mod home;
6
mod microvm;
7
mod outputs;
8
mod runner;
9
mod wait;
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 = env!("OXY_VERSION_LONG"), 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 RunnerCmd {
33
    /// Register this runner with a hub using a one-time token from its Admin → Runners page
34
    Register {
35
        /// The hub, e.g. https://earth.example:7343
36
        hub: String,
37
        /// The registration token (read from stdin when not given)
38
        #[arg(long)]
39
        token: Option<String>,
40
        /// The runner's name (default: the host name)
41
        #[arg(long)]
42
        name: Option<String>,
43
        /// The hub's CA certificate, when self-signed
44
        #[arg(long)]
45
        ca: Option<PathBuf>,
46
        /// A label the runner offers (repeatable)
47
        #[arg(long)]
48
        label: Vec<String>,
49
    },
50
}
51
52
#[derive(Subcommand)]
53
enum Cmd {
54
    /// Validate a pipeline and print its jobs and hash
55
    Check {
56
        /// The pipeline file (default: .flow/pipeline.kdl)
57
        file: Option<PathBuf>,
58
    },
59
    /// Print a file's BLAKE3, for pinning in flow.toml
60
    Hash { file: PathBuf },
61
    /// Rootfs images for sandboxes
62
    Rootfs {
63
        #[command(subcommand)]
64
        cmd: RootfsCmd,
65
    },
66
    /// Run the pipeline on this directory's files, locally
67
    Run {
68
        /// Only these jobs (and what they depend on)
69
        jobs: Vec<String>,
70
        /// The directory to build (default: the current one)
71
        #[arg(long, default_value = ".")]
72
        dir: PathBuf,
73
        /// Minutes before a job is stopped
74
        #[arg(long, default_value_t = 60)]
75
        timeout: u64,
76
        /// A variable for every job, as the hub's settings would give it;
77
        /// the pipeline's own `env` wins
78
        #[arg(long = "var", value_name = "NAME=VALUE")]
79
        vars: Vec<String>,
80
        /// A secret a job's `secret` block names, as the hub would supply
81
        /// it; mounted as a file, never in the environment
82
        #[arg(long = "secret", value_name = "NAME=VALUE")]
83
        secrets: Vec<String>,
84
    },
85
    /// Claim and run jobs for the hub in flow.toml's [runner] (or the
86
    /// runner.toml `flow runner register` wrote)
87
    Runner {
88
        #[command(subcommand)]
89
        cmd: Option<RunnerCmd>,
90
    },
91
    /// Follow the checks of a pushed state on OxygenHub until they finish:
92
    /// exit 0 when all passed, 1 when one failed, 2 at the time limit.
93
    /// Uses $OXY_TOKEN and $OXY_CA as oxy does.
94
    Wait {
95
        /// The repository's URL on the hub (https://hub/owner/name)
96
        url: String,
97
        /// The state hash the push moved the bookmark to (`oxy push --json` prints it)
98
        state: String,
99
        /// Minutes to wait at most
100
        #[arg(long, default_value_t = 60)]
101
        timeout: u64,
102
    },
103
}
104
105
#[derive(Subcommand)]
106
enum RootfsCmd {
107
    /// Make an ext4 rootfs image from a root file system tarball and store it by hash
108
    Import {
109
        tarball: PathBuf,
110
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
111
        #[arg(long)]
112
        sha256: Option<String>,
113
        /// The image's own environment, NAME=value (repeatable): what a
114
        /// Dockerfile's ENV sets and `docker export` loses. Jobs start from
115
        /// it; their sandbox's and their own `env` override it
116
        #[arg(long = "env", value_name = "NAME=VALUE")]
117
        env: Vec<String>,
118
    },
119
    /// List the images this home holds
120
    List,
121
}
122
123
fn main() {
124
    let cli = Cli::parse();
125
    if let Err(e) = run(cli) {
126
        eprintln!("flow: {e:#}");
127
        std::process::exit(1);
128
    }
129
}
130
131
fn run(cli: Cli) -> Result<()> {
132
    match cli.cmd {
133
        Cmd::Check { file } => check(&file.unwrap_or_else(|| PathBuf::from(PIPELINE_PATH))),
134
        Cmd::Hash { file } => {
135
            println!("{}", home::hash_file(&file)?);
136
            Ok(())
137
        }
138
        Cmd::Rootfs { cmd } => {
139
            let home = Home::locate(cli.home.as_deref())?;
140
            match cmd {
141
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
142
                RootfsCmd::List => {
143
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
144
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
145
                        if let Some(h) = e.file_name().to_string_lossy().strip_suffix(".ext4") {
146
                            println!("cas://blob/{h}  {} MiB", size >> 20);
147
                        }
148
                    }
149
                    Ok(())
150
                }
151
            }
152
        }
153
        Cmd::Run { jobs, dir, timeout, vars, secrets } => {
154
            let home = Home::locate(cli.home.as_deref())?;
155
            let pairs = |what: &str, list: &[String]| {
156
                list.iter()
157
                    .map(|e| {
158
                        e.split_once('=')
159
                            .map(|(k, v)| (k.to_string(), v.to_string()))
160
                            .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE"))
161
                    })
162
                    .collect::<Result<Vec<_>>>()
163
            };
164
            let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?);
165
            run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets)
166
        }
167
        Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => {
168
            let home = Home::locate(cli.home.as_deref())?;
169
            if std::env::var_os("OXY_HOME").is_none() {
170
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
171
            }
172
            let name = name.unwrap_or_else(|| {
173
                std::fs::read_to_string("/etc/hostname").map(|h| h.trim().to_string()).unwrap_or_else(|_| "flow".into())
174
            });
175
            let token = match token {
176
                Some(t) => t,
177
                None => {
178
                    eprintln!("Paste the registration token from the hub's Admin → Runners, then press Enter:");
179
                    let mut line = String::new();
180
                    std::io::stdin().read_line(&mut line)?;
181
                    line.trim().to_string()
182
                }
183
            };
184
            let got = runner::register(&home, &hub, &token, &name, ca, label)?;
185
            println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id);
186
            Ok(())
187
        }
188
        Cmd::Runner { cmd: None } => {
189
            let home = Home::locate(cli.home.as_deref())?;
190
            // The runner's oxy state (identity, user config) stays in its home.
191
            if std::env::var_os("OXY_HOME").is_none() {
192
                std::env::set_var("OXY_HOME", home.root.join("oxy"));
193
            }
194
            runner::serve(&home)
195
        }
196
        Cmd::Wait { url, state, timeout } => {
197
            let token = std::env::var("OXY_TOKEN").ok();
198
            let ca = std::env::var_os("OXY_CA").map(PathBuf::from);
199
            let code =
200
                wait::wait(&url, &state, token.as_deref(), ca.as_deref(), Duration::from_secs(timeout * 60), Duration::from_secs(15))?;
201
            std::process::exit(code);
202
        }
203
    }
204
}
205
206
fn load(file: &Path) -> Result<Pipeline> {
207
    let src = std::fs::read_to_string(file).with_context(|| format!("read {}", file.display()))?;
208
    Pipeline::parse(&src).map_err(|p| anyhow::anyhow!("{} is not valid:\n{p}", file.display()))
209
}
210
211
fn check(file: &Path) -> Result<()> {
212
    let p = load(file)?;
213
    let depths = p.depths();
214
    for j in p.order() {
215
        let sb = &p.sandboxes[&j.sandbox];
216
        let after = if j.depends_on.is_empty() { String::new() } else { format!(" after {}", j.depends_on.join(", ")) };
217
        println!("{}{}  {} [{}]{after}", "  ".repeat(depths[j.name.as_str()]), j.name, j.exec.join(" "), sb.runtime.as_str());
218
    }
219
    println!("pipeline {}", hex::encode(p.hash()));
220
    Ok(())
221
}
222
223
/// The image environment file flow-init reads (`/etc/flow/env`): one
224
/// `NAME=value` per line, names checked as a pipeline's are.
225
fn image_env_file(env: &[String]) -> Result<String> {
226
    let mut out = String::new();
227
    for e in env {
228
        let Some((k, v)) = e.split_once('=') else { bail!("--env {e:?}: expected NAME=value") };
229
        let ok = !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
230
        if !ok || v.contains('\n') || v.contains('\0') {
231
            bail!("--env {e:?}: a name is letters, digits and _, and a value is one line");
232
        }
233
        out.push_str(&format!("{k}={v}\n"));
234
    }
235
    Ok(out)
236
}
237
238
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>, env: &[String]) -> Result<()> {
239
    let env_file = image_env_file(env)?;
240
    if let Some(want) = sha256 {
241
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
242
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
243
        if got != want.to_lowercase() {
244
            bail!("{} has SHA-256 {got}, expected {want}", tarball.display());
245
        }
246
    }
247
    let tmp = home.root.join("work").join(format!("rootfs-{}", std::process::id()));
248
    let _ = std::fs::remove_dir_all(&tmp);
249
    let tree = tmp.join("tree");
250
    std::fs::create_dir_all(&tree)?;
251
    let result = (|| -> Result<String> {
252
        let status = std::process::Command::new("tar").arg("-xf").arg(tarball).arg("-C").arg(&tree).arg("--no-same-owner").status()?;
253
        if !status.success() {
254
            bail!("tar could not unpack {}", tarball.display());
255
        }
256
        // Mount points flow-init needs (docs/m0.md §2).
257
        for d in ["src", "tmp", "proc", "dev", "run"] {
258
            std::fs::create_dir_all(tree.join(d))?;
259
        }
260
        if !env_file.is_empty() {
261
            std::fs::create_dir_all(tree.join("etc/flow"))?;
262
            std::fs::write(tree.join("etc/flow/env"), &env_file)?;
263
        }
264
        let used: u64 = ignore::WalkBuilder::new(&tree)
265
            .hidden(false)
266
            .ignore(false)
267
            .git_ignore(false)
268
            .build()
269
            .flatten()
270
            .filter_map(|e| e.metadata().ok())
271
            .map(|m| m.len().max(4096))
272
            .sum();
273
        let mib = used * 13 / 10 / (1 << 20) + 64;
274
        let img = tmp.join("rootfs.ext4");
275
        let status = std::process::Command::new("mkfs.ext4")
276
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0", "-d"])
277
            .arg(&tree)
278
            .arg(&img)
279
            .arg(format!("{mib}M"))
280
            .status()
281
            .context("run mkfs.ext4")?;
282
        if !status.success() {
283
            bail!("mkfs.ext4 failed");
284
        }
285
        let hash = home::hash_file(&img)?;
286
        std::fs::rename(&img, home.rootfs(&hash))?;
287
        Ok(hash)
288
    })();
289
    let _ = std::fs::remove_dir_all(&tmp);
290
    let hash = result?;
291
    println!("rootfs \"cas://blob/{hash}\"");
292
    Ok(())
293
}
294
295
fn run_local(
296
    home: &Home,
297
    dir: &Path,
298
    only: &[String],
299
    timeout: Duration,
300
    vars: &[(String, String)],
301
    secrets: &[(String, String)],
302
) -> Result<()> {
303
    let p = load(&dir.join(PIPELINE_PATH))?;
304
    // The named jobs and everything they depend on.
305
    let mut wanted: BTreeSet<String> = only.iter().cloned().collect();
306
    for name in only {
307
        if !p.jobs.contains_key(name) {
308
            bail!("no job {name:?} in the pipeline");
309
        }
310
    }
311
    if !only.is_empty() {
312
        loop {
313
            let more: Vec<String> = wanted.iter().flat_map(|j| p.jobs[j].depends_on.clone()).filter(|d| !wanted.contains(d)).collect();
314
            if more.is_empty() {
315
                break;
316
            }
317
            wanted.extend(more);
318
        }
319
    }
320
    let scratch = home.root.join("work").join(format!("local-{}", std::process::id()));
321
    let _ = std::fs::remove_dir_all(&scratch);
322
    std::fs::create_dir_all(scratch.join("tree"))?;
323
    workspace::copy_tree(dir, &scratch.join("tree"))?;
324
    let mut failed: BTreeSet<String> = BTreeSet::new();
325
    let mut worst = 0;
326
    for job in p.order() {
327
        if !only.is_empty() && !wanted.contains(&job.name) {
328
            continue;
329
        }
330
        if let Some(d) = job.depends_on.iter().find(|d| failed.contains(*d)) {
331
            println!("── {} skipped: {d} failed", job.name);
332
            failed.insert(job.name.clone());
333
            continue;
334
        }
335
        let sb = &p.sandboxes[&job.sandbox];
336
        if sb.runtime != Runtime::Microvm {
337
            bail!("job {}: runtime {} is not available in this build yet", job.name, sb.runtime.as_str());
338
        }
339
        println!("── {} ({})", job.name, job.exec.join(" "));
340
        let js = scratch.join(&job.name);
341
        std::fs::create_dir_all(&js)?;
342
        // Each job starts from the same files: jobs do not share writes.
343
        workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
344
        let job = &job
345
            .in_sandbox(sb)
346
            .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())])
347
            .with_flow_env(vars);
348
        // Each secret the job declares must be given, as the hub would.
349
        let inputs = job
350
            .secrets
351
            .iter()
352
            .map(|s| {
353
                let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone());
354
                value
355
                    .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value })
356
                    .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name))
357
            })
358
            .collect::<Result<Vec<_>>>()?;
359
        let out = microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
360
        let _ = std::fs::remove_dir_all(&js);
361
        match out.exit_code {
362
            Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms),
363
            other => {
364
                println!(
365
                    "── {} failed ({}) in {} ms",
366
                    job.name,
367
                    other.map(|c| format!("exit {c}")).unwrap_or_else(|| "no exit code".into()),
368
                    out.duration_ms
369
                );
370
                failed.insert(job.name.clone());
371
                worst = 1;
372
            }
373
        }
374
    }
375
    let _ = std::fs::remove_dir_all(&scratch);
376
    if worst != 0 {
377
        std::process::exit(worst);
378
    }
379
    Ok(())
380
}
381
382
fn print_record(r: &Record) {
383
    match r.stream.as_str() {
384
        "out" => println!("   {}", r.text),
385
        "err" => eprintln!("   {}", r.text),
386
        s => eprintln!("   [{s}] {}", r.text),
387
    }
388
}
389
390
#[cfg(test)]
391
mod tests {
392
    use super::image_env_file;
393
394
    #[test]
395
    fn image_environments_are_checked_lines() {
396
        let f = image_env_file(&["PATH=/opt/bin:/usr/bin".into(), "X16EMU=/opt/x16emu".into(), "EMPTY=".into()]).unwrap();
397
        assert_eq!(f, "PATH=/opt/bin:/usr/bin\nX16EMU=/opt/x16emu\nEMPTY=\n");
398
        assert!(image_env_file(&["NOEQUALS".into()]).is_err());
399
        assert!(image_env_file(&["1X=y".into()]).is_err());
400
        assert!(image_env_file(&["X=two\nlines".into()]).is_err());
401
        assert_eq!(image_env_file(&[]).unwrap(), "");
402
    }
403
}