OXYGENHUB

Sandbox env, and an image's own environment a72abeeedd78

A sandbox takes an env block: every job in it starts from that
environment and its own env overrides it name by name (part of the
sandbox's canonical form, field 6, only when set, so existing pipelines
keep their hashes). flow rootfs import --env NAME=value writes the image's
own environment to /etc/flow/env, for what a Dockerfile's ENV sets and
docker export loses. flow-init layers PATH and HOME, the image's, then the
job's, and finds the program in the resulting PATH, so a toolchain that
locates itself from its real path runs without wrappers.

closes #9
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 00:25 UTC
Signature
✔ signed by Andy McCall
Hash
a72abeeedd783f8ba2aa1c93ba62956fb7d7225bed612628419c2497e63188f4
Depends on
a083cc802892 c4285535b03a e0918b887850 eb5c4db673e5 fc10d9e80383
Depended on by
ea81796bb757 82cf6e91d398 3b9dddc92057 4cf0c99a1486 ea56c56eaf90 1abadd4a2fa6 c58efc6a74db bf7bb1b80fa3 02b127d2c721 e576e08617dc a67b97e9a717
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
27 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified README.md +1 −0
…
53 unchanged lines
54 54
    flow check                 # validate .flow/pipeline.kdl, print jobs and the pipeline hash
55 55
    flow run [JOB…]            # run the pipeline on this directory, locally, in microVMs
56 56
    flow rootfs import T.tgz --sha256 HEX   # make a rootfs image; prints the `rootfs` line to pin
57 +
                  [--env NAME=value …]      #   with the image's own environment (a Dockerfile's ENV)
57 58
    flow rootfs list
58 59
    flow hash FILE             # BLAKE3, for pinning in flow.toml
59 60
    flow runner                # claim and run jobs for the hub in flow.toml
…
53 unchanged lines
modified crates/flow-core/src/canonical.rs +8 −2
…
29 unchanged lines
30 30
}
31 31
32 32
pub fn sandbox_value(s: &Sandbox) -> Value {
33 -
    Value::map([
33 +
    let mut fields = vec![
34 34
        (0, text(&s.name)),
35 35
        (1, text(s.runtime.as_str())),
36 36
        (2, hex32(&s.rootfs)),
37 37
        (3, Value::Uint(s.memory_mib as u64)),
38 38
        (4, Value::Uint(s.cpus as u64)),
39 39
        (5, text(s.network.as_str())),
40 -
    ])
40 +
    ];
41 +
    // Environment as sorted [name, value] pairs; absent when empty, so
42 +
    // sandboxes without one hash as before.
43 +
    if !s.env.is_empty() {
44 +
        fields.push((6, Value::Array(s.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())));
45 +
    }
46 +
    Value::map(fields)
41 47
}
42 48
43 49
pub fn job_value(j: &Job) -> Value {
…
66 unchanged lines
modified crates/flow-core/src/pipeline.rs +42 −22
…
65 unchanged lines
66 66
    pub memory_mib: u32,
67 67
    pub cpus: u32,
68 68
    pub network: Network,
69 +
    /// Environment every job in this sandbox starts from; a job's own `env`
70 +
    /// overrides it.
71 +
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 +
    pub env: BTreeMap<String, String>,
69 73
}
70 74
71 75
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
…
13 unchanged lines
85 89
}
86 90
87 91
impl Job {
92 +
    /// The job's environment on top of its sandbox's: what it runs with.
93 +
    pub fn in_sandbox(&self, sandbox: &Sandbox) -> Job {
94 +
        let mut job = self.clone();
95 +
        for (k, v) in &sandbox.env {
96 +
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
97 +
        }
98 +
        job
99 +
    }
100 +
88 101
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
89 102
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
90 103
    /// sets itself keeps the pipeline's value.
…
355 unchanged lines
446 459
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
447 460
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
448 461
    let mut seen = BTreeSet::new();
462 +
    let mut env = BTreeMap::new();
449 463
    for c in block(ctx, node) {
450 464
        let key = c.name().value();
451 465
        if !seen.insert(key.to_string()) {
452 466
            ctx.err(c.span(), format!("`{key}` is given twice"));
467 +
        }
468 +
        if key == "env" {
469 +
            parse_env(ctx, c, &mut env);
470 +
            continue;
453 471
        }
454 472
        ctx.no_children(c);
455 473
        match key {
…
77 unchanged lines
533 551
    }
534 552
    if !named {
535 553
        return None;
554 +
    }
555 +
    Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env })
556 +
}
557 +
558 +
/// An `env` block of `NAME "value"` lines, into `env`.
559 +
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
560 +
    if !c.entries().is_empty() {
561 +
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
562 +
    }
