OXYGENHUB

Guest /dev/fd links; jobs are told what they build c4285535b03a

flow-init adds the links devtmpfs lacks: /dev/fd, /dev/stdin, /dev/stdout
and /dev/stderr into /proc/self/fd, so bash process substitution works in
a job as it does everywhere else.

A claim now carries the run's state hash, bookmark and number, and the
runner adds FLOW_REPO, FLOW_STATE, FLOW_HEADS, FLOW_BOOKMARK, FLOW_RUN and
FLOW_JOB to the job's environment (so to its receipt); flow run sets
FLOW_JOB and FLOW_RUN=local. A variable the pipeline sets keeps its value;
unknown values are left out.

refs #7
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 23:45 UTC
Signature
✔ signed by Andy McCall
Hash
c4285535b03a56b1d8674534801c5577d29e955146a26339c0abc8648581bc53
Depends on
c23d519168d8 e0918b887850 eb5c4db673e5 fc10d9e80383
Depended on by
f201e1bc5bcd 1b160d372d9e 46fe2c815cc3 ea56c56eaf90 1abadd4a2fa6 720eb8191059 e2c125610f43 c58efc6a74db a72abeeedd78
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
12 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified crates/flow-core/src/api.rs +33 −0
…
46 unchanged lines
47 47
    /// fetched with the same token and written at their paths.
48 48
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 49
    pub subrepos: Vec<SubrepoInput>,
50 +
    /// The state's hash on the hub (what checks are keyed by).
51 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
52 +
    pub state: String,
53 +
    /// The bookmark the run is for.
54 +
    #[serde(default, skip_serializing_if = "String::is_empty")]
55 +
    pub bookmark: String,
56 +
    /// The run's number within its repository.
57 +
    #[serde(default)]
58 +
    pub run_number: i64,
59 +
}
60 +
61 +
impl Claim {
62 +
    /// What a job is told about what it builds (docs/m0.md §4):
63 +
    /// `FLOW_REPO`, `FLOW_STATE`, `FLOW_HEADS` (full hashes joined with
64 +
    /// `+`), `FLOW_BOOKMARK`, `FLOW_RUN` and `FLOW_JOB`. Unknown values are
65 +
    /// left out.
66 +
    pub fn flow_env(&self) -> Vec<(String, String)> {
67 +
        let mut v = vec![
68 +
            ("FLOW_REPO".to_string(), self.repo.clone()),
69 +
            ("FLOW_HEADS".to_string(), self.heads.join("+")),
70 +
            ("FLOW_JOB".to_string(), self.job.name.clone()),
71 +
        ];
72 +
        if !self.state.is_empty() {
73 +
            v.push(("FLOW_STATE".into(), self.state.clone()));
74 +
        }
75 +
        if !self.bookmark.is_empty() {
76 +
            v.push(("FLOW_BOOKMARK".into(), self.bookmark.clone()));
77 +
        }
78 +
        if self.run_number > 0 {
79 +
            v.push(("FLOW_RUN".into(), self.run_number.to_string()));
80 +
        }
81 +
        v
82 +
    }
50 83
}
51 84
52 85
/// One line of output (docs/m0.md §2).
…
30 unchanged lines
modified crates/flow-core/src/pipeline.rs +13 −0
…
83 unchanged lines
84 84
    pub mounts: Vec<Mount>,
85 85
}
86 86
87 +
impl Job {
88 +
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
89 +
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
90 +
    /// sets itself keeps the pipeline's value.
91 +
    pub fn with_flow_env(&self, vars: &[(String, String)]) -> Job {
92 +
        let mut job = self.clone();
93 +
        for (k, v) in vars {
94 +
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
95 +
        }
96 +
        job
97 +
    }
98 +
}
99 +
87 100
/// An ephemeral scratch mount: empty at the start of every job and gone
88 101
/// after it, so it cannot carry anything between jobs (spec §2.2 I4).
89 102
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
…
605 unchanged lines
modified crates/flow-core/src/tests.rs +40 −0
…
256 unchanged lines
257 257
    assert_ne!(p.hash(), Pipeline::parse(&src("isolated")).unwrap().hash(), "the network is part of the sandbox");
