OXYGENHUB
# 3e3cc9475ab3 oxygenflow / crates / flow-core / src / pipeline.rs
46.8 KiB · Rust · last changed Architecture awareness: image manifests, arch on a sandbox, archs on the claim 1 month ago source blame history raw
1
//! `.flow/pipeline.kdl`: the M0 subset of spec §3 (docs/m0.md §1), parsed
2
//! from KDL 2.0 and validated before anything runs. Every problem is
3
//! reported with its line, and all of them at once.
4
5
use serde::{Deserialize, Serialize};
6
use std::collections::{BTreeMap, BTreeSet};
7
use std::fmt;
8
9
/// Where a pipeline lives in the state being built.
10
pub const PIPELINE_PATH: &str = ".flow/pipeline.kdl";
11
12
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13
pub struct Pipeline {
14
    pub schema_version: u32,
15
    /// By name.
16
    pub sandboxes: BTreeMap<String, Sandbox>,
17
    /// By name.
18
    pub jobs: BTreeMap<String, Job>,
19
    /// When the pipeline's jobs run, unless a job says (oxygenflow#24);
20
    /// absent: on every push and from "Run pipeline".
21
    #[serde(default, skip_serializing_if = "Option::is_none")]
22
    pub on: Option<On>,
23
}
24
25
/// What starts a job (`on { push "main"; tag "v*"; manual; schedule "0 3 * * *" }`).
26
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27
pub struct On {
28
    /// Bookmark globs a push runs it for; `Some(vec![])`: any bookmark.
29
    #[serde(default, skip_serializing_if = "Option::is_none")]
30
    pub push: Option<Vec<String>>,
31
    /// Tag globs a pushed tag runs it for; `Some(vec![])`: any tag.
32
    #[serde(default, skip_serializing_if = "Option::is_none")]
33
    pub tag: Option<Vec<String>>,
34
    /// From "Run pipeline" (and `oh run start`).
35
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36
    pub manual: bool,
37
    /// Five-field cron lines, on the hub's clock, for the default bookmark.
38
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39
    pub schedule: Vec<String>,
40
}
41
42
/// Why a run started, for [`On::matches`].
43
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44
pub enum Event<'a> {
45
    /// A push moved this bookmark.
46
    Push(&'a str),
47
    /// This tag was pushed.
48
    Tag(&'a str),
49
    /// "Run pipeline", `oh run start`.
50
    Manual,
51
    /// A `schedule` line came due.
52
    Schedule,
53
}
54
55
impl On {
56
    /// What a job without any `on` runs for: every push, and by hand.
57
    pub fn default_on() -> On {
58
        On { push: Some(vec![]), tag: None, manual: true, schedule: vec![] }
59
    }
60
61
    pub fn matches(&self, event: Event<'_>) -> bool {
62
        let any = |globs: &Option<Vec<String>>, name: &str| match globs {
63
            None => false,
64
            Some(g) if g.is_empty() => true,
65
            Some(g) => g.iter().any(|p| globset::Glob::new(p).is_ok_and(|p| p.compile_matcher().is_match(name))),
66
        };
67
        match event {
68
            Event::Push(b) => any(&self.push, b),
69
            Event::Tag(t) => any(&self.tag, t),
70
            Event::Manual => self.manual,
71
            Event::Schedule => !self.schedule.is_empty(),
72
        }
73
    }
74
}
75
76
impl Pipeline {
77
    /// When `job` runs: its own `on`, else the pipeline's, else the default.
78
    pub fn on_of(&self, job: &Job) -> On {
79
        job.on.clone().or_else(|| self.on.clone()).unwrap_or_else(On::default_on)
80
    }
81
}
82
83
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
84
#[serde(rename_all = "lowercase")]
85
pub enum Runtime {
86
    /// Tier 0 (spec §4.1); refused in M0.
87
    Wasm,
88
    /// Tier 1 (spec §4.2): namespaces, Landlock, seccomp, cgroups.
89
    Syscall,
90
    /// Tier 2 (spec §4.3), the default.
91
    Microvm,
92
}
93
94
impl Runtime {
95
    pub fn as_str(self) -> &'static str {
96
        match self {
97
            Runtime::Wasm => "wasm",
98
            Runtime::Syscall => "syscall",
99
            Runtime::Microvm => "microvm",
100
        }
101
    }
102
}
103
104
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
105
#[serde(rename_all = "lowercase")]
106
pub enum Network {
107
    Isolated,
108
    Loopback,
109
    Egress,
110
}
111
112
impl Network {
113
    pub fn as_str(self) -> &'static str {
114
        match self {
115
            Network::Isolated => "isolated",
116
            Network::Loopback => "loopback",
117
            Network::Egress => "egress",
118
        }
119
    }
