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 |