258 258
    assert!(problems(&src("egress")).iter().any(|(l, m)| *l == 4 && m.contains("not in M0")));
259 259
}
260 +
261 +
#[test]
262 +
fn jobs_are_told_what_they_build() {
263 +
    let p = Pipeline::parse(&doc(r#"job "build" {
264 +
    sandbox "alpine"
265 +
    env {
266 +
        FLOW_BOOKMARK "pinned-by-the-pipeline"
267 +
    }
268 +
    exec "make"
269 +
}"#))
270 +
    .unwrap();
271 +
    let job = p.jobs["build"].clone();
272 +
    let claim = crate::api::Claim {
273 +
        job_id: 7,
274 +
        run_id: 40,
275 +
        repo: "andy/demo".into(),
276 +
        clone_url: "https://hub/andy/demo".into(),
277 +
        token: "t".into(),
278 +
        heads: vec!["aa".into(), "bb".into()],
279 +
        pipeline_hash: "00".into(),
280 +
        job: job.clone(),
281 +
        sandbox: p.sandboxes["alpine"].clone(),
282 +
        timeout_s: 60,
283 +
        subrepos: vec![],
284 +
        state: "cc".into(),
285 +
        bookmark: "feature/x".into(),
286 +
        run_number: 3,
287 +
    };
288 +
    let env = job.with_flow_env(&claim.flow_env()).env;
289 +
    assert_eq!(env["FLOW_REPO"], "andy/demo");
290 +
    assert_eq!(env["FLOW_HEADS"], "aa+bb");
291 +
    assert_eq!(env["FLOW_STATE"], "cc");
292 +
    assert_eq!(env["FLOW_RUN"], "3", "the run's number in its repository, not the hub's id");
293 +
    assert_eq!(env["FLOW_JOB"], "build");
294 +
    assert_eq!(env["FLOW_BOOKMARK"], "pinned-by-the-pipeline", "the pipeline's own value wins");
295 +
    // Unknown values (an older hub) are left out, not empty.
296 +
    let old = crate::api::Claim { state: String::new(), bookmark: String::new(), run_number: 0, ..claim };
297 +
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
298 +
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
299 +
}
modified crates/flow-init/src/main.rs +34 −1
…
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 +
/// The links every Linux system has under /dev and devtmpfs does not
116 +
/// make: `/dev/fd` (bash's process substitution, `<(…)`) and the standard
117 +
/// streams, all through the job's own /proc.
118 +
fn dev_links(dev: &str) -> Result<(), String> {
119 +
    for (name, target) in DEV_LINKS {
120 +
        let link = format!("{dev}/{name}");
121 +
        if std::fs::symlink_metadata(&link).is_err() {
122 +
            std::os::unix::fs::symlink(target, &link).map_err(|e| format!("link {link}: {e}"))?;
123 +
        }
124 +
    }
125 +
    Ok(())
126 +
}
127 +
128 +
const DEV_LINKS: [(&str, &str); 4] =
129 +
    [("fd", "/proc/self/fd"), ("stdin", "/proc/self/fd/0"), ("stdout", "/proc/self/fd/1"), ("stderr", "/proc/self/fd/2")];
130 +
115 131
/// Set `lo` up; the kernel gives it 127.0.0.1 and ::1 itself.
116 132
fn loopback_up() -> Result<(), String> {
117 133
    let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) };
…
42 unchanged lines
160 176
    mount("tmpfs", "/newroot/tmp", "tmpfs", nosuid, "mode=1777")?;
161 177
    mount("proc", "/newroot/proc", "proc", nosuid | libc::MS_NOEXEC, "")?;