120
}
121
122
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
123
pub struct Sandbox {
124
    pub name: String,
125
    pub runtime: Runtime,
126
    /// BLAKE3 of the rootfs image, hex.
127
    pub rootfs: String,
128
    pub memory_mib: u32,
129
    pub cpus: u32,
130
    pub network: Network,
131
    /// Environment every job in this sandbox starts from; a job's own `env`
132
    /// overrides it.
133
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134
    pub env: BTreeMap<String, String>,
135
    /// Labels a runner must have to run jobs here (`runs-on`), sorted.
136
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137
    pub runs_on: Vec<String>,
138
    /// How long each job here may run, in seconds (`timeout "2h"`), when
139
    /// the job does not say; the hub caps it at the site's maximum.
140
    #[serde(default, skip_serializing_if = "Option::is_none")]
141
    pub timeout_s: Option<u64>,
142
    /// The architecture the jobs need (`arch "riscv64"`, spec §11.3);
143
    /// without it, the rootfs image's.
144
    #[serde(default, skip_serializing_if = "Option::is_none")]
145
    pub arch: Option<String>,
146
}
147
148
/// An architecture name as runners report it: `x86_64`, `aarch64`, `riscv64`.
149
pub fn arch_ok(a: &str) -> bool {
150
    (2..=16).contains(&a.len()) && a.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
151
}
152
153
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154
pub struct Job {
155
    pub name: String,
156
    pub sandbox: String,
157
    /// Glob patterns over repository paths (spec §6.2).
158
    pub scope: Vec<String>,
159
    /// Sorted.
160
    pub depends_on: Vec<String>,
161
    pub env: BTreeMap<String, String>,
162
    /// Executed as is: no shell, no expansion (spec §2.2 I2).
163
    pub exec: Vec<String>,
164
    /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`).
165
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166
    pub mounts: Vec<Mount>,
167
    /// Files the job produces, sorted by name (spec §5.2 `output`).
168
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169
    pub outputs: Vec<Output>,
170
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
171
    /// supplies each by name, the runner mounts it as a file.
172
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
173
    pub secrets: Vec<Secret>,
174
    /// Identities the job asks the hub for (oxygenhub#103): an OIDC token
175
    /// for an audience, mounted as a file like a secret.
176
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177
    pub identities: Vec<Identity>,
178
    /// Labels a runner must have to take this job (`runs-on`), sorted;
179
    /// with its sandbox's, what the hub matches against a runner's.
180
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181
    pub runs_on: Vec<String>,
182
    /// How long the job may run, in seconds (`timeout "90m"`); its
183
    /// sandbox's when absent, else the site's default (oxygenflow#23).
184
    #[serde(default, skip_serializing_if = "Option::is_none")]
185
    pub timeout_s: Option<u64>,
186
    /// When it runs, over the pipeline's `on` (oxygenflow#24).
187
    #[serde(default, skip_serializing_if = "Option::is_none")]
188
    pub on: Option<On>,
189
}
190
191
impl Job {
192
    /// The job's environment on top of its sandbox's: what it runs with.
193
    pub fn in_sandbox(&self, sandbox: &Sandbox) -> Job {
194
        let mut job = self.clone();
195
        for (k, v) in &sandbox.env {
196
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
197
        }
198
        job.runs_on.extend(sandbox.runs_on.iter().cloned());
199
        job.runs_on.sort();
200
        job.runs_on.dedup();
201
        job.timeout_s = job.timeout_s.or(sandbox.timeout_s);
202
        job
203
    }
204
205
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
206
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
207
    /// sets itself keeps the pipeline's value.
208
    pub fn with_flow_env(&self, vars: &[(String, String)]) -> Job {
209
        let mut job = self.clone();
210
        for (k, v) in vars {
211
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
212
        }
213
        job
214
    }
215
}
216
217
/// An ephemeral scratch mount: empty at the start of every job and gone
218
/// after it, so it cannot carry anything between jobs (spec §2.2 I4).
219
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
220
pub struct Mount {
221
    pub source: MountSource,
222
    /// An absolute path under `/src` or `/tmp`.
223
    pub target: String,
224
    pub size_mib: u32,
225
}
226
227
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
228
#[serde(rename_all = "lowercase")]
229
pub enum MountSource {
230
    /// `ephemeral://tmpfs`: memory in the sandbox, counted against its memory-limit.
231
    Tmpfs,
232
    /// `ephemeral://disk`: a fresh sparse disk on the runner, for large build trees.
233
    Disk,
234
}
235
236
impl MountSource {
237
    pub fn as_str(self) -> &'static str {
238
        match self {
239
            MountSource::Tmpfs => "ephemeral://tmpfs",
240
            MountSource::Disk => "ephemeral://disk",
241
        }
242
    }
243
}
244
245
/// A file a job produces (spec §5.2): read from the job's disks when it
246
/// ends, kept in the repository as a blob, and named by its receipt.
247
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
248
pub struct Output {
249
    /// The artifact's name, unique in the job.
250
    pub name: String,
251
    /// A clean absolute path under `/src`, not on a tmpfs mount.
252
    pub path: String,
253
}
254
255
/// A secret the job reads as a file (spec §9): never in the environment.
256
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
257
pub struct Secret {
258
    /// Which secret, as the hub names it; unique in the job.
259
    pub name: String,
260
    /// A clean absolute path under `/run/secrets/`, unique in the job;
261
    /// the file is 0400, the job's user's, on an in-memory mount.
262
    pub mount_path: String,
263
}
264
265
/// An OIDC token the hub mints for the job (oxygenhub#103), naming the
266
/// repository, bookmark, run and job, for `audience`; read as a file.
267
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
268
pub struct Identity {
269
    /// Who the token is for (`sts.amazonaws.com`, a Vault role's audience, …).
270
    pub audience: String,
271
    /// A clean absolute path under `/run/secrets/`, unique among the
272
    /// job's secrets and identities.
273
    pub mount_path: String,
274
}
275
276
/// A secret's mount path: under `/run/secrets/`, no `.` or `..`.
277
pub fn secret_path_ok(p: &str) -> bool {
278
    p.strip_prefix("/run/secrets/").is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
279
}
280
281
/// Artifacts a job may declare.
282
pub const MAX_OUTPUTS: usize = 16;
283
284
/// Whether `p` is a clean absolute path strictly under `/src`, with nothing
285
/// a debugfs command line would have to quote.
286
fn output_path_ok(p: &str) -> bool {
287
    p.strip_prefix("/src/").is_some_and(|rest| {
288
        !rest.is_empty()
289
            && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
290
            && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
291
    })
292
}
293
294
/// Mounts a job may declare.
295
pub const MAX_MOUNTS: usize = 4;
296
/// The most secrets a job may mount.
297
pub const MAX_SECRETS: usize = 8;
298
/// The most identities a job may ask for.
299
pub const MAX_IDENTITIES: usize = 4;
300
/// Sizes a mount may have: 16 MiB to 64 GiB.
301
pub const MIN_MOUNT_MIB: u64 = 16;
302
pub const MAX_MOUNT_MIB: u64 = 64 * 1024;
303
304
/// Whether `t` is a clean absolute path strictly under `/src` or `/tmp`.
305
fn mount_target_ok(t: &str) -> bool {
306
    let under = t.strip_prefix("/src/").or_else(|| t.strip_prefix("/tmp/"));
307
    under.is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
308
}
309
310
/// One problem in a pipeline, at a 1-based line.
311
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
312
pub struct Problem {
313
    pub line: usize,
314
    pub message: String,
315
}
316
317
impl fmt::Display for Problem {
318
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
319
        write!(f, "line {}: {}", self.line, self.message)
320
    }