563 +
    for e in block(ctx, c) {
564 +
        let k = e.name().value().to_string();
565 +
        let valid =
566 +
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
567 +
        if !valid {
568 +
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
569 +
            continue;
570 +
        }
571 +
        ctx.no_children(e);
572 +
        if let Some(v) = ctx.one_string(e) {
573 +
            if env.insert(k.clone(), v).is_some() {
574 +
                ctx.err(e.span(), format!("environment {k} is set twice"));
575 +
            }
576 +
        }
536 577
    }
537 -
    Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network? })
538 578
}
539 579
540 580
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
…
99 unchanged lines
640 680
                ctx.no_children(c);
641 681
                job.depends_on.extend(ctx.strings(c));
642 682
            }
643 -
            "env" => {
644 -
                if !c.entries().is_empty() {
645 -
                    ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
646 -
                }
647 -
                for e in block(ctx, c) {
648 -
                    let k = e.name().value().to_string();
649 -
                    let valid = !k.is_empty()
650 -
                        && !k.starts_with(|c: char| c.is_ascii_digit())
651 -
                        && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
652 -
                    if !valid {
653 -
                        ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
654 -
                        continue;
655 -
                    }
656 -
                    ctx.no_children(e);
657 -
                    if let Some(v) = ctx.one_string(e) {
658 -
                        if job.env.insert(k.clone(), v).is_some() {
659 -
                            ctx.err(e.span(), format!("environment {k} is set twice"));
660 -
                        }
661 -
                    }
662 -
                }
663 -
            }
683 +
            "env" => parse_env(ctx, c, &mut job.env),
664 684
            "exec" => {
665 685
                ctx.no_children(c);
666 686
                job.exec = ctx.strings(c);
…
41 unchanged lines
modified crates/flow-core/src/tests.rs +24 −0
…
296 unchanged lines
297 297
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
298 298
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
299 299
}
300 +
301 +
#[test]
302 +
fn a_sandbox_env_is_where_jobs_start() {
303 +
    let src = |sandbox_env: &str| {
304 +
        format!(
305 +
            "schema-version 1\nsandbox \"mos\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n{sandbox_env}}}\njob \"build\" {{\n    sandbox \"mos\"\n    env {{\n        X16EMU \"/opt/job\"\n    }}\n    exec \"make\"\n}}\n"
306 +
        )
307 +
    };
308 +
    let with =
309 +
        Pipeline::parse(&src("    env {\n        PATH \"/opt/llvm-mos/bin:/usr/bin:/bin\"\n        X16EMU \"/opt/x16emu\"\n    }\n"))
310 +
            .unwrap();
311 +
    let sb = &with.sandboxes["mos"];
312 +
    assert_eq!(sb.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin");
313 +
    let job = with.jobs["build"].in_sandbox(sb);
314 +
    assert_eq!(job.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "from the sandbox");
315 +
    assert_eq!(job.env["X16EMU"], "/opt/job", "the job's own value wins");
316 +
    // The sandbox's environment is part of the pipeline's hash; a sandbox
317 +
    // without one hashes as before.
318 +
    let without = Pipeline::parse(&src("")).unwrap();
319 +
    assert!(without.sandboxes["mos"].env.is_empty());
320 +
    assert_ne!(with.hash(), without.hash());
321 +
    let bad = problems(&src("    env {\n        BAD-NAME \"x\"\n    }\n"));
322 +
    assert!(bad.iter().any(|(_, m)| m.contains("letters, digits and _")), "{bad:?}");
323 +
}
modified crates/flow-init/src/main.rs +37 −6
…
111 unchanged lines
112 112
        .find(|p| std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0))
113 113
}
114 114
115 +
/// A job's environment: `PATH` and `HOME=/tmp` by default, then the
116 +
/// image's own (`/etc/flow/env`, written by `flow rootfs import --env`),
117 +
/// then the job's (its sandbox's and its own, already merged by the host).
118 +
/// Later ones win, so a pipeline can always set `PATH`.
119 +
fn job_env(image: Option<&str>, job: &[(String, String)]) -> std::collections::BTreeMap<String, String> {
120 +
    let mut env = std::collections::BTreeMap::new();
121 +
    env.insert("PATH".to_string(), PATH.to_string());
122 +
    env.insert("HOME".to_string(), "/tmp".to_string());
123 +
    for line in image.unwrap_or("").lines() {
124 +
        if let Some((k, v)) = line.split_once('=') {
125 +
            if !k.is_empty() {
126 +
                env.insert(k.to_string(), v.to_string());
127 +
            }
128 +
        }
129 +
    }
130 +
    for (k, v) in job {
131 +
        env.insert(k.clone(), v.clone());
132 +
    }
133 +
    env
134 +
}
135 +
115 136
/// The links every Linux system has under /dev and devtmpfs does not
116 137
/// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard
117 138
/// streams, all through the job's own /proc.
…
100 unchanged lines
218 239
    let argv: Vec<CString> = job.argv.iter().map(|a| cstr(a)).collect::<Result<_, _>>()?;
