Ephemeral scratch mounts in M0: ephemeral://tmpfs and ephemeral://disk (a sparse disk per job), checked targets and sizes, in the canonical job and the receipt; flow-init mounts them for the job's user; make deploy-runner fc10d9e80383
closes #2
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 17:21 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
fc10d9e803837ef5d41e38c42485363786bda0f2fe00f8c846606bbf5b244f58- Depends on
- a083cc802892 e0918b887850
- Depended on by
- 731413202557 3554afefb6aa f201e1bc5bcd 077a65242359 e87c1c138187 2be1fe4ad345 46fe2c815cc3 ea81796bb757 de1d7926185f 82cf6e91d398 89171fca079c d60aac6c3f8a 6d53fb7d00ec cd008acc4eb2 ea56c56eaf90 720eb8191059 e2c125610f43 bf7bb1b80fa3 02b127d2c721 e576e08617dc c0c54484499e 2d025bbf9842 a67b97e9a717 a72abeeedd78 c4285535b03a fe128fa67a06 eb5c4db673e5
- 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
- 35 atoms, 1 tree operation, 10 files
Browse the tree at this change
added Makefile +25 −0
| 1 | + | # OxygenFlow on Earth (docs/m0.md §6): build the runner and the guest init, |
|
| 2 | + | # install them under /home/claude/flow, re-pin flow-init in flow.toml, and |
|
| 3 | + | # restart the flow-runner service. |
|
| 4 | + | ||
| 5 | + | EARTH ?= claude@earth |
|
| 6 | + | SSH ?= ssh -i $(HOME)/.ssh/earth_claude |
|
| 7 | + | SCP ?= scp -i $(HOME)/.ssh/earth_claude |
|
| 8 | + | FLOW_HOME_ON_EARTH ?= /home/claude/flow |
|
| 9 | + | INIT := target/x86_64-unknown-linux-gnu/release/flow-init |
|
| 10 | + | ||
| 11 | + | .PHONY: build deploy-runner |
|
| 12 | + | ||
| 13 | + | build: |
|
| 14 | + | cargo build --release -p flow |
|
| 15 | + | RUSTFLAGS="-C target-feature=+crt-static" cargo build --release -p flow-init --target x86_64-unknown-linux-gnu |
|
| 16 | + | ||
| 17 | + | deploy-runner: build |
|
| 18 | + | $(SCP) target/release/flow $(EARTH):$(FLOW_HOME_ON_EARTH)/bin/flow.new |
|
| 19 | + | $(SCP) $(INIT) $(EARTH):$(FLOW_HOME_ON_EARTH)/bin/flow-init.new |
|
| 20 | + | $(SSH) $(EARTH) 'set -e; cd $(FLOW_HOME_ON_EARTH); \ |
|
| 21 | + | cp -p bin/flow bin/flow.prev; cp -p bin/flow-init bin/flow-init.prev; cp -p flow.toml flow.toml.prev; \ |
|
| 22 | + | mv bin/flow.new bin/flow; mv bin/flow-init.new bin/flow-init; \ |
|
| 23 | + | h=$$(bin/flow hash bin/flow-init | cut -d" " -f1); \ |
|
| 24 | + | sed -i "s|^init = .*|init = { path = \"bin/flow-init\", blake3 = \"$$h\" }|" flow.toml; \ |
|
| 25 | + | sudo systemctl restart flow-runner; systemctl is-active flow-runner' |
modified README.md +6 −0
| … | 42 unchanged lines |
||
| 43 | 43 | ||
| 44 | 44 | This repository's own pipeline is [.flow/pipeline.kdl](.flow/pipeline.kdl). |
|
| 45 | 45 | ||
| 46 | + | ## Deploying the runner on Earth |
|
| 47 | + | ||
| 48 | + | `make deploy-runner` builds `flow` and the static `flow-init`, installs them |
|
| 49 | + | under `/home/claude/flow` (keeping the previous files as `.prev`), re-pins |
|
| 50 | + | `flow-init` in `flow.toml` and restarts the `flow-runner` service. |
|
| 51 | + | ||
| 46 | 52 | ## Commands |
|
| 47 | 53 | ||
| 48 | 54 | flow check # validate .flow/pipeline.kdl, print jobs and the pipeline hash |
|
| … | 58 unchanged lines |
modified crates/flow-core/src/canonical.rs +16 −2
| … | 40 unchanged lines |
||
| 41 | 41 | } |
|
| 42 | 42 | ||
| 43 | 43 | pub fn job_value(j: &Job) -> Value { |
|
| 44 | - | Value::map([ |
|
| 44 | + | let mut fields = vec![ |
|
| 45 | 45 | (0, text(&j.name)), |
|
| 46 | 46 | (1, text(&j.sandbox)), |
|
| 47 | 47 | (2, texts(&j.scope)), |
|
| … | 1 unchanged lines |
||
| 49 | 49 | // Environment as sorted [name, value] pairs: the profile has integer map keys only. |
|
| 50 | 50 | (4, Value::Array(j.env.iter().map(|(k, v)| Value::Array(vec![text(k), text(v)])).collect())), |
|
| 51 | 51 | (5, texts(&j.exec)), |
|
| 52 | - | ]) |
|
| 52 | + | ]; |
|
| 53 | + | // Mounts as [source, target, size MiB] sorted by target; absent when |
|
| 54 | + | // there are none, so jobs without mounts hash as before. |
|
| 55 | + | if !j.mounts.is_empty() { |
|
| 56 | + | fields.push(( |
|
| 57 | + | 6, |
|
| 58 | + | Value::Array( |
|
| 59 | + | j.mounts |
|
| 60 | + | .iter() |
|
| 61 | + | .map(|m| Value::Array(vec![text(m.source.as_str()), text(&m.target), Value::Uint(m.size_mib as u64)])) |
|
| 62 | + | .collect(), |
|
| 63 | + | ), |
|
| 64 | + | )); |
|
| 65 | + | } |
|
| 66 | + | Value::map(fields) |
|
| 53 | 67 | } |
|
| 54 | 68 | ||
| 55 | 69 | impl Pipeline { |
|
| … | 40 unchanged lines |
modified crates/flow-core/src/lib.rs +1 −1
| … | 6 unchanged lines |
||
| 7 | 7 | pub mod receipt; |
|
| 8 | 8 | ||
| 9 | 9 | pub use canonical::{object_hash, scope_tree_hash, Hash}; |
|
| 10 | - | pub use pipeline::{Job, Network, Pipeline, Problem, Problems, Runtime, Sandbox, PIPELINE_PATH}; |
|
| 10 | + | pub use pipeline::{Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, PIPELINE_PATH}; |
|
| 11 | 11 | pub use receipt::{Environment, Receipt}; |
|
| 12 | 12 | ||
| 13 | 13 | #[cfg(test)] |
|
| … | 1 unchanged lines |
modified crates/flow-core/src/pipeline.rs +140 −3
| … | 78 unchanged lines |
||
| 79 | 79 | pub env: BTreeMap<String, String>, |
|
| 80 | 80 | /// Executed as is: no shell, no expansion (spec §2.2 I2). |
|
| 81 | 81 | pub exec: Vec<String>, |
|
| 82 | + | /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`). |
|
| 83 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 84 | + | pub mounts: Vec<Mount>, |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | /// An ephemeral scratch mount: empty at the start of every job and gone |
|
| 88 | + | /// after it, so it cannot carry anything between jobs (spec §2.2 I4). |
|
| 89 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 90 | + | pub struct Mount { |
|
| 91 | + | pub source: MountSource, |
|
| 92 | + | /// An absolute path under `/src` or `/tmp`. |
|
| 93 | + | pub target: String, |
|
| 94 | + | pub size_mib: u32, |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 98 | + | #[serde(rename_all = "lowercase")] |
|
| 99 | + | pub enum MountSource { |
|
| 100 | + | /// `ephemeral://tmpfs`: memory in the sandbox, counted against its memory-limit. |
|
| 101 | + | Tmpfs, |
|
| 102 | + | /// `ephemeral://disk`: a fresh sparse disk on the runner, for large build trees. |
|
| 103 | + | Disk, |
|
| 104 | + | } |
|
| 105 | + | ||
| 106 | + | impl MountSource { |
|
| 107 | + | pub fn as_str(self) -> &'static str { |
|
| 108 | + | match self { |
|
| 109 | + | MountSource::Tmpfs => "ephemeral://tmpfs", |
|
| 110 | + | MountSource::Disk => "ephemeral://disk", |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | /// Mounts a job may declare. |
|
| 116 | + | pub const MAX_MOUNTS: usize = 4; |
|
| 117 | + | /// Sizes a mount may have: 16 MiB to 64 GiB. |
|
| 118 | + | pub const MIN_MOUNT_MIB: u64 = 16; |
|
| 119 | + | pub const MAX_MOUNT_MIB: u64 = 64 * 1024; |
|
| 120 | + | ||
| 121 | + | /// Whether `t` is a clean absolute path strictly under `/src` or `/tmp`. |
|
| 122 | + | fn mount_target_ok(t: &str) -> bool { |
|
| 123 | + | let under = t.strip_prefix("/src/").or_else(|| t.strip_prefix("/tmp/")); |
|
| 124 | + | under.is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")) |
|
| 82 | 125 | } |
|
| 83 | 126 | ||
| 84 | 127 | /// One problem in a pipeline, at a 1-based line. |
|
| … | 26 unchanged lines |
||
| 111 | 154 | ||
| 112 | 155 | /// Nodes of the full spec that M0 recognises and refuses. |
|
| 113 | 156 | const LATER_TOP: &[&str] = &["publish", "secret"]; |
|
| 114 | - | const LATER_JOB: &[&str] = &["mount", "output", "secret", "attest"]; |
|
| 157 | + | const LATER_JOB: &[&str] = &["output", "secret", "attest"]; |
|
| 115 | 158 | ||
| 116 | 159 | struct Ctx<'a> { |
|
| 117 | 160 | src: &'a str, |
|
| … | 360 unchanged lines |
||
| 478 | 521 | Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network? }) |
|
| 479 | 522 | } |
|
| 480 | 523 | ||
| 524 | + | /// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`. |
|
| 525 | + | fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> { |
|
| 526 | + | let (mut source, mut target, mut size) = (None, None, None); |
|
| 527 | + | let mut seen = BTreeSet::new(); |
|
| 528 | + | for c in block(ctx, node) { |
|
| 529 | + | let key = c.name().value(); |
|
| 530 | + | if !seen.insert(key.to_string()) { |
|
| 531 | + | ctx.err(c.span(), format!("`{key}` is given twice")); |
|
| 532 | + | continue; |
|
| 533 | + | } |
|
| 534 | + | ctx.no_children(c); |
|
| 535 | + | match key { |
|
| 536 | + | "source" => { |
|
| 537 | + | source = match ctx.one_string(c).as_deref() { |
|
| 538 | + | Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs), |
|
| 539 | + | Some("ephemeral://disk") => Some(MountSource::Disk), |
|
| 540 | + | Some(other) => { |
|
| 541 | + | ctx.err( |
|
| 542 | + | c.span(), |
|
| 543 | + | format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""), |
|
| 544 | + | ); |
|
| 545 | + | None |
|
| 546 | + | } |
|
| 547 | + | None => None, |
|
| 548 | + | } |
|
| 549 | + | } |
|
| 550 | + | "target" => { |
|
| 551 | + | target = match ctx.one_string(c) { |
|
| 552 | + | Some(t) if mount_target_ok(&t) => Some(t), |
|
| 553 | + | Some(t) => { |
|
| 554 | + | ctx.err( |
|
| 555 | + | c.span(), |
|
| 556 | + | format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"), |
|
| 557 | + | ); |
|
| 558 | + | None |
|
| 559 | + | } |
|
| 560 | + | None => None, |
|
| 561 | + | } |
|
| 562 | + | } |
|
| 563 | + | "size" => { |
|
| 564 | + | size = match ctx.one_string(c) { |
|
| 565 | + | Some(m) => match parse_memory(&m) { |
|
| 566 | + | Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32), |
|
| 567 | + | Some(_) => { |
|
| 568 | + | ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB")); |
|
| 569 | + | None |
|
| 570 | + | } |
|
| 571 | + | None => { |
|
| 572 | + | ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\"")); |
|
| 573 | + | None |
|
| 574 | + | } |
|
| 575 | + | }, |
|
| 576 | + | None => None, |
|
| 577 | + | } |
|
| 578 | + | } |
|
| 579 | + | "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")), |
|
| 580 | + | other => ctx.err(c.span(), format!("unknown mount setting `{other}`")), |
|
| 581 | + | } |
|
| 582 | + | } |
|
| 583 | + | for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] { |
|
| 584 | + | if missing && !seen.contains(name) { |
|
| 585 | + | ctx.err(node.span(), format!("a mount needs a {name}")); |
|
| 586 | + | } |
|
| 587 | + | } |
|
| 588 | + | Some(Mount { source: source?, target: target?, size_mib: size? }) |
|
| 589 | + | } |
|
| 590 | + | ||
| 481 | 591 | fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> { |
|
| 482 | 592 | let name = ctx.one_string(node)?; |
|
| 483 | 593 | let named = ctx.name_ok(node.span(), "job", &name); |
|
| 484 | - | let mut job = Job { name: name.clone(), sandbox: String::new(), scope: vec![], depends_on: vec![], env: BTreeMap::new(), exec: vec![] }; |
|
| 594 | + | let mut job = Job { |
|
| 595 | + | name: name.clone(), |
|
| 596 | + | sandbox: String::new(), |
|
| 597 | + | scope: vec![], |
|
| 598 | + | depends_on: vec![], |
|
| 599 | + | env: BTreeMap::new(), |
|
| 600 | + | exec: vec![], |
|
| 601 | + | mounts: vec![], |
|
| 602 | + | }; |
|
| 485 | 603 | let mut seen = BTreeSet::new(); |
|
| 486 | 604 | for c in block(ctx, node) { |
|
| 487 | 605 | let key = c.name().value(); |
|
| 488 | - | if key != "scope" && key != "depends-on" && !seen.insert(key.to_string()) { |
|
| 606 | + | if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) { |
|
| 489 | 607 | ctx.err(c.span(), format!("`{key}` is given twice")); |
|
| 490 | 608 | } |
|
| 491 | 609 | match key { |
|
| … | 44 unchanged lines |
||
| 536 | 654 | ctx.err(c.span(), "`exec` arguments cannot contain NUL"); |
|
| 537 | 655 | } |
|
| 538 | 656 | } |
|
| 657 | + | "mount" => { |
|
| 658 | + | if !c.entries().is_empty() { |
|
| 659 | + | ctx.err(c.span(), "`mount` takes a block of source, target and size"); |
|
| 660 | + | } |
|
| 661 | + | if let Some(m) = parse_mount(ctx, c) { |
|
| 662 | + | if job.mounts.len() == MAX_MOUNTS { |
|
| 663 | + | ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts")); |
|
| 664 | + | } else if let Some(other) = job.mounts.iter().find(|o| { |
|
| 665 | + | o.target == m.target |
|
| 666 | + | || m.target.starts_with(&format!("{}/", o.target)) |
|
| 667 | + | || o.target.starts_with(&format!("{}/", m.target)) |
|
| 668 | + | }) { |
|
| 669 | + | ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target)); |
|
| 670 | + | } else { |
|
| 671 | + | job.mounts.push(m); |
|
| 672 | + | } |
|
| 673 | + | } |
|
| 674 | + | } |
|
| 539 | 675 | n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")), |
|
| 540 | 676 | other => ctx.err(c.span(), format!("unknown job setting `{other}`")), |
|
| 541 | 677 | } |
|
| 542 | 678 | } |
|
| 679 | + | job.mounts.sort_by(|a, b| a.target.cmp(&b.target)); |
|
| 543 | 680 | if job.sandbox.is_empty() && !seen.contains("sandbox") { |
|
| 544 | 681 | ctx.err(node.span(), format!("job {name:?} needs a sandbox")); |
|
| 545 | 682 | } |
|
| … | 9 unchanged lines |
modified crates/flow-core/src/tests.rs +71 −1
| … | 75 unchanged lines |
||
| 76 | 76 | let ps = problems(&src); |
|
| 77 | 77 | let has = |line: usize, text: &str| ps.iter().any(|(l, m)| *l == line && m.contains(text)); |
|
| 78 | 78 | assert!(has(11, "which is not declared") && has(11, "\"missing\""), "{ps:?}"); |
|
| 79 | - | assert!(has(14, "`mount` is not in M0"), "{ps:?}"); |
|
| 79 | + | assert!(has(14, "mount source \"oxy://workspace\" is not in M0 yet"), "{ps:?}"); |
|
| 80 | 80 | assert!(has(16, "\"nope\""), "{ps:?}"); |
|
| 81 | 81 | assert!(has(19, "`exec` needs a program"), "{ps:?}"); |
|
| 82 | 82 | assert!(has(21, "unknown node `flavour`"), "{ps:?}"); |
|
| … | 92 unchanged lines |
||
| 175 | 175 | let mut r2 = r.clone(); |
|
| 176 | 176 | r2.exit_code = 1; |
|
| 177 | 177 | assert_ne!(r.hash(), r2.hash()); |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | #[test] |
|
| 181 | + | fn ephemeral_mounts_parse_sort_and_hash() { |
|
| 182 | + | let plain = Pipeline::parse(&doc("job \"b\" {\n sandbox \"alpine\"\n exec \"cargo\" \"build\"\n}\n")).unwrap(); |
|
| 183 | + | let p = Pipeline::parse(&doc(r#"job "b" { |
|
| 184 | + | sandbox "alpine" |
|
| 185 | + | mount { |
|
| 186 | + | source "ephemeral://tmpfs" |
|
| 187 | + | target "/tmp/cache" |
|
| 188 | + | size "256MiB" |
|
| 189 | + | } |
|
| 190 | + | mount { |
|
| 191 | + | source "ephemeral://disk" |
|
| 192 | + | target "/src/target" |
|
| 193 | + | size "8GiB" |
|
| 194 | + | } |
|
| 195 | + | exec "cargo" "build" |
|
| 196 | + | } |
|
| 197 | + | "#)) |
|
| 198 | + | .unwrap(); |
|
| 199 | + | let b = &p.jobs["b"]; |
|
| 200 | + | assert_eq!( |
|
| 201 | + | b.mounts, |
|
| 202 | + | vec![ |
|
| 203 | + | Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 }, |
|
| 204 | + | Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 }, |
|
| 205 | + | ], |
|
| 206 | + | "sorted by target" |
|
| 207 | + | ); |
|
| 208 | + | assert_ne!(p.hash(), plain.hash(), "mounts are part of the job"); |
|
| 209 | + | // A job without mounts encodes as before mounts existed: no field 6. |
|
| 210 | + | let v = oxy_core::cbor::decode(&plain.canonical()).unwrap(); |
|
| 211 | + | let text = format!("{v:?}"); |
|
| 212 | + | assert!(!text.contains("ephemeral"), "{text}"); |
|
| 213 | + | let json = serde_json::to_value(&plain.jobs["b"]).unwrap(); |
|
| 214 | + | assert!(json.get("mounts").is_none(), "absent from the API too"); |
|
| 215 | + | let back: Job = serde_json::from_value(json).unwrap(); |
|
| 216 | + | assert!(back.mounts.is_empty()); |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | #[test] |
|
| 220 | + | fn mounts_are_checked() { |
|
| 221 | + | let job = |mount: &str| doc(&format!("job \"b\" {{\n sandbox \"alpine\"\n mount {{\n{mount}\n }}\n exec \"true\"\n}}\n")); |
|
| 222 | + | let has = |src: String, want: &str| { |
|
| 223 | + | let ps = problems(&src); |
|
| 224 | + | assert!(ps.iter().any(|(_, m)| m.contains(want)), "{want:?} not in {ps:?}"); |
|
| 225 | + | }; |
|
| 226 | + | has(job("source \"oxy://workspace\"\ntarget \"/src/x\"\nsize \"1GiB\""), "not in M0 yet"); |
|
| 227 | + | has(job("source \"ephemeral://disk\"\ntarget \"/etc/x\"\nsize \"1GiB\""), "under /src or /tmp"); |
|
| 228 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src\"\nsize \"1GiB\""), "under /src or /tmp"); |
|
| 229 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/../etc\"\nsize \"1GiB\""), "without . or .."); |
|
| 230 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x/\"\nsize \"1GiB\""), "trailing /"); |
|
| 231 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"8MiB\""), "from 16MiB to 64GiB"); |
|
| 232 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"65GiB\""), "from 16MiB to 64GiB"); |
|
| 233 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"lots\""), "whole number"); |
|
| 234 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x\""), "needs a size"); |
|
| 235 | + | has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"1GiB\"\nreadonly #true"), "`readonly` is not in M0 yet"); |
|
| 236 | + | has(job("source \"ephemeral://disk\"\nsource \"ephemeral://tmpfs\"\ntarget \"/src/x\"\nsize \"1GiB\""), "given twice"); |
|
| 237 | + | let two = |a: &str, b: &str| { |
|
| 238 | + | doc(&format!( |
|
| 239 | + | "job \"b\" {{\n sandbox \"alpine\"\n mount {{ source \"ephemeral://disk\"; target \"{a}\"; size \"1GiB\"; }}\n mount {{ source \"ephemeral://disk\"; target \"{b}\"; size \"1GiB\"; }}\n exec \"true\"\n}}\n" |
|
| 240 | + | )) |
|
| 241 | + | }; |
|
| 242 | + | has(two("/src/t", "/src/t/deps"), "overlaps"); |
|
| 243 | + | has(two("/src/t", "/src/t"), "overlaps"); |
|
| 244 | + | assert!(Pipeline::parse(&two("/src/t", "/src/tt")).is_ok(), "a shared prefix is not nesting"); |
|
| 245 | + | let five: String = |
|
| 246 | + | (0..5).map(|i| format!(" mount {{ source \"ephemeral://tmpfs\"; target \"/tmp/m{i}\"; size \"16MiB\"; }}\n")).collect(); |
|
| 247 | + | has(doc(&format!("job \"b\" {{\n sandbox \"alpine\"\n{five} exec \"true\"\n}}\n")), "at most 4 mounts"); |
|
| 178 | 248 | } |
modified crates/flow-init/src/main.rs +53 −0
| … | 18 unchanged lines |
||
| 19 | 19 | struct JobSpec { |
|
| 20 | 20 | argv: Vec<String>, |
|
| 21 | 21 | env: Vec<(String, String)>, |
|
| 22 | + | #[serde(default)] |
|
| 23 | + | mounts: Vec<MountSpec>, |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// An ephemeral mount from the host: a tmpfs of `size_mib`, or the disk at |
|
| 27 | + | /// `device`, mounted at `target` under the new root. |
|
| 28 | + | #[derive(serde::Deserialize)] |
|
| 29 | + | struct MountSpec { |
|
| 30 | + | kind: String, |
|
| 31 | + | target: String, |
|
| 32 | + | size_mib: u32, |
|
| 33 | + | #[serde(default)] |
|
| 34 | + | device: Option<String>, |
|
| 22 | 35 | } |
|
| 23 | 36 | ||
| 24 | 37 | const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
|
| … | 29 unchanged lines |
||
| 54 | 67 | CString::new(s).map_err(|_| format!("{s:?} contains NUL")) |
|
| 55 | 68 | } |
|
| 56 | 69 | ||
| 70 | + | fn chown(path: &str, uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> { |
|
| 71 | + | let p = cstr(path)?; |
|
| 72 | + | if unsafe { libc::chown(p.as_ptr(), uid, gid) } != 0 { |
|
| 73 | + | return Err(format!("chown {path}: {}", std::io::Error::last_os_error())); |
|
| 74 | + | } |
|
| 75 | + | Ok(()) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | /// Create `path` and its missing parents, each owned by the job's user. |
|
| 79 | + | fn make_dirs_owned(path: &str, uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> { |
|
| 80 | + | let mut missing = Vec::new(); |
|
| 81 | + | let mut p = std::path::Path::new(path); |
|
| 82 | + | while !p.exists() { |
|
| 83 | + | missing.push(p.to_path_buf()); |
|
| 84 | + | p = match p.parent() { |
|
| 85 | + | Some(parent) => parent, |
|
| 86 | + | None => break, |
|
| 87 | + | }; |
|
| 88 | + | } |
|
| 89 | + | for d in missing.iter().rev() { |
|
| 90 | + | std::fs::create_dir(d).map_err(|e| format!("mkdir {}: {e}", d.display()))?; |
|
| 91 | + | chown(&d.to_string_lossy(), uid, gid)?; |
|
| 92 | + | } |
|
| 93 | + | Ok(()) |
|
| 94 | + | } |
|
| 95 | + | ||
| 57 | 96 | fn mount(src: &str, target: &str, fstype: &str, flags: libc::c_ulong, data: &str) -> Result<(), String> { |
|
| 58 | 97 | let (s, t, f, d) = (cstr(src)?, cstr(target)?, cstr(fstype)?, cstr(data)?); |
|
| 59 | 98 | let rc = unsafe { libc::mount(s.as_ptr(), t.as_ptr(), f.as_ptr(), flags, d.as_ptr() as *const libc::c_void) }; |
|
| … | 34 unchanged lines |
||
| 94 | 133 | (st.st_uid, st.st_gid) |
|
| 95 | 134 | } |
|
| 96 | 135 | }; |
|
| 136 | + | // Ephemeral mounts: mount points are made (and owned by the job's user) |
|
| 137 | + | // under the writable /src or /tmp, then each is mounted and given to it. |
|
| 138 | + | for m in &job.mounts { |
|
| 139 | + | let target = format!("/newroot{}", m.target); |
|
| 140 | + | make_dirs_owned(&target, uid, gid)?; |
|
| 141 | + | match (m.kind.as_str(), &m.device) { |
|
| 142 | + | ("tmpfs", _) => mount("tmpfs", &target, "tmpfs", nosuid, &format!("size={}m,uid={uid},gid={gid},mode=755", m.size_mib))?, |
|
| 143 | + | ("disk", Some(dev)) => { |
|
| 144 | + | mount(dev, &target, "ext4", nosuid, "")?; |
|
| 145 | + | chown(&target, uid, gid)?; |
|
| 146 | + | } |
|
| 147 | + | (kind, _) => return Err(format!("unknown mount kind {kind}")), |
|
| 148 | + | } |
|
| 149 | + | } |
|
| 97 | 150 | let root = cstr("/newroot")?; |
|
| 98 | 151 | if unsafe { libc::chroot(root.as_ptr()) } != 0 || std::env::set_current_dir("/src").is_err() { |
|
| 99 | 152 | return Err(format!("enter the rootfs: {}", std::io::Error::last_os_error())); |
|
| … | 86 unchanged lines |
modified crates/flow/src/microvm.rs +68 −7
| … | 5 unchanged lines |
||
| 6 | 6 | use crate::home::Home; |
|
| 7 | 7 | use anyhow::{bail, Context, Result}; |
|
| 8 | 8 | use flow_core::api::Record; |
|
| 9 | - | use flow_core::{Environment, Job, Sandbox}; |
|
| 9 | + | use flow_core::{Environment, Job, MountSource, Sandbox}; |
|
| 10 | 10 | use std::io::{BufRead, BufReader, Read}; |
|
| 11 | 11 | use std::path::Path; |
|
| 12 | 12 | use std::sync::mpsc; |
|
| … | 18 unchanged lines |
||
| 31 | 31 | ("init".into(), cfg.init.blake3.clone()), |
|
| 32 | 32 | ("memory_mib".into(), sandbox.memory_mib.to_string()), |
|
| 33 | 33 | ("cpus".into(), sandbox.cpus.to_string()), |
|
| 34 | - | ], |
|
| 34 | + | ] |
|
| 35 | + | .into_iter() |
|
| 36 | + | .chain(job.mounts.iter().map(|m| (format!("mount {}", m.target), format!("{} {} MiB", m.source.as_str(), m.size_mib)))) |
|
| 37 | + | .collect(), |
|
| 35 | 38 | rootfs: sandbox.rootfs.clone(), |
|
| 36 | 39 | argv: job.exec.clone(), |
|
| 37 | 40 | env: job.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), |
|
| … | 21 unchanged lines |
||
| 59 | 62 | let rootfs = home.verified(&rootfs, &sandbox.rootfs)?; |
|
| 60 | 63 | let environment = environment(home, sandbox, job)?; |
|
| 61 | 64 | ||
| 62 | - | let spec = serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>() }); |
|
| 65 | + | let spec = guest_spec(job); |
|
| 66 | + | // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the |
|
| 67 | + | // scratch directory, attached after the workspace (vdc, vdd, ...). |
|
| 68 | + | let mut disks = Vec::new(); |
|
| 69 | + | for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() { |
|
| 70 | + | let path = scratch.join(format!("mount-{i}.ext4")); |
|
| 71 | + | crate::workspace::empty_ext4(&path, m.size_mib, "scratch")?; |
|
| 72 | + | disks.push( |
|
| 73 | + | serde_json::json!({ "drive_id": format!("mount{i}"), "path_on_host": path, "is_root_device": false, "is_read_only": false }), |
|
| 74 | + | ); |
|
| 75 | + | } |
|
| 63 | 76 | let init_bytes = std::fs::read(&init)?; |
|
| 64 | 77 | let spec_bytes = serde_json::to_vec(&spec)?; |
|
| 65 | 78 | let initrd = crate::cpio::newc(&[ |
|
| … | 2 unchanged lines |
||
| 68 | 81 | ]); |
|
| 69 | 82 | let initrd_path = scratch.join("initrd.cpio"); |
|
| 70 | 83 | std::fs::write(&initrd_path, initrd)?; |
|
| 84 | + | let drives: Vec<serde_json::Value> = [ |
|
| 85 | + | serde_json::json!({ "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }), |
|
| 86 | + | serde_json::json!({ "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }), |
|
| 87 | + | ] |
|
| 88 | + | .into_iter() |
|
| 89 | + | .chain(disks) |
|
| 90 | + | .collect(); |
|
| 71 | 91 | let vm = serde_json::json!({ |
|
| 72 | 92 | "boot-source": { |
|
| 73 | 93 | "kernel_image_path": kernel, |
|
| 74 | 94 | "initrd_path": initrd_path, |
|
| 75 | 95 | "boot_args": "console=ttyS0 reboot=k panic=-1 pci=off quiet loglevel=0 rdinit=/init", |
|
| 76 | 96 | }, |
|
| 77 | - | "drives": [ |
|
| 78 | - | { "drive_id": "rootfs", "path_on_host": rootfs, "is_root_device": false, "is_read_only": true }, |
|
| 79 | - | { "drive_id": "workspace", "path_on_host": workspace, "is_root_device": false, "is_read_only": false }, |
|
| 80 | - | ], |
|
| 97 | + | "drives": drives, |
|
| 81 | 98 | "machine-config": { "vcpu_count": sandbox.cpus, "mem_size_mib": sandbox.memory_mib }, |
|
| 82 | 99 | }); |
|
| 83 | 100 | let vm_path = scratch.join("vm.json"); |
|
| … | 60 unchanged lines |
||
| 144 | 161 | Ok(Outcome { exit_code: if timed_out { None } else { exit_code }, duration_ms: t(&started), environment, timed_out }) |
|
| 145 | 162 | } |
|
| 146 | 163 | ||
| 164 | + | /// What `flow-init` reads from `/job.json`: argv, env, and the mounts |
|
| 165 | + | /// with the device each disk mount is attached as (vdc onwards). |
|
| 166 | + | pub fn guest_spec(job: &Job) -> serde_json::Value { |
|
| 167 | + | let mut next_disk = b'c'; |
|
| 168 | + | let mounts: Vec<serde_json::Value> = job |
|
| 169 | + | .mounts |
|
| 170 | + | .iter() |
|
| 171 | + | .map(|m| match m.source { |
|
| 172 | + | MountSource::Tmpfs => serde_json::json!({ "kind": "tmpfs", "target": m.target, "size_mib": m.size_mib }), |
|
| 173 | + | MountSource::Disk => { |
|
| 174 | + | let dev = format!("/dev/vd{}", next_disk as char); |
|
| 175 | + | next_disk += 1; |
|
| 176 | + | serde_json::json!({ "kind": "disk", "target": m.target, "size_mib": m.size_mib, "device": dev }) |
|
| 177 | + | } |
|
| 178 | + | }) |
|
| 179 | + | .collect(); |
|
| 180 | + | serde_json::json!({ "argv": job.exec, "env": job.env.iter().collect::<Vec<_>>(), "mounts": mounts }) |
|
| 181 | + | } |
|
| 182 | + | ||
| 147 | 183 | fn lines(r: impl Read, console: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) { |
|
| 148 | 184 | let mut r = BufReader::new(r); |
|
| 149 | 185 | let mut buf = Vec::new(); |
|
| … | 10 unchanged lines |
||
| 160 | 196 | } |
|
| 161 | 197 | } |
|
| 162 | 198 | } |
|
| 199 | + | } |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | #[cfg(test)] |
|
| 203 | + | mod tests { |
|
| 204 | + | use super::*; |
|
| 205 | + | use flow_core::Mount; |
|
| 206 | + | ||
| 207 | + | #[test] |
|
| 208 | + | fn guest_spec_numbers_disks_after_the_workspace() { |
|
| 209 | + | let mut job: Job = serde_json::from_value(serde_json::json!({ |
|
| 210 | + | "name": "t", "sandbox": "s", "scope": [], "depends_on": [], "env": {"A": "1"}, "exec": ["true"] |
|
| 211 | + | })) |
|
| 212 | + | .unwrap(); |
|
| 213 | + | assert_eq!(guest_spec(&job)["mounts"], serde_json::json!([]), "no mounts"); |
|
| 214 | + | job.mounts = vec![ |
|
| 215 | + | Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 }, |
|
| 216 | + | Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 }, |
|
| 217 | + | Mount { source: MountSource::Disk, target: "/tmp/z".into(), size_mib: 64 }, |
|
| 218 | + | ]; |
|
| 219 | + | let spec = guest_spec(&job); |
|
| 220 | + | assert_eq!(spec["mounts"][0], serde_json::json!({"kind": "disk", "target": "/src/target", "size_mib": 8192, "device": "/dev/vdc"})); |
|
| 221 | + | assert_eq!(spec["mounts"][1], serde_json::json!({"kind": "tmpfs", "target": "/tmp/cache", "size_mib": 256})); |
|
| 222 | + | assert_eq!(spec["mounts"][2]["device"], "/dev/vdd"); |
|
| 223 | + | assert_eq!(spec["env"], serde_json::json!([["A", "1"]])); |
|
| 163 | 224 | } |
|
| 164 | 225 | } |
modified crates/flow/src/workspace.rs +18 −0
| … | 114 unchanged lines |
||
| 115 | 115 | } |
|
| 116 | 116 | Ok(()) |
|
| 117 | 117 | } |
|
| 118 | + | ||
| 119 | + | /// An empty ext4 filesystem of `mib` MiB at `out`, owned by this user like |
|
| 120 | + | /// the workspace. mkfs.ext4 creates the file sparse, so only what the job |
|
| 121 | + | /// writes takes space on the runner. |
|
| 122 | + | pub fn empty_ext4(out: &Path, mib: u32, label: &str) -> Result<()> { |
|
| 123 | + | let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) }; |
|
| 124 | + | let status = std::process::Command::new("mkfs.ext4") |
|
| 125 | + | .args(["-q", "-F", "-L", label, "-E"]) |
|
| 126 | + | .arg(format!("root_owner={uid}:{gid}")) |
|
| 127 | + | .arg(out) |
|
| 128 | + | .arg(format!("{mib}M")) |
|
| 129 | + | .status() |
|
| 130 | + | .context("run mkfs.ext4 (e2fsprogs 1.43 or later)")?; |
|
| 131 | + | if !status.success() { |
|
| 132 | + | bail!("mkfs.ext4 failed for a scratch disk"); |
|
| 133 | + | } |
|
| 134 | + | Ok(()) |
|
| 135 | + | } |
modified docs/m0.md +18 −3
| … | 51 unchanged lines |
||
| 52 | 52 | - Names are 1 to 64 characters from `[A-Za-z0-9._-]`. |
|
| 53 | 53 | - `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1). |
|
| 54 | 54 | A job that wants a shell names one, as `sh` above; nothing adds one. |
|
| 55 | - | - `mount`, `output`, `publish` and `secret` are recognised and refused with |
|
| 55 | + | - `output`, `publish` and `secret` are recognised and refused with |
|
| 56 | 56 | "not in M0", so a pipeline written for the full spec fails clearly. |
|
| 57 | + | - `mount` is accepted for **ephemeral scratch** only (up to 4 per job): |
|
| 58 | + | `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`. |
|
| 59 | + | `ephemeral://tmpfs` is memory in the guest and counts against its |
|
| 60 | + | memory-limit; `ephemeral://disk` is a fresh sparse ext4 disk on the |
|
| 61 | + | runner, attached after the workspace, for build trees larger than |
|
| 62 | + | memory. The target is a clean absolute path strictly under `/src` or |
|
| 63 | + | `/tmp`, mounts may not nest, sizes run from 16 MiB to 64 GiB, and any |
|
| 64 | + | other source (`oxy://workspace` and the rest) or setting (`readonly`, |
|
| 65 | + | `include`) is refused as not in M0. Both are empty when the job starts |
|
| 66 | + | and deleted when it ends, so nothing passes between jobs. Mounts are |
|
| 67 | + | part of the job's canonical form (field 6, present only when a job has |
|
| 68 | + | mounts, so pipelines without them keep their hashes) and of the |
|
| 69 | + | receipt's environment. |
|
| 57 | 70 | ||
| 58 | 71 | The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes |
|
| 59 | 72 | and jobs in name order). Its BLAKE3 is the `pipeline_hash` used in receipts |
|
| … | 15 unchanged lines |
||
| 75 | 88 | workspace as a read-write `vdb`, and an initramfs holding `flow-init`. |
|
| 76 | 89 | 4. `flow-init` (a static binary, PID 1 in the guest) mounts `/proc`, |
|
| 77 | 90 | `/sys`, `/dev`, the rootfs at `/newroot` read-only, `vdb` at |
|
| 78 | - | `/newroot/src`, and tmpfs at `/newroot/tmp` and `/newroot/run`; it |
|
| 91 | + | `/newroot/src`, and tmpfs at `/newroot/tmp` and `/newroot/run`, then the |
|
| 92 | + | job's scratch mounts (creating their mount points, owned by the job's |
|
| 93 | + | user); it |
|
| 79 | 94 | switches root, then runs `argv` with exactly the job's `env` (plus |
|
| 80 | 95 | `PATH`, `HOME=/tmp`) in `/src` as uid 1000, with a job timeout. |
|
| 81 | 96 | 5. `flow-init` frames the child's output on the serial console as |
|
| … | 69 unchanged lines |
||
| 151 | 166 | ## 7. Not in M0 |
|
| 152 | 167 | ||
| 153 | 168 | WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree |
|
| 154 | - | into an image); `mount`, `output` and artifacts; receipt reuse; provenance |
|
| 169 | + | into an image); mounts other than ephemeral scratch, `output` and artifacts; receipt reuse; provenance |
|
| 155 | 170 | attestations and composite signatures; policy gates; secrets; proposal |
|
| 156 | 171 | runs; scope-based skipping; re-run and cancel from the hub. |