321
}
322
323
/// Every problem found, in line order.
324
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
325
pub struct Problems(pub Vec<Problem>);
326
327
impl fmt::Display for Problems {
328
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329
        let lines: Vec<String> = self.0.iter().map(|p| p.to_string()).collect();
330
        f.write_str(&lines.join("\n"))
331
    }
332
}
333
334
const MIN_MEMORY_MIB: u64 = 64;
335
const MAX_MEMORY_MIB: u64 = 16 * 1024;
336
const MAX_CPUS: i128 = 16;
337
338
/// Nodes of the full spec that M0 recognises and refuses.
339
const LATER_TOP: &[&str] = &["publish", "secret"];
340
const LATER_JOB: &[&str] = &["attest"];
341
342
struct Ctx<'a> {
343
    src: &'a str,
344
    problems: Vec<Problem>,
345
}
346
347
impl Ctx<'_> {
348
    fn line(&self, span: SourceSpan) -> usize {
349
        let at = span.offset().min(self.src.len());
350
        self.src[..at].bytes().filter(|b| *b == b'\n').count() + 1
351
    }
352
353
    fn err(&mut self, span: SourceSpan, message: impl Into<String>) {
354
        let line = self.line(span);
355
        self.problems.push(Problem { line, message: message.into() });
356
    }
357
358
    /// The node's arguments as strings; properties and other types are errors.
359
    fn strings(&mut self, node: &KdlNode) -> Vec<String> {
360
        let mut out = Vec::new();
361
        for e in node.entries() {
362
            if let Some(k) = e.name() {
363
                self.err(e.span(), format!("`{}` takes no property `{}`", node.name().value(), k.value()));
364
                continue;
365
            }
366
            match e.value() {
367
                KdlValue::String(s) => out.push(s.clone()),
368
                other => self.err(e.span(), format!("`{}` takes strings, not {}", node.name().value(), kind_of(other))),
369
            }
370
        }
371
        out
372
    }
373
374
    /// Exactly one string argument.
375
    fn one_string(&mut self, node: &KdlNode) -> Option<String> {
376
        let v = self.strings(node);
377
        if v.len() != 1 {
378
            self.err(node.span(), format!("`{}` takes one string", node.name().value()));
379
            return None;
380
        }
381
        v.into_iter().next()
382
    }
383
384
    fn one_integer(&mut self, node: &KdlNode) -> Option<i128> {
385
        match node.entries() {
386
            [e] if e.name().is_none() => match e.value() {
387
                KdlValue::Integer(n) => Some(*n),
388
                other => {
389
                    self.err(e.span(), format!("`{}` takes an integer, not {}", node.name().value(), kind_of(other)));
390
                    None
391
                }
392
            },
393
            _ => {
394
                self.err(node.span(), format!("`{}` takes one integer", node.name().value()));
395
                None
396
            }
397
        }
398
    }
399
400
    fn no_children(&mut self, node: &KdlNode) {
401
        if node.children().is_some() {
402
            self.err(node.span(), format!("`{}` takes no block", node.name().value()));
403
        }
404
    }
405
406
    fn name_ok(&mut self, span: SourceSpan, what: &str, name: &str) -> bool {
407
        let ok = !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
408
        if !ok {
409
            self.err(span, format!("{what} name {name:?} must be 1 to 64 of A-Z a-z 0-9 . _ -"));
410
        }
411
        ok
412
    }
413
}
414
415
use kdl::{KdlDocument, KdlNode, KdlValue};
416
use miette::SourceSpan;
417
418
fn kind_of(v: &KdlValue) -> &'static str {
419
    match v {
420
        KdlValue::String(_) => "a string",
421
        KdlValue::Integer(_) => "an integer",
422
        KdlValue::Float(_) => "a number",
423
        KdlValue::Bool(_) => "a boolean",
424
        KdlValue::Null => "null",
425
    }
