OXYGENHUB

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.