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 |