426
}
427
428
/// `"512MiB"`, `"4GiB"`, `"65536KiB"` to MiB.
429
fn parse_memory(s: &str) -> Option<u64> {
430
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
431
    let n: u64 = num.parse().ok()?;
432
    let mib = match unit {
433
        "KiB" => n.is_multiple_of(1024).then_some(n / 1024)?,
434
        "MiB" => n,
435
        "GiB" => n.checked_mul(1024)?,
436
        _ => return None,
437
    };
438
    Some(mib)
439
}
440
441
/// The line of the first escape KDL 2.0 does not have but shells and C
442
/// do (`\0NN`, `\e`, `\xNN`), as someone colouring output writes them.
443
fn foreign_escape_line(src: &str) -> Option<usize> {
444
    src.lines()
445
        .position(|l| {
446
            let b = l.as_bytes();
447
            b.windows(2).enumerate().any(|(i, w)| {
448
                w[0] == b'\\'
449
                    && (i == 0 || b[i - 1] != b'\\')
450
                    && (w[1] == b'e' || w[1] == b'x' || (w[1] == b'0' && b.get(i + 2).is_some_and(u8::is_ascii_digit)))
451
            })
452
        })
453
        .map(|i| i + 1)
454
}
455
456
impl Pipeline {
457
    /// Parse and validate a pipeline document.
458
    pub fn parse(src: &str) -> Result<Pipeline, Problems> {
459
        let doc = match KdlDocument::parse_v2(src) {
460
            Ok(d) => d,
461
            Err(e) => {
462
                let mut ctx = Ctx { src, problems: Vec::new() };
463
                for d in &e.diagnostics {
464
                    let msg = d.message.clone().or_else(|| d.label.clone()).unwrap_or_else(|| "not valid KDL 2.0".into());
465
                    ctx.err(d.span, msg);
466
                }
467
                if ctx.problems.is_empty() {
468
                    ctx.problems.push(Problem { line: 1, message: "not valid KDL 2.0".into() });
469
                }
470
                if let Some(line) = foreign_escape_line(src) {
471
                    ctx.problems.push(Problem {
472
                        line,
473
                        message: "KDL strings have no \\033, \\e or \\x escapes: write ESC (for ANSI colours) as \\u{1b}".into(),
474
                    });
475
                }
476
                return Err(Problems(ctx.problems));
477
            }
478
        };
479
        let mut ctx = Ctx { src, problems: Vec::new() };
480
        let mut schema = None;
481
        let mut sandboxes = BTreeMap::new();
482
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
483
        let mut on = None;
484
        for node in doc.nodes() {
485
            match node.name().value() {
486
                "on" => {
487
                    if on.is_some() {
488
                        ctx.err(node.span(), "`on` is given twice");
489
                    }
490
                    on = Some(parse_on(&mut ctx, node));
491
                }
492
                "schema-version" => {
493
                    ctx.no_children(node);
494
                    match ctx.one_integer(node) {
495
                        Some(1) => schema = Some(1),
496
                        Some(n) => ctx.err(node.span(), format!("schema-version {n} is not supported; this runner reads 1")),
497
                        None => {}
498
                    }
499
                }
500
                "sandbox" => {
501
                    if let Some(sb) = parse_sandbox(&mut ctx, node) {
502
                        if sandboxes.contains_key(&sb.name) {
503
                            ctx.err(node.span(), format!("sandbox {:?} is declared twice", sb.name));
504
                        } else {
505
                            sandboxes.insert(sb.name.clone(), sb);
506
                        }
507
                    }
508
                }
509
                "job" => {
510
                    if let Some(job) = parse_job(&mut ctx, node) {
511
                        if jobs.contains_key(&job.name) {
512
                            ctx.err(node.span(), format!("job {:?} is declared twice", job.name));
513
                        } else {
514
                            jobs.insert(job.name.clone(), (job, node.span()));
515
                        }
516
                    }
517
                }
518
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
519
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, sandbox or job")),
520
            }
521
        }
522
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
523
            ctx.problems.push(Problem { line: 1, message: "`schema-version 1` is required".into() });
524
        }
525
        if jobs.is_empty() && ctx.problems.is_empty() {
526
            ctx.problems.push(Problem { line: 1, message: "a pipeline needs at least one job".into() });
527
        }
528
        // References and cycles.
529
        for (job, span) in jobs.values() {
530
            if !job.sandbox.is_empty() && !sandboxes.contains_key(&job.sandbox) {
531
                ctx.err(*span, format!("job {:?} uses sandbox {:?}, which is not declared", job.name, job.sandbox));
532
            }
533
            for d in &job.depends_on {
534
                if !jobs.contains_key(d) {
535
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
536
                } else if d == &job.name {
537
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
538
                }
539
            }
540
        }
541
        let graph: BTreeMap<&str, Vec<&str>> =
542
            jobs.values().map(|(j, _)| (j.name.as_str(), j.depends_on.iter().map(String::as_str).collect())).collect();
543
        if let Some(cycle) = find_cycle(&graph) {
544
            let span = jobs[cycle[0]].1;
545
            ctx.err(span, format!("jobs depend on each other in a cycle: {}", cycle.join(" → ")));
546
        }
547
        if !ctx.problems.is_empty() {
548
            ctx.problems.sort_by_key(|p| p.line);
549
            return Err(Problems(ctx.problems));
550
        }
551
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect(), on })
552
    }
553
554
    /// Jobs in an order where each comes after what it depends on (ties by name).
555
    pub fn order(&self) -> Vec<&Job> {
556
        let mut done: BTreeSet<&str> = BTreeSet::new();
557
        let mut out = Vec::with_capacity(self.jobs.len());
558
        while out.len() < self.jobs.len() {
559
            let before = out.len();
560
            for j in self.jobs.values() {
561
                if !done.contains(j.name.as_str()) && j.depends_on.iter().all(|d| done.contains(d.as_str())) {
562
                    out.push(j);
563
                }
564
            }
565
            for j in &out[before..] {
566
                done.insert(j.name.as_str());
567
            }
568
            if out.len() == before {
569
                break; // validated pipelines have no cycles
570
            }
571
        }
572
        out
573
    }
574
575
    /// Each job's depth: 0 without dependencies, else one more than its deepest dependency.
576
    pub fn depths(&self) -> BTreeMap<&str, usize> {
577
        let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
578
        for j in self.order() {
579
            let d = j.depends_on.iter().map(|x| depth.get(x.as_str()).copied().unwrap_or(0) + 1).max().unwrap_or(0);
580
            depth.insert(j.name.as_str(), d);
581
        }
582
        depth
583
    }
584
}
585
586
fn find_cycle<'a>(graph: &BTreeMap<&'a str, Vec<&'a str>>) -> Option<Vec<&'a str>> {
587
    #[derive(Clone, Copy, PartialEq)]
588
    enum Mark {
589
        Visiting,
590
        Done,
591
    }
592
    fn visit<'a>(
593
        n: &'a str,
594
        graph: &BTreeMap<&'a str, Vec<&'a str>>,
595
        marks: &mut BTreeMap<&'a str, Mark>,
596
        stack: &mut Vec<&'a str>,
597
    ) -> Option<Vec<&'a str>> {
