Pipelines: runs-on asks for runner labels (refs andymccall/oxygenhub#41, part 3) e576e08617dc
A sandbox or a job may name labels a runner must have; a job needs its own and its sandbox's. They are part of the compiled job only when given, so existing jobs' hashes stay. The hub matches them at claim.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 03:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e576e08617dc609934dba3097a29d7af129d6b91315b6537dfb2edec500e6d65- Depends on
- a083cc802892 a67b97e9a717 a72abeeedd78 b647ab72d51e e0918b887850 fc10d9e80383
- Depended on by
- e87c1c138187 f4e51e63582d e20897216221 46fe2c815cc3 ea81796bb757 1abadd4a2fa6 720eb8191059 e2c125610f43 bf7bb1b80fa3 02b127d2c721
- 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
- 15 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/flow-core/src/pipeline.rs +45 −3
| … | 69 unchanged lines |
||
| 70 | 70 | /// overrides it. |
|
| 71 | 71 | #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] |
|
| 72 | 72 | pub env: BTreeMap<String, String>, |
|
| 73 | + | /// Labels a runner must have to run jobs here (`runs-on`), sorted. |
|
| 74 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 75 | + | pub runs_on: Vec<String>, |
|
| 73 | 76 | } |
|
| 74 | 77 | ||
| 75 | 78 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| … | 13 unchanged lines |
||
| 89 | 92 | /// Files the job produces, sorted by name (spec §5.2 `output`). |
|
| 90 | 93 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 91 | 94 | pub outputs: Vec<Output>, |
|
| 95 | + | /// Labels a runner must have to take this job (`runs-on`), sorted; |
|
| 96 | + | /// with its sandbox's, what the hub matches against a runner's. |
|
| 97 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 98 | + | pub runs_on: Vec<String>, |
|
| 92 | 99 | } |
|
| 93 | 100 | ||
| 94 | 101 | impl Job { |
|
| … | 3 unchanged lines |
||
| 98 | 105 | for (k, v) in &sandbox.env { |
|
| 99 | 106 | job.env.entry(k.clone()).or_insert_with(|| v.clone()); |
|
| 100 | 107 | } |
|
| 108 | + | job.runs_on.extend(sandbox.runs_on.iter().cloned()); |
|
| 109 | + | job.runs_on.sort(); |
|
| 110 | + | job.runs_on.dedup(); |
|
| 101 | 111 | job |
|
| 102 | 112 | } |
|
| 103 | 113 | ||
| … | 403 unchanged lines |
||
| 507 | 517 | (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated)); |
|
| 508 | 518 | let mut seen = BTreeSet::new(); |
|
| 509 | 519 | let mut env = BTreeMap::new(); |
|
| 520 | + | let mut runs_on = Vec::new(); |
|
| 510 | 521 | for c in block(ctx, node) { |
|
| 511 | 522 | let key = c.name().value(); |
|
| 512 | - | if !seen.insert(key.to_string()) { |
|
| 523 | + | if key != "runs-on" && !seen.insert(key.to_string()) { |
|
| 513 | 524 | ctx.err(c.span(), format!("`{key}` is given twice")); |
|
| 514 | 525 | } |
|
| 515 | 526 | if key == "env" { |
|
| 516 | 527 | parse_env(ctx, c, &mut env); |
|
| 517 | 528 | continue; |
|
| 518 | 529 | } |
|
| 530 | + | if key == "runs-on" { |
|
| 531 | + | ctx.no_children(c); |
|
| 532 | + | parse_labels(ctx, c, &mut runs_on); |
|
| 533 | + | continue; |
|
| 534 | + | } |
|
| 519 | 535 | ctx.no_children(c); |
|
| 520 | 536 | match key { |
|
| 521 | 537 | "runtime" => { |
|
| … | 76 unchanged lines |
||
| 598 | 614 | } |
|
| 599 | 615 | if !named { |
|
| 600 | 616 | return None; |
|
| 617 | + | } |
|
| 618 | + | Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env, runs_on }) |
|
| 619 | + | } |
|
| 620 | + | ||
| 621 | + | /// The labels of a `runs-on` line: lower-case letters, digits, `-`, `_` |
|
| 622 | + | /// and `.`, at most 64 bytes each; into `into`, sorted, once each. |
|
| 623 | + | fn parse_labels(ctx: &mut Ctx, c: &KdlNode, into: &mut Vec<String>) { |
|
| 624 | + | let labels = ctx.strings(c); |
|
| 625 | + | if labels.is_empty() { |
|
| 626 | + | ctx.err(c.span(), "`runs-on` needs one or more labels, as runs-on \"riscv64\""); |
|
| 601 | 627 | } |
|
| 602 | - | Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env }) |
|
| 628 | + | for l in labels { |
|
| 629 | + | let ok = !l.is_empty() |
|
| 630 | + | && l.len() <= 64 |
|
| 631 | + | && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')); |
|
| 632 | + | if ok { |
|
| 633 | + | into.push(l); |
|
| 634 | + | } else { |
|
| 635 | + | ctx.err(c.span(), format!("runs-on {l:?}: a label is lower-case letters, digits, -, _ and ., at most 64")); |
|
| 636 | + | } |
|
| 637 | + | } |
|
| 638 | + | into.sort(); |
|
| 639 | + | into.dedup(); |
|
| 603 | 640 | } |
|
| 604 | 641 | ||
| 605 | 642 | /// An `env` block of `NAME "value"` lines, into `env`. |
|
| … | 145 unchanged lines |
||
| 751 | 788 | exec: vec![], |
|
| 752 | 789 | mounts: vec![], |
|
| 753 | 790 | outputs: vec![], |
|
| 791 | + | runs_on: vec![], |
|
| 754 | 792 | }; |
|
| 755 | 793 | let mut seen = BTreeSet::new(); |
|
| 756 | 794 | let mut outputs_at = None; |
|
| 757 | 795 | for c in block(ctx, node) { |
|
| 758 | 796 | let key = c.name().value(); |
|
| 759 | - | if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) { |
|
| 797 | + | if key != "scope" && key != "depends-on" && key != "mount" && key != "runs-on" && !seen.insert(key.to_string()) { |
|
| 760 | 798 | ctx.err(c.span(), format!("`{key}` is given twice")); |
|
| 761 | 799 | } |
|
| 762 | 800 | match key { |
|
| … | 13 unchanged lines |
||
| 776 | 814 | "depends-on" => { |
|
| 777 | 815 | ctx.no_children(c); |
|
| 778 | 816 | job.depends_on.extend(ctx.strings(c)); |
|
| 817 | + | } |
|
| 818 | + | "runs-on" => { |
|
| 819 | + | ctx.no_children(c); |
|
| 820 | + | parse_labels(ctx, c, &mut job.runs_on); |
|
| 779 | 821 | } |
|
| 780 | 822 | "env" => parse_env(ctx, c, &mut job.env), |
|
| 781 | 823 | "exec" => { |
|
| … | 60 unchanged lines |
modified crates/flow-core/src/tests.rs +19 −0
| … | 367 unchanged lines |
||
| 368 | 368 | let p = Pipeline::parse(&doc("job \"a\" {\n sandbox \"alpine\"\n exec \"printf\" \"\\u{1b}[32mok\\u{1b}[0m\"\n}\n")).unwrap(); |
|
| 369 | 369 | assert_eq!(p.jobs["a"].exec[1], "\u{1b}[32mok\u{1b}[0m"); |
|
| 370 | 370 | } |
|
| 371 | + | ||
| 372 | + | #[test] |
|
| 373 | + | fn runs_on_asks_for_runner_labels() { |
|
| 374 | + | let src = doc("job \"cross\" {\n sandbox \"alpine\"\n runs-on \"riscv64\" \"big-disk\"\n exec \"make\"\n}\n") |
|
| 375 | + | .replace(" network", " runs-on \"gpu\"\n network"); |
|
| 376 | + | let p = Pipeline::parse(&src).unwrap(); |
|
| 377 | + | let job = &p.jobs["cross"]; |
|
| 378 | + | assert_eq!(job.runs_on, vec!["big-disk", "riscv64"], "sorted"); |
|
| 379 | + | let sandbox = &p.sandboxes["alpine"]; |
|
| 380 | + | assert_eq!(sandbox.runs_on, vec!["gpu"]); |
|
| 381 | + | assert_eq!(job.in_sandbox(sandbox).runs_on, vec!["big-disk", "gpu", "riscv64"], "with its sandbox's"); |
|
| 382 | + | // A pipeline without runs-on serialises as before, so its jobs' hashes stay. |
|
| 383 | + | let plain = Pipeline::parse(&doc("job \"x\" {\n sandbox \"alpine\"\n exec \"true\"\n}\n")).unwrap(); |
|
| 384 | + | assert!(!serde_json::to_string(&plain.jobs["x"]).unwrap().contains("runs_on")); |
|
| 385 | + | let bad = problems(&doc("job \"x\" {\n sandbox \"alpine\"\n runs-on \"RISCV\"\n exec \"true\"\n}\n")); |
|
| 386 | + | assert!(bad.iter().any(|(_, m)| m.contains("a label is lower-case")), "{bad:?}"); |
|
| 387 | + | let none = problems(&doc("job \"y\" {\n sandbox \"alpine\"\n runs-on\n exec \"true\"\n}\n")); |
|
| 388 | + | assert!(none.iter().any(|(_, m)| m.contains("needs one or more labels")), "{none:?}"); |
|
| 389 | + | } |
modified crates/flow/src/outputs.rs +1 −0
| … | 74 unchanged lines |
||
| 75 | 75 | Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 }, |
|
| 76 | 76 | ], |
|
| 77 | 77 | outputs: vec![], |
|
| 78 | + | runs_on: vec![], |
|
| 78 | 79 | } |
|
| 79 | 80 | } |
|
| 80 | 81 | ||
| … | 24 unchanged lines |
modified docs/spec.md +2 −0
| … | 160 unchanged lines |
||
| 161 | 161 | } |
|
| 162 | 162 | ||
| 163 | 163 | ||
| 164 | + | **Runner labels.** A sandbox or a job may say `runs-on "LABEL" ...`: lower-case letters, digits, `-`, `_` and `.`, at most 64 bytes each, given on one or more lines. A job needs a runner that has every label its sandbox and it name. A runner reports its labels and architecture when it registers and on each claim (`flow runner register --label`). The hub hands a job only to a runner with all of them, within the runner's scope (OxygenHub spec 13.2), and a job no runner can take waits, saying so on the queue. Labels are part of the compiled job, so a job that asks for none compiles as before. |
|
| 165 | + | ||
| 164 | 166 | ### 3.2 Canonical Compilation |
|
| 165 | 167 | Upon commit, .flow/pipeline.kdl is validated and compiled into a deterministic |
|
| 166 | 168 | CBOR job-graph blob. Runners evaluate the canonical CBOR to prevent syntax or |
|
| … | 246 unchanged lines |