219 240
    // glibc's execvpe searches the caller's PATH, and PID 1 has none of its
220 241
    // own, so the program is found here, in the job's PATH, before forking.
221 -
    let program = cstr(&resolve_program(&job.argv[0], PATH).ok_or_else(|| format!("cannot run {:?}: not found in {PATH}", job.argv[0]))?)?;
222 -
    let mut env: Vec<CString> = vec![cstr(&format!("PATH={PATH}"))?, cstr("HOME=/tmp")?];
223 -
    for (k, v) in &job.env {
224 -
        env.push(cstr(&format!("{k}={v}"))?);
225 -
    }
242 +
    let vars = job_env(std::fs::read_to_string("/etc/flow/env").ok().as_deref(), &job.env);
243 +
    let path = vars.get("PATH").map(String::as_str).unwrap_or(PATH);
244 +
    let program = cstr(&resolve_program(&job.argv[0], path).ok_or_else(|| format!("cannot run {:?}: not found in {path}", job.argv[0]))?)?;
245 +
    let env: Vec<CString> = vars.iter().map(|(k, v)| cstr(&format!("{k}={v}"))).collect::<Result<_, _>>()?;
226 246
    let mut out = [0; 2];
227 247
    let mut err = [0; 2];
228 248
    if unsafe { libc::pipe2(out.as_mut_ptr(), libc::O_CLOEXEC) } != 0 || unsafe { libc::pipe2(err.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
…
76 unchanged lines
305 325
306 326
#[cfg(test)]
307 327
mod tests {
308 -
    use super::{dev_links, resolve_program};
328 +
    use super::{dev_links, job_env, resolve_program};
329 +
330 +
    #[test]
331 +
    fn the_image_environment_sits_under_the_jobs() {
332 +
        let image = "PATH=/opt/llvm-mos/bin:/usr/bin:/bin\nX16EMU=/opt/x16emu\nnot a line\n";
333 +
        let env = job_env(Some(image), &[("X16EMU".into(), "/job".into()), ("LANG".into(), "C".into())]);
334 +
        assert_eq!(env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "the image's PATH replaces the default");
335 +
        assert_eq!(env["X16EMU"], "/job", "the job's value wins");
336 +
        assert_eq!(env["HOME"], "/tmp");
337 +
        assert_eq!(env["LANG"], "C");
338 +
        assert_eq!(job_env(None, &[])["PATH"], super::PATH);
339 +
    }
309 340
310 341
    #[test]
311 342
    fn dev_gets_the_usual_links_once() {
…
34 unchanged lines
modified crates/flow/src/main.rs +43 −3
…
62 unchanged lines
63 63
        /// Refuse the tarball unless its SHA-256 matches (as its publisher states it)
64 64
        #[arg(long)]
65 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>,
66 71
    },
67 72
    /// List the images this home holds
68 73
    List,
…
17 unchanged lines
86 91
        Cmd::Rootfs { cmd } => {
87 92
            let home = Home::locate(cli.home.as_deref())?;
88 93
            match cmd {
89 -
                RootfsCmd::Import { tarball, sha256 } => rootfs_import(&home, &tarball, sha256.as_deref()),
94 +
                RootfsCmd::Import { tarball, sha256, env } => rootfs_import(&home, &tarball, sha256.as_deref(), &env),
90 95
                RootfsCmd::List => {
91 96
                    for e in std::fs::read_dir(home.root.join("cas"))?.flatten() {
92 97
                        let size = e.metadata().map(|m| m.len()).unwrap_or(0);
…
37 unchanged lines
130 135
    Ok(())
131 136
}
132 137
133 -
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>) -> Result<()> {
138 +
/// The image environment file flow-init reads (`/etc/flow/env`): one
139 +
/// `NAME=value` per line, names checked as a pipeline's are.
140 +
fn image_env_file(env: &[String]) -> Result<String> {
141 +
    let mut out = String::new();
142 +
    for e in env {
143 +
        let Some((k, v)) = e.split_once('=') else { bail!("--env {e:?}: expected NAME=value") };
144 +
        let ok = !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
145 +
        if !ok || v.contains('\n') || v.contains('\0') {
146 +
            bail!("--env {e:?}: a name is letters, digits and _, and a value is one line");
147 +
        }
148 +
        out.push_str(&format!("{k}={v}\n"));
149 +
    }
150 +
    Ok(out)
151 +
}
152 +
153 +
fn rootfs_import(home: &Home, tarball: &Path, sha256: Option<&str>, env: &[String]) -> Result<()> {
154 +
    let env_file = image_env_file(env)?;
134 155
    if let Some(want) = sha256 {
135 156
        let out = std::process::Command::new("sha256sum").arg(tarball).output().context("run sha256sum")?;
136 157
        let got = String::from_utf8_lossy(&out.stdout).split_whitespace().next().unwrap_or("").to_string();
…
13 unchanged lines
150 171
        // Mount points flow-init needs (docs/m0.md §2).
151 172
        for d in ["src", "tmp", "proc", "dev", "run"] {
152 173
            std::fs::create_dir_all(tree.join(d))?;
174 +
        }
175 +
        if !env_file.is_empty() {
176 +
            std::fs::create_dir_all(tree.join("etc/flow"))?;
177 +
            std::fs::write(tree.join("etc/flow/env"), &env_file)?;
153 178
        }
154 179
        let used: u64 = ignore::WalkBuilder::new(&tree)
155 180
            .hidden(false)
…
68 unchanged lines
224 249
        std::fs::create_dir_all(&js)?;
225 250
        // Each job starts from the same files: jobs do not share writes.
226 251
        workspace::image(&scratch.join("tree"), &js.join("workspace.ext4"))?;
227 -
        let job = &job.with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())]);
252 +
        let job = &job.in_sandbox(sb).with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())]);
228 253
        let out = microvm::run(home, sb, job, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
229 254
        let _ = std::fs::remove_dir_all(&js);
230 255
        match out.exit_code {
…
22 unchanged lines
253 278
        "out" => println!("   {}", r.text),
254 279
        "err" => eprintln!("   {}", r.text),
255 280
        s => eprintln!("   [{s}] {}", r.text),
281 +
    }
282 +
}
283 +
284 +
#[cfg(test)]
285 +
mod tests {
286 +
    use super::image_env_file;
287 +
288 +
    #[test]
289 +
    fn image_environments_are_checked_lines() {
290 +
        let f = image_env_file(&["PATH=/opt/bin:/usr/bin".into(), "X16EMU=/opt/x16emu".into(), "EMPTY=".into()]).unwrap();
291 +
        assert_eq!(f, "PATH=/opt/bin:/usr/bin\nX16EMU=/opt/x16emu\nEMPTY=\n");
292 +
        assert!(image_env_file(&["NOEQUALS".into()]).is_err());
293 +
        assert!(image_env_file(&["1X=y".into()]).is_err());
294 +
        assert!(image_env_file(&["X=two\nlines".into()]).is_err());
295 +
        assert_eq!(image_env_file(&[]).unwrap(), "");
256 296
    }
257 297
}
modified crates/flow/src/runner.rs +1 −1
…
169 unchanged lines
170 170
                &claim.sandbox.rootfs[..12]
171 171
            ),
172 172
        );