598
        match marks.get(n) {
599
            Some(Mark::Done) => return None,
600
            Some(Mark::Visiting) => {
601
                let at = stack.iter().position(|s| *s == n).unwrap_or(0);
602
                let mut c = stack[at..].to_vec();
603
                c.push(n);
604
                return Some(c);
605
            }
606
            None => {}
607
        }
608
        marks.insert(n, Mark::Visiting);
609
        stack.push(n);
610
        for d in graph.get(n).into_iter().flatten() {
611
            if graph.contains_key(d) && *d != n {
612
                if let Some(c) = visit(d, graph, marks, stack) {
613
                    return Some(c);
614
                }
615
            }
616
        }
617
        stack.pop();
618
        marks.insert(n, Mark::Done);
619
        None
620
    }
621
    let mut marks = BTreeMap::new();
622
    for n in graph.keys() {
623
        let mut stack = Vec::new();
624
        if let Some(c) = visit(n, graph, &mut marks, &mut stack) {
625
            return Some(c);
626
        }
627
    }
628
    None
629
}
630
631
fn block<'a>(ctx: &mut Ctx, node: &'a KdlNode) -> &'a [KdlNode] {
632
    match node.children() {
633
        Some(d) => d.nodes(),
634
        None => {
635
            ctx.err(node.span(), format!("`{}` needs a block {{ … }}", node.name().value()));
636
            &[]
637
        }
638
    }
639
}
640
641
fn parse_sandbox(ctx: &mut Ctx, node: &KdlNode) -> Option<Sandbox> {
642
    let name = ctx.one_string(node)?;
643
    let named = ctx.name_ok(node.span(), "sandbox", &name);
644
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
645
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
646
    let mut seen = BTreeSet::new();
647
    let mut env = BTreeMap::new();
648
    let mut runs_on = Vec::new();
649
    let mut timeout_s = None;
650
    let mut arch: Option<String> = None;
651
    for c in block(ctx, node) {
652
        let key = c.name().value();
653
        if key != "runs-on" && !seen.insert(key.to_string()) {
654
            ctx.err(c.span(), format!("`{key}` is given twice"));
655
        }
656
        if key == "env" {
657
            parse_env(ctx, c, &mut env);
658
            continue;
659
        }
660
        if key == "runs-on" {
661
            ctx.no_children(c);
662
            parse_labels(ctx, c, &mut runs_on);
663
            continue;
664
        }
665
        ctx.no_children(c);
666
        match key {
667
            "runtime" => {
668
                runtime = match ctx.one_string(c).as_deref() {
669
                    Some("microvm") => Some(Runtime::Microvm),
670
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
671
                    Some("wasm") => {
672
                        ctx.err(c.span(), "runtime \"wasm\" is not in M0 yet; use \"microvm\" or \"syscall\"");
673
                        None
674
                    }
675
                    Some(other) => {
676
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
677
                        None
678
                    }
679
                    None => None,
680
                }
681
            }
682
            "rootfs" => {
683
                rootfs = match ctx.one_string(c) {
684
                    Some(r) => match r.strip_prefix("cas://blob/") {
685
                        Some(h) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) => {
686
                            Some(h.to_string())
687
                        }
688
                        _ => {
689
                            ctx.err(c.span(), format!("rootfs {r:?}: expected \"cas://blob/\" and 64 lower-case hex digits"));
690
                            None
691
                        }
692
                    },
693
                    None => None,
694
                }
695
            }
696
            "arch" => {
697
                arch = match ctx.one_string(c) {
698
                    Some(a) if arch_ok(&a) => Some(a),
699
                    Some(a) => {
700
                        ctx.err(c.span(), format!("arch {a:?} is not an architecture name such as x86_64, aarch64 or riscv64"));
701
                        None
702
                    }
703
                    None => None,
704
                }
705
            }
706
            "memory-limit" => {
707
                memory = match ctx.one_string(c) {
708
                    Some(m) => match parse_memory(&m) {
709
                        Some(mib) if (MIN_MEMORY_MIB..=MAX_MEMORY_MIB).contains(&mib) => Some(mib as u32),
710
                        Some(_) => {
711
                            ctx.err(c.span(), format!("memory-limit {m:?} must be from 64MiB to 16GiB"));
712
                            None
713
                        }
714
                        None => {
715
                            ctx.err(c.span(), format!("memory-limit {m:?}: expected a whole number of KiB, MiB or GiB, as \"4GiB\""));
716
                            None
717
                        }
718
                    },
719
                    None => None,
720
                }
721
            }
722
            "cpu-limit" => {
723
                cpus = match ctx.one_integer(c) {
724
                    Some(n) if (1..=MAX_CPUS).contains(&n) => Some(n as u32),
725
                    Some(n) => {
726
                        ctx.err(c.span(), format!("cpu-limit {n} must be from 1 to {MAX_CPUS}"));
727
                        None
728
                    }
729
                    None => None,
730
                }
731
            }
732
            "network" => {
733
                network = match ctx.one_string(c).as_deref() {
734
                    Some("isolated") => Some(Network::Isolated),
735
                    // Loopback only: the guest's `lo` comes up, and it still has
736
                    // no device that reaches anything outside it.
737
                    Some("loopback") => Some(Network::Loopback),
738
                    Some("egress") => {
739
                        ctx.err(c.span(), "network \"egress\" is not in M0; M0 has \"isolated\" and \"loopback\"");
740
                        None
741
                    }
742
                    Some(other) => {
743
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
744
                        None
745
                    }
746
                    None => None,
747
                }
748
            }
749
            "timeout" => timeout_s = parse_timeout(ctx, c),
750
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
751
        }
752
    }
753
    if rootfs.is_none() && !seen.contains("rootfs") {
754
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
755
    }
