OXYGENHUB

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