162 178
    mount("devtmpfs", "/newroot/dev", "devtmpfs", libc::MS_NOSUID, "")?;
179 +
    dev_links("/newroot/dev")?;
163 180
    if std::path::Path::new("/newroot/run").is_dir() {
164 181
        mount("tmpfs", "/newroot/run", "tmpfs", nosuid, "mode=755")?;
165 182
    }
…
122 unchanged lines
288 305
289 306
#[cfg(test)]
290 307
mod tests {
291 -
    use super::resolve_program;
308 +
    use super::{dev_links, resolve_program};
309 +
310 +
    #[test]
311 +
    fn dev_gets_the_usual_links_once() {
312 +
        let dir = std::env::temp_dir().join(format!("flow-init-dev-{}", std::process::id()));
313 +
        let _ = std::fs::remove_dir_all(&dir);
314 +
        std::fs::create_dir_all(&dir).unwrap();
315 +
        let d = dir.to_str().unwrap();
316 +
        dev_links(d).unwrap();
317 +
        dev_links(d).unwrap();
318 +
        assert_eq!(std::fs::read_link(dir.join("fd")).unwrap(), std::path::Path::new("/proc/self/fd"));
319 +
        assert_eq!(std::fs::read_link(dir.join("stdout")).unwrap(), std::path::Path::new("/proc/self/fd/1"));
320 +
        // Through the host's /proc, the link works as in a job.
321 +
        assert!(dir.join("fd").join("0").exists() || !std::path::Path::new("/proc/self/fd/0").exists());
322 +
        std::fs::remove_dir_all(&dir).unwrap();
323 +
    }
324 +
292 325
    use std::os::unix::fs::PermissionsExt;
293 326
294 327
    #[test]
…
18 unchanged lines
modified crates/flow/src/main.rs +1 −0
…
223 unchanged lines
224 224
        std::fs::create_dir_all(&js)?;
225 225
        // Each job starts from the same files: jobs do not share writes.
226 226
        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())]);
227 228
        let out = microvm::run(home, sb, job, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?;
228 229
        let _ = std::fs::remove_dir_all(&js);
229 230
        match out.exit_code {
…
27 unchanged lines
modified crates/flow/src/runner.rs +2 −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 174
        let outcome = microvm::run(
174 175
            home,
175 176
            &claim.sandbox,
176 -
            &claim.job,
177 +
            &job,
177 178
            &scratch.join("workspace.ext4"),
178 179
            &scratch,
179 180
            Duration::from_secs(claim.timeout_s),
…
71 unchanged lines
modified docs/m0.md +7 −0
…
94 unchanged lines
95 95
   switches root, then runs `argv` (its program found in the guest's `PATH` by
96 96
   `flow-init` itself) with exactly the job's `env` (plus
97 97
   `PATH`, `HOME=/tmp`) in `/src` as uid 1000, with a job timeout.
98 +
   `/dev` gets the links devtmpfs lacks, `fd`, `stdin`, `stdout` and
99 +
   `stderr` into `/proc/self/fd`, so shell process substitution works. A job
100 +
   is told what it builds: `FLOW_REPO`, `FLOW_STATE` (the hub's state hash),
101 +
   `FLOW_HEADS` (full hashes joined with `+`), `FLOW_BOOKMARK`, `FLOW_RUN`
102 +
   (the run's number in its repository) and `FLOW_JOB`; `flow run` sets
103 +
   `FLOW_JOB` and `FLOW_RUN=local`. A variable the pipeline sets keeps its
104 +
   value. They are part of the job's environment, so of its receipt.
98 105
5. `flow-init` frames the child's output on the serial console as
99 106
   `\x1eO <line>` (stdout) and `\x1eE <line>` (stderr), then
100 107
   `\x1eX <code>`, and powers off. The kernel runs `quiet` so the host sees
…
73 unchanged lines