756
    if !named {
757
        return None;
758
    }
759
    Some(Sandbox {
760
        name,
761
        runtime: runtime?,
762
        rootfs: rootfs?,
763
        memory_mib: memory?,
764
        cpus: cpus?,
765
        network: network?,
766
        env,
767
        runs_on,
768
        timeout_s,
769
        arch,
770
    })
771
}
772
773
/// An `on` block: `push` and `tag` with globs (none: any), `manual`,
774
/// `schedule` with five-field cron lines.
775
fn parse_on(ctx: &mut Ctx, node: &KdlNode) -> On {
776
    let mut on = On::default();
777
    if !node.entries().is_empty() {
778
        ctx.err(node.span(), "`on` takes a block: on { push \"main\"; tag \"v*\"; manual }");
779
    }
780
    let globs = |ctx: &mut Ctx, c: &KdlNode| -> Vec<String> {
781
        let g = ctx.strings(c);
782
        for p in &g {
783
            if let Err(e) = globset::Glob::new(p) {
784
                ctx.err(c.span(), format!("{p:?}: {e}"));
785
            }
786
        }
787
        g
788
    };
789
    for c in block(ctx, node) {
790
        ctx.no_children(c);
791
        match c.name().value() {
792
            "push" => on.push.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
793
            "tag" => on.tag.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
794
            "manual" => on.manual = true,
795
            "schedule" => {
796
                for line in ctx.strings(c) {
797
                    if crate::cron::Cron::parse(&line).is_some() {
798
                        on.schedule.push(line);
799
                    } else {
800
                        ctx.err(
801
                            c.span(),
802
                            format!("schedule {line:?}: expected five cron fields (minute hour day month weekday), as \"0 3 * * *\""),
803
                        );
804
                    }
805
                }
806
            }
807
            other => ctx.err(c.span(), format!("unknown `on` event `{other}`; expected push, tag, manual or schedule")),
808
        }
809
    }
810
    if on.push.is_none() && on.tag.is_none() && !on.manual && on.schedule.is_empty() {
811
        ctx.err(node.span(), "`on` names no event, so nothing would run it");
812
    }
813
    on
814
}
815
816
/// The longest a pipeline may ask a job to run.
817
pub const MAX_TIMEOUT_S: u64 = 24 * 3600;
818
819
/// A duration: whole hours, minutes and seconds in that order, each
820
/// optional, as "90m", "2h", "1h30m" or "45s".
821
pub fn parse_duration(s: &str) -> Option<u64> {
822
    let (mut total, mut num, mut last) = (0u64, String::new(), 0u8);
823
    for ch in s.chars() {
824
        if ch.is_ascii_digit() {
825
            num.push(ch);
826
            continue;
827
        }
828
        let (unit, mult) = match ch {
829
            'h' => (1, 3600),
830
            'm' => (2, 60),
831
            's' => (3, 1),
832
            _ => return None,
833
        };
834
        // Each unit once, largest first, each with its number.
835
        if num.is_empty() || unit <= last {
836
            return None;
837
        }
838
        last = unit;
839
        total = total.checked_add(num.parse::<u64>().ok()?.checked_mul(mult)?)?;
840
        num.clear();
841
    }
842
    (num.is_empty() && total > 0).then_some(total)
843
}
844
845
/// A `timeout` line: one duration, from a minute to a day.
846
fn parse_timeout(ctx: &mut Ctx, c: &KdlNode) -> Option<u64> {
847
    let t = ctx.one_string(c)?;
848
    match parse_duration(&t) {
849
        Some(secs) if (60..=MAX_TIMEOUT_S).contains(&secs) => Some(secs),
850
        Some(_) => {
851
            ctx.err(c.span(), format!("timeout {t:?} must be from 1m to 24h"));
852
            None
853
        }
854
        None => {
855
            ctx.err(c.span(), format!("timeout {t:?}: expected hours, minutes and seconds, as \"90m\" or \"1h30m\""));
856
            None
857
        }
858
    }
859
}
860
861
/// The labels of a `runs-on` line: lower-case letters, digits, `-`, `_`
862
/// and `.`, at most 64 bytes each; into `into`, sorted, once each.
863
fn parse_labels(ctx: &mut Ctx, c: &KdlNode, into: &mut Vec<String>) {
864
    let labels = ctx.strings(c);
865
    if labels.is_empty() {
866
        ctx.err(c.span(), "`runs-on` needs one or more labels, as runs-on \"riscv64\"");
867
    }
868
    for l in labels {
869
        let ok = !l.is_empty()
870
            && l.len() <= 64
871
            && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.'));
872
        if ok {
873
            into.push(l);
874
        } else {
875
            ctx.err(c.span(), format!("runs-on {l:?}: a label is lower-case letters, digits, -, _ and ., at most 64"));
876
        }
877
    }
878
    into.sort();
879
    into.dedup();
880
}
881
882
/// An `env` block of `NAME "value"` lines, into `env`.
883
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
884
    if !c.entries().is_empty() {
885
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
886
    }
887
    for e in block(ctx, c) {
888
        let k = e.name().value().to_string();
889
        let valid =
890
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
891
        if !valid {
892
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
893
            continue;
894
        }
895
        ctx.no_children(e);
896
        if let Some(v) = ctx.one_string(e) {
897
            if env.insert(k.clone(), v).is_some() {
898
                ctx.err(e.span(), format!("environment {k} is set twice"));
899
            }
900
        }
901
    }
902
}
903
904
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
905
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
906
    let (mut source, mut target, mut size) = (None, None, None);
907
    let mut seen = BTreeSet::new();