173 -
        let job = claim.job.with_flow_env(&claim.flow_env());
173 +
        let job = claim.job.in_sandbox(&claim.sandbox).with_flow_env(&claim.flow_env());
174 174
        let outcome = microvm::run(
175 175
            home,
176 176
            &claim.sandbox,
…
75 unchanged lines
modified docs/m0.md +10 −0
…
24 unchanged lines
25 25
    memory-limit "1GiB"          // KiB, MiB, GiB
26 26
    cpu-limit 2
27 27
    network "isolated"           // "isolated" or "loopback" in M0
28 +
    env { PATH "/opt/tool/bin:/usr/bin:/bin" }  // every job's starting environment
28 29
}
29 30
30 31
job "lint" {
…
36 unchanged lines
67 68
  part of the job's canonical form (field 6, present only when a job has
68 69
  mounts, so pipelines without them keep their hashes) and of the
69 70
  receipt's environment.
71 +
72 +
- A sandbox's `env` is where each of its jobs' environments starts; a
73 +
  job's own `env` overrides it name by name. It is part of the sandbox's
74 +
  canonical form (field 6, present only when set). Below both is the
75 +
  image's own environment: `flow rootfs import --env NAME=value`
76 +
  (repeatable) writes `/etc/flow/env` into the image, for what a
77 +
  Dockerfile's `ENV` sets and `docker export` loses, such as a toolchain's
78 +
  `PATH`. It is part of the image, so of its hash. Under all three are
79 +
  `PATH` (the usual directories) and `HOME=/tmp`.
70 80
71 81
The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes
72 82
and jobs in name order). Its BLAKE3 is the `pipeline_hash` used in receipts
…
108 unchanged lines