908
    for c in block(ctx, node) {
909
        let key = c.name().value();
910
        if !seen.insert(key.to_string()) {
911
            ctx.err(c.span(), format!("`{key}` is given twice"));
912
            continue;
913
        }
914
        ctx.no_children(c);
915
        match key {
916
            "source" => {
917
                source = match ctx.one_string(c).as_deref() {
918
                    Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs),
919
                    Some("ephemeral://disk") => Some(MountSource::Disk),
920
                    Some(other) => {
921
                        ctx.err(
922
                            c.span(),
923
                            format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""),
924
                        );
925
                        None
926
                    }
927
                    None => None,
928
                }
929
            }
930
            "target" => {
931
                target = match ctx.one_string(c) {
932
                    Some(t) if mount_target_ok(&t) => Some(t),
933
                    Some(t) => {
934
                        ctx.err(
935
                            c.span(),
936
                            format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"),
937
                        );
938
                        None
939
                    }
940
                    None => None,
941
                }
942
            }
943
            "size" => {
944
                size = match ctx.one_string(c) {
945
                    Some(m) => match parse_memory(&m) {
946
                        Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32),
947
                        Some(_) => {
948
                            ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB"));
949
                            None
950
                        }
951
                        None => {
952
                            ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\""));
953
                            None
954
                        }
955
                    },
956
                    None => None,
957
                }
958
            }
959
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
960
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
961
        }
962
    }
963
    for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] {
964
        if missing && !seen.contains(name) {
965
            ctx.err(node.span(), format!("a mount needs a {name}"));
966
        }
967
    }
968
    Some(Mount { source: source?, target: target?, size_mib: size? })
969
}
970
971
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
972
/// `secret "deploy-token" { mount-path "/run/secrets/token" }` (spec §9.1).
973
/// The hub is the vault: the secret of that name set for the repository,
974
/// its organisation or the site. `vault-ref` is not in this slice.
975
fn parse_secret(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
976
    let Some(name) = ctx.one_string(node) else {
977
        ctx.err(node.span(), "`secret` takes a name and a block with mount-path");
978
        return;
979
    };
980
    if !ctx.name_ok(node.span(), "secret", &name) {
981
        return;
982
    }
983
    let mut path = None;
984
    for c in block(ctx, node) {
985
        ctx.no_children(c);
986
        match c.name().value() {
987
            "mount-path" => match ctx.one_string(c) {
988
                Some(p) if secret_path_ok(&p) => path = Some(p),
989
                Some(p) => {
990
                    ctx.err(c.span(), format!("secret mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
991
                }
992
                None => {}
993
            },
994
            "vault-ref" => ctx.err(c.span(), "`vault-ref` is not in M0; the hub supplies the secret of that name"),
995
            other => ctx.err(c.span(), format!("unknown secret setting `{other}`")),
996
        }
997
    }
998
    let Some(mount_path) = path else {
999
        ctx.err(node.span(), format!("secret {name:?} needs a mount-path"));
1000
        return;
1001
    };
1002
    if job.secrets.iter().any(|s| s.name == name) {
1003
        ctx.err(node.span(), format!("secret {name:?} is given twice"));
1004
    } else if job.secrets.iter().any(|s| s.mount_path == mount_path) || job.identities.iter().any(|i| i.mount_path == mount_path) {
1005
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
1006
    } else if job.secrets.len() == MAX_SECRETS {
1007
        ctx.err(node.span(), format!("a job has at most {MAX_SECRETS} secrets"));
1008
    } else {
1009
        job.secrets.push(Secret { name, mount_path });
1010
    }
1011
}
1012
1013
/// `identity "sts.amazonaws.com" { mount-path "/run/secrets/aws-token" }`:
1014
/// the hub mints an OIDC token for that audience and the runner mounts it
1015
/// as a file (oxygenhub#103).
1016
fn parse_identity(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1017
    let Some(audience) = ctx.one_string(node) else {
1018
        ctx.err(node.span(), "`identity` takes an audience and a block with mount-path");
1019
        return;
1020
    };
1021
    let aud_ok = !audience.is_empty() && audience.len() <= 255 && !audience.chars().any(|c| c.is_whitespace() || c.is_control());
1022
    if !aud_ok {
1023
        ctx.err(node.span(), format!("identity audience {audience:?} must be 1 to 255 characters without spaces"));
1024
        return;
1025
    }
1026
    let mut path = None;
1027
    for c in block(ctx, node) {
1028
        ctx.no_children(c);
1029
        match c.name().value() {
1030
            "mount-path" => match ctx.one_string(c) {
1031
                Some(p) if secret_path_ok(&p) => path = Some(p),
1032
                Some(p) => {
1033
                    ctx.err(c.span(), format!("identity mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
1034
                }
1035
                None => {}
1036
            },
1037
            other => ctx.err(c.span(), format!("unknown identity setting `{other}`")),
1038
        }
1039
    }
1040
    let Some(mount_path) = path else {
1041
        ctx.err(node.span(), format!("identity {audience:?} needs a mount-path"));
1042
        return;
1043
    };
1044
    if job.identities.iter().any(|i| i.audience == audience) {
1045
        ctx.err(node.span(), format!("identity {audience:?} is given twice"));
1046
    } else if job.identities.iter().any(|i| i.mount_path == mount_path) || job.secrets.iter().any(|s| s.mount_path == mount_path) {
1047
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
1048
    } else if job.identities.len() == MAX_IDENTITIES {
1049
        ctx.err(node.span(), format!("a job has at most {MAX_IDENTITIES} identities"));
1050
    } else {
1051
        job.identities.push(Identity { audience, mount_path });
1052
    }
1053
}
1054
1055
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1056
    if !node.entries().is_empty() {
1057
        ctx.err(node.span(), "`output` takes a block of artifacts");
1058
    }
1059
    for a in block(ctx, node) {
1060
        if a.name().value() != "artifact" {
1061
            ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value()));
1062
            continue;
1063
        }
1064
        let Some(name) = ctx.one_string(a) else { continue };
1065
        if !ctx.name_ok(a.span(), "artifact", &name) {
1066
            continue;
1067
        }
1068
        let mut path = None;
1069
        for c in block(ctx, a) {
1070
            ctx.no_children(c);
1071
            match c.name().value() {
1072
                "path" => match ctx.one_string(c) {
1073
                    Some(p) if output_path_ok(&p) => path = Some(p),
1074
                    Some(p) => ctx.err(
1075
                        c.span(),
1076
                        format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"),
1077
                    ),
1078
                    None => {}
1079
                },
1080
                "format" => {
1081
                    if ctx.one_string(c).as_deref() != Some("blob") {
1082
                        ctx.err(c.span(), "artifact format is \"blob\"");
1083
                    }
1084
                }
1085
                other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")),
1086
            }
1087
        }
1088
        let Some(path) = path else {
1089
            ctx.err(a.span(), format!("artifact {name:?} needs a path"));
1090
            continue;
1091
        };
1092
        if job.outputs.iter().any(|o| o.name == name) {
1093
            ctx.err(a.span(), format!("artifact {name:?} is given twice"));
1094
        } else if job.outputs.len() == MAX_OUTPUTS {
1095
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts"));
1096
        } else {
1097
            job.outputs.push(Output { name, path });
1098
        }
1099
    }
1100
}
1101
1102
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
1103
    let name = ctx.one_string(node)?;
1104
    let named = ctx.name_ok(node.span(), "job", &name);
1105
    let mut job = Job {
1106
        name: name.clone(),
1107
        sandbox: String::new(),
1108
        scope: vec![],
1109
        depends_on: vec![],
1110
        env: BTreeMap::new(),
1111
        exec: vec![],
1112
        mounts: vec![],
1113
        secrets: vec![],
1114
        identities: vec![],
1115
        outputs: vec![],
1116
        runs_on: vec![],
1117
        timeout_s: None,
1118
        on: None,
1119
    };
1120
    let mut seen = BTreeSet::new();
1121
    let mut outputs_at = None;
1122
    for c in block(ctx, node) {
1123
        let key = c.name().value();
1124
        if key != "scope"
1125
            && key != "depends-on"
1126
            && key != "mount"
1127
            && key != "secret"
1128
            && key != "identity"
1129
            && key != "runs-on"
1130
            && !seen.insert(key.to_string())
1131
        {
1132
            ctx.err(c.span(), format!("`{key}` is given twice"));
1133
        }
1134
        match key {
1135
            "sandbox" => {
1136
                ctx.no_children(c);
1137
                job.sandbox = ctx.one_string(c).unwrap_or_default();
1138
            }
1139
            "scope" => {
1140
                ctx.no_children(c);
1141
                for g in ctx.strings(c) {
1142
                    match globset::Glob::new(&g) {
1143
                        Ok(_) => job.scope.push(g),
1144
                        Err(e) => ctx.err(c.span(), format!("scope {g:?}: {e}")),
1145
                    }
1146
                }
1147
            }
1148
            "depends-on" => {
1149
                ctx.no_children(c);
1150
                job.depends_on.extend(ctx.strings(c));
1151
            }
1152
            "runs-on" => {
1153
                ctx.no_children(c);
1154
                parse_labels(ctx, c, &mut job.runs_on);
1155
            }
1156
            "timeout" => {
1157
                ctx.no_children(c);
1158
                job.timeout_s = parse_timeout(ctx, c);
1159
            }
1160
            "on" => job.on = Some(parse_on(ctx, c)),
1161
            "env" => parse_env(ctx, c, &mut job.env),
1162
            "exec" => {
1163
                ctx.no_children(c);
1164
                job.exec = ctx.strings(c);
1165
                if job.exec.is_empty() {
1166
                    ctx.err(c.span(), "`exec` needs a program and its arguments");
1167
                } else if job.exec.iter().any(|a| a.contains('\0')) {
1168
                    ctx.err(c.span(), "`exec` arguments cannot contain NUL");
1169
                }
1170
            }
1171
            "mount" => {
1172
                if !c.entries().is_empty() {
1173
                    ctx.err(c.span(), "`mount` takes a block of source, target and size");
1174
                }
1175
                if let Some(m) = parse_mount(ctx, c) {
1176
                    if job.mounts.len() == MAX_MOUNTS {
1177
                        ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts"));
1178
                    } else if let Some(other) = job.mounts.iter().find(|o| {
1179
                        o.target == m.target
1180
                            || m.target.starts_with(&format!("{}/", o.target))
1181
                            || o.target.starts_with(&format!("{}/", m.target))
1182
                    }) {
1183
                        ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target));
1184
                    } else {
1185
                        job.mounts.push(m);
1186
                    }
1187
                }
1188
            }
1189
            "output" => {
1190
                outputs_at = Some(c.span());
1191
                parse_output(ctx, c, &mut job);
1192
            }
1193
            "secret" => parse_secret(ctx, c, &mut job),
1194
            "identity" => parse_identity(ctx, c, &mut job),
1195
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
1196
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
1197
        }
1198
    }
1199
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1200
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1201
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
1202
    job.identities.sort_by(|a, b| a.audience.cmp(&b.audience));
1203
    // A tmpfs is gone when the job ends; nothing on it can be kept.
1204
    for o in &job.outputs {
1205
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
1206
            ctx.err(
1207
                outputs_at.unwrap_or(node.span()),
1208
                format!(
1209
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
1210
                    o.name, m.target
1211
                ),
1212
            );
1213
        }
1214
    }
1215
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
1216
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
1217
    }
1218
    if job.exec.is_empty() && !seen.contains("exec") {
1219
        ctx.err(node.span(), format!("job {name:?} needs an exec"));
1220
    }
1221
    job.depends_on.sort();
1222
    job.depends_on.dedup();
1223
    // Kept even when incomplete, so its references are checked too; the
1224
    // problems above already make the pipeline invalid.
1225
    named.then_some(job)
1226
}