OXYGENHUB
# b647ab72d51e oxygenflow / crates / flow-core / src / pipeline.rs
31.7 KiB · Rust · last changed flow check points at \u{1b} for ANSI colours 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
}
20
21
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22
#[serde(rename_all = "lowercase")]
23
pub enum Runtime {
24
    /// Tier 0 (spec §4.1); refused in M0.
25
    Wasm,
26
    /// Tier 1 (spec §4.2): namespaces, Landlock, seccomp, cgroups.
27
    Syscall,
28
    /// Tier 2 (spec §4.3), the default.
29
    Microvm,
30
}
31
32
impl Runtime {
33
    pub fn as_str(self) -> &'static str {
34
        match self {
35
            Runtime::Wasm => "wasm",
36
            Runtime::Syscall => "syscall",
37
            Runtime::Microvm => "microvm",
38
        }
39
    }
40
}
41
42
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
43
#[serde(rename_all = "lowercase")]
44
pub enum Network {
45
    Isolated,
46
    Loopback,
47
    Egress,
48
}
49
50
impl Network {
51
    pub fn as_str(self) -> &'static str {
52
        match self {
53
            Network::Isolated => "isolated",
54
            Network::Loopback => "loopback",
55
            Network::Egress => "egress",
56
        }
57
    }
58
}
59
60
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61
pub struct Sandbox {
62
    pub name: String,
63
    pub runtime: Runtime,
64
    /// BLAKE3 of the rootfs image, hex.
65
    pub rootfs: String,
66
    pub memory_mib: u32,
67
    pub cpus: u32,
68
    pub network: Network,
69
    /// Environment every job in this sandbox starts from; a job's own `env`
70
    /// overrides it.
71
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72
    pub env: BTreeMap<String, String>,
73
}
74
75
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76
pub struct Job {
77
    pub name: String,
78
    pub sandbox: String,
79
    /// Glob patterns over repository paths (spec §6.2).
80
    pub scope: Vec<String>,
81
    /// Sorted.
82
    pub depends_on: Vec<String>,
83
    pub env: BTreeMap<String, String>,
84
    /// Executed as is: no shell, no expansion (spec §2.2 I2).
85
    pub exec: Vec<String>,
86
    /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`).
87
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88
    pub mounts: Vec<Mount>,
89
    /// Files the job produces, sorted by name (spec §5.2 `output`).
90
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91
    pub outputs: Vec<Output>,
92
}
93
94
impl Job {
95
    /// The job's environment on top of its sandbox's: what it runs with.
96
    pub fn in_sandbox(&self, sandbox: &Sandbox) -> Job {
97
        let mut job = self.clone();
98
        for (k, v) in &sandbox.env {
99
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
100
        }
101
        job
102
    }
103
104
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
105
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
106
    /// sets itself keeps the pipeline's value.
107
    pub fn with_flow_env(&self, vars: &[(String, String)]) -> Job {
108
        let mut job = self.clone();
109
        for (k, v) in vars {
110
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
111
        }
112
        job
113
    }
114
}
115
116
/// An ephemeral scratch mount: empty at the start of every job and gone
117
/// after it, so it cannot carry anything between jobs (spec §2.2 I4).
118
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119
pub struct Mount {
120
    pub source: MountSource,
121
    /// An absolute path under `/src` or `/tmp`.
122
    pub target: String,
123
    pub size_mib: u32,
124
}
125
126
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
127
#[serde(rename_all = "lowercase")]
128
pub enum MountSource {
129
    /// `ephemeral://tmpfs`: memory in the sandbox, counted against its memory-limit.
130
    Tmpfs,
131
    /// `ephemeral://disk`: a fresh sparse disk on the runner, for large build trees.
132
    Disk,
133
}
134
135
impl MountSource {
136
    pub fn as_str(self) -> &'static str {
137
        match self {
138
            MountSource::Tmpfs => "ephemeral://tmpfs",
139
            MountSource::Disk => "ephemeral://disk",
140
        }
141
    }
142
}
143
144
/// A file a job produces (spec §5.2): read from the job's disks when it
145
/// ends, kept in the repository as a blob, and named by its receipt.
146
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147
pub struct Output {
148
    /// The artifact's name, unique in the job.
149
    pub name: String,
150
    /// A clean absolute path under `/src`, not on a tmpfs mount.
151
    pub path: String,
152
}
153
154
/// Artifacts a job may declare.
155
pub const MAX_OUTPUTS: usize = 16;
156
157
/// Whether `p` is a clean absolute path strictly under `/src`, with nothing
158
/// a debugfs command line would have to quote.
159
fn output_path_ok(p: &str) -> bool {
160
    p.strip_prefix("/src/").is_some_and(|rest| {
161
        !rest.is_empty()
162
            && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
163
            && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
164
    })
165
}
166
167
/// Mounts a job may declare.
168
pub const MAX_MOUNTS: usize = 4;
169
/// Sizes a mount may have: 16 MiB to 64 GiB.
170
pub const MIN_MOUNT_MIB: u64 = 16;
171
pub const MAX_MOUNT_MIB: u64 = 64 * 1024;
172
173
/// Whether `t` is a clean absolute path strictly under `/src` or `/tmp`.
174
fn mount_target_ok(t: &str) -> bool {
175
    let under = t.strip_prefix("/src/").or_else(|| t.strip_prefix("/tmp/"));
176
    under.is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
177
}
178
179
/// One problem in a pipeline, at a 1-based line.
180
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
181
pub struct Problem {
182
    pub line: usize,
183
    pub message: String,
184
}
185
186
impl fmt::Display for Problem {
187
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188
        write!(f, "line {}: {}", self.line, self.message)
189
    }
190
}
191
192
/// Every problem found, in line order.
193
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
194
pub struct Problems(pub Vec<Problem>);
195
196
impl fmt::Display for Problems {
197
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198
        let lines: Vec<String> = self.0.iter().map(|p| p.to_string()).collect();
199
        f.write_str(&lines.join("\n"))
200
    }
201
}
202
203
const MIN_MEMORY_MIB: u64 = 64;
204
const MAX_MEMORY_MIB: u64 = 16 * 1024;
205
const MAX_CPUS: i128 = 16;
206
207
/// Nodes of the full spec that M0 recognises and refuses.
208
const LATER_TOP: &[&str] = &["publish", "secret"];
209
const LATER_JOB: &[&str] = &["secret", "attest"];
210
211
struct Ctx<'a> {
212
    src: &'a str,
213
    problems: Vec<Problem>,
214
}
215
216
impl Ctx<'_> {
217
    fn line(&self, span: SourceSpan) -> usize {
218
        let at = span.offset().min(self.src.len());
219
        self.src[..at].bytes().filter(|b| *b == b'\n').count() + 1
220
    }
221
222
    fn err(&mut self, span: SourceSpan, message: impl Into<String>) {
223
        let line = self.line(span);
224
        self.problems.push(Problem { line, message: message.into() });
225
    }
226
227
    /// The node's arguments as strings; properties and other types are errors.
228
    fn strings(&mut self, node: &KdlNode) -> Vec<String> {
229
        let mut out = Vec::new();
230
        for e in node.entries() {
231
            if let Some(k) = e.name() {
232
                self.err(e.span(), format!("`{}` takes no property `{}`", node.name().value(), k.value()));
233
                continue;
234
            }
235
            match e.value() {
236
                KdlValue::String(s) => out.push(s.clone()),
237
                other => self.err(e.span(), format!("`{}` takes strings, not {}", node.name().value(), kind_of(other))),
238
            }
239
        }
240
        out
241
    }
242
243
    /// Exactly one string argument.
244
    fn one_string(&mut self, node: &KdlNode) -> Option<String> {
245
        let v = self.strings(node);
246
        if v.len() != 1 {
247
            self.err(node.span(), format!("`{}` takes one string", node.name().value()));
248
            return None;
249
        }
250
        v.into_iter().next()
251
    }
252
253
    fn one_integer(&mut self, node: &KdlNode) -> Option<i128> {
254
        match node.entries() {
255
            [e] if e.name().is_none() => match e.value() {
256
                KdlValue::Integer(n) => Some(*n),
257
                other => {
258
                    self.err(e.span(), format!("`{}` takes an integer, not {}", node.name().value(), kind_of(other)));
259
                    None
260
                }
261
            },
262
            _ => {
263
                self.err(node.span(), format!("`{}` takes one integer", node.name().value()));
264
                None
265
            }
266
        }
267
    }
268
269
    fn no_children(&mut self, node: &KdlNode) {
270
        if node.children().is_some() {
271
            self.err(node.span(), format!("`{}` takes no block", node.name().value()));
272
        }
273
    }
274
275
    fn name_ok(&mut self, span: SourceSpan, what: &str, name: &str) -> bool {
276
        let ok = !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
277
        if !ok {
278
            self.err(span, format!("{what} name {name:?} must be 1 to 64 of A-Z a-z 0-9 . _ -"));
279
        }
280
        ok
281
    }
282
}
283
284
use kdl::{KdlDocument, KdlNode, KdlValue};
285
use miette::SourceSpan;
286
287
fn kind_of(v: &KdlValue) -> &'static str {
288
    match v {
289
        KdlValue::String(_) => "a string",
290
        KdlValue::Integer(_) => "an integer",
291
        KdlValue::Float(_) => "a number",
292
        KdlValue::Bool(_) => "a boolean",
293
        KdlValue::Null => "null",
294
    }
295
}
296
297
/// `"512MiB"`, `"4GiB"`, `"65536KiB"` to MiB.
298
fn parse_memory(s: &str) -> Option<u64> {
299
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
300
    let n: u64 = num.parse().ok()?;
301
    let mib = match unit {
302
        "KiB" => n.is_multiple_of(1024).then_some(n / 1024)?,
303
        "MiB" => n,
304
        "GiB" => n.checked_mul(1024)?,
305
        _ => return None,
306
    };
307
    Some(mib)
308
}
309
310
/// The line of the first escape KDL 2.0 does not have but shells and C
311
/// do (`\0NN`, `\e`, `\xNN`), as someone colouring output writes them.
312
fn foreign_escape_line(src: &str) -> Option<usize> {
313
    src.lines()
314
        .position(|l| {
315
            let b = l.as_bytes();
316
            b.windows(2).enumerate().any(|(i, w)| {
317
                w[0] == b'\\'
318
                    && (i == 0 || b[i - 1] != b'\\')
319
                    && (w[1] == b'e' || w[1] == b'x' || (w[1] == b'0' && b.get(i + 2).is_some_and(u8::is_ascii_digit)))
320
            })
321
        })
322
        .map(|i| i + 1)
323
}
324
325
impl Pipeline {
326
    /// Parse and validate a pipeline document.
327
    pub fn parse(src: &str) -> Result<Pipeline, Problems> {
328
        let doc = match KdlDocument::parse_v2(src) {
329
            Ok(d) => d,
330
            Err(e) => {
331
                let mut ctx = Ctx { src, problems: Vec::new() };
332
                for d in &e.diagnostics {
333
                    let msg = d.message.clone().or_else(|| d.label.clone()).unwrap_or_else(|| "not valid KDL 2.0".into());
334
                    ctx.err(d.span, msg);
335
                }
336
                if ctx.problems.is_empty() {
337
                    ctx.problems.push(Problem { line: 1, message: "not valid KDL 2.0".into() });
338
                }
339
                if let Some(line) = foreign_escape_line(src) {
340
                    ctx.problems.push(Problem {
341
                        line,
342
                        message: "KDL strings have no \\033, \\e or \\x escapes: write ESC (for ANSI colours) as \\u{1b}".into(),
343
                    });
344
                }
345
                return Err(Problems(ctx.problems));
346
            }
347
        };
348
        let mut ctx = Ctx { src, problems: Vec::new() };
349
        let mut schema = None;
350
        let mut sandboxes = BTreeMap::new();
351
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
352
        for node in doc.nodes() {
353
            match node.name().value() {
354
                "schema-version" => {
355
                    ctx.no_children(node);
356
                    match ctx.one_integer(node) {
357
                        Some(1) => schema = Some(1),
358
                        Some(n) => ctx.err(node.span(), format!("schema-version {n} is not supported; this runner reads 1")),
359
                        None => {}
360
                    }
361
                }
362
                "sandbox" => {
363
                    if let Some(sb) = parse_sandbox(&mut ctx, node) {
364
                        if sandboxes.contains_key(&sb.name) {
365
                            ctx.err(node.span(), format!("sandbox {:?} is declared twice", sb.name));
366
                        } else {
367
                            sandboxes.insert(sb.name.clone(), sb);
368
                        }
369
                    }
370
                }
371
                "job" => {
372
                    if let Some(job) = parse_job(&mut ctx, node) {
373
                        if jobs.contains_key(&job.name) {
374
                            ctx.err(node.span(), format!("job {:?} is declared twice", job.name));
375
                        } else {
376
                            jobs.insert(job.name.clone(), (job, node.span()));
377
                        }
378
                    }
379
                }
380
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
381
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, sandbox or job")),
382
            }
383
        }
384
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
385
            ctx.problems.push(Problem { line: 1, message: "`schema-version 1` is required".into() });
386
        }
387
        if jobs.is_empty() && ctx.problems.is_empty() {
388
            ctx.problems.push(Problem { line: 1, message: "a pipeline needs at least one job".into() });
389
        }
390
        // References and cycles.
391
        for (job, span) in jobs.values() {
392
            if !job.sandbox.is_empty() && !sandboxes.contains_key(&job.sandbox) {
393
                ctx.err(*span, format!("job {:?} uses sandbox {:?}, which is not declared", job.name, job.sandbox));
394
            }
395
            for d in &job.depends_on {
396
                if !jobs.contains_key(d) {
397
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
398
                } else if d == &job.name {
399
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
400
                }
401
            }
402
        }
403
        let graph: BTreeMap<&str, Vec<&str>> =
404
            jobs.values().map(|(j, _)| (j.name.as_str(), j.depends_on.iter().map(String::as_str).collect())).collect();
405
        if let Some(cycle) = find_cycle(&graph) {
406
            let span = jobs[cycle[0]].1;
407
            ctx.err(span, format!("jobs depend on each other in a cycle: {}", cycle.join(" → ")));
408
        }
409
        if !ctx.problems.is_empty() {
410
            ctx.problems.sort_by_key(|p| p.line);
411
            return Err(Problems(ctx.problems));
412
        }
413
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect() })
414
    }
415
416
    /// Jobs in an order where each comes after what it depends on (ties by name).
417
    pub fn order(&self) -> Vec<&Job> {
418
        let mut done: BTreeSet<&str> = BTreeSet::new();
419
        let mut out = Vec::with_capacity(self.jobs.len());
420
        while out.len() < self.jobs.len() {
421
            let before = out.len();
422
            for j in self.jobs.values() {
423
                if !done.contains(j.name.as_str()) && j.depends_on.iter().all(|d| done.contains(d.as_str())) {
424
                    out.push(j);
425
                }
426
            }
427
            for j in &out[before..] {
428
                done.insert(j.name.as_str());
429
            }
430
            if out.len() == before {
431
                break; // validated pipelines have no cycles
432
            }
433
        }
434
        out
435
    }
436
437
    /// Each job's depth: 0 without dependencies, else one more than its deepest dependency.
438
    pub fn depths(&self) -> BTreeMap<&str, usize> {
439
        let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
440
        for j in self.order() {
441
            let d = j.depends_on.iter().map(|x| depth.get(x.as_str()).copied().unwrap_or(0) + 1).max().unwrap_or(0);
442
            depth.insert(j.name.as_str(), d);
443
        }
444
        depth
445
    }
446
}
447
448
fn find_cycle<'a>(graph: &BTreeMap<&'a str, Vec<&'a str>>) -> Option<Vec<&'a str>> {
449
    #[derive(Clone, Copy, PartialEq)]
450
    enum Mark {
451
        Visiting,
452
        Done,
453
    }
454
    fn visit<'a>(
455
        n: &'a str,
456
        graph: &BTreeMap<&'a str, Vec<&'a str>>,
457
        marks: &mut BTreeMap<&'a str, Mark>,
458
        stack: &mut Vec<&'a str>,
459
    ) -> Option<Vec<&'a str>> {
460
        match marks.get(n) {
461
            Some(Mark::Done) => return None,
462
            Some(Mark::Visiting) => {
463
                let at = stack.iter().position(|s| *s == n).unwrap_or(0);
464
                let mut c = stack[at..].to_vec();
465
                c.push(n);
466
                return Some(c);
467
            }
468
            None => {}
469
        }
470
        marks.insert(n, Mark::Visiting);
471
        stack.push(n);
472
        for d in graph.get(n).into_iter().flatten() {
473
            if graph.contains_key(d) && *d != n {
474
                if let Some(c) = visit(d, graph, marks, stack) {
475
                    return Some(c);
476
                }
477
            }
478
        }
479
        stack.pop();
480
        marks.insert(n, Mark::Done);
481
        None
482
    }
483
    let mut marks = BTreeMap::new();
484
    for n in graph.keys() {
485
        let mut stack = Vec::new();
486
        if let Some(c) = visit(n, graph, &mut marks, &mut stack) {
487
            return Some(c);
488
        }
489
    }
490
    None
491
}
492
493
fn block<'a>(ctx: &mut Ctx, node: &'a KdlNode) -> &'a [KdlNode] {
494
    match node.children() {
495
        Some(d) => d.nodes(),
496
        None => {
497
            ctx.err(node.span(), format!("`{}` needs a block {{ … }}", node.name().value()));
498
            &[]
499
        }
500
    }
501
}
502
503
fn parse_sandbox(ctx: &mut Ctx, node: &KdlNode) -> Option<Sandbox> {
504
    let name = ctx.one_string(node)?;
505
    let named = ctx.name_ok(node.span(), "sandbox", &name);
506
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
507
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
508
    let mut seen = BTreeSet::new();
509
    let mut env = BTreeMap::new();
510
    for c in block(ctx, node) {
511
        let key = c.name().value();
512
        if !seen.insert(key.to_string()) {
513
            ctx.err(c.span(), format!("`{key}` is given twice"));
514
        }
515
        if key == "env" {
516
            parse_env(ctx, c, &mut env);
517
            continue;
518
        }
519
        ctx.no_children(c);
520
        match key {
521
            "runtime" => {
522
                runtime = match ctx.one_string(c).as_deref() {
523
                    Some("microvm") => Some(Runtime::Microvm),
524
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
525
                    Some("wasm") => {
526
                        ctx.err(c.span(), "runtime \"wasm\" is not in M0 yet; use \"microvm\" or \"syscall\"");
527
                        None
528
                    }
529
                    Some(other) => {
530
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
531
                        None
532
                    }
533
                    None => None,
534
                }
535
            }
536
            "rootfs" => {
537
                rootfs = match ctx.one_string(c) {
538
                    Some(r) => match r.strip_prefix("cas://blob/") {
539
                        Some(h) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) => {
540
                            Some(h.to_string())
541
                        }
542
                        _ => {
543
                            ctx.err(c.span(), format!("rootfs {r:?}: expected \"cas://blob/\" and 64 lower-case hex digits"));
544
                            None
545
                        }
546
                    },
547
                    None => None,
548
                }
549
            }
550
            "memory-limit" => {
551
                memory = match ctx.one_string(c) {
552
                    Some(m) => match parse_memory(&m) {
553
                        Some(mib) if (MIN_MEMORY_MIB..=MAX_MEMORY_MIB).contains(&mib) => Some(mib as u32),
554
                        Some(_) => {
555
                            ctx.err(c.span(), format!("memory-limit {m:?} must be from 64MiB to 16GiB"));
556
                            None
557
                        }
558
                        None => {
559
                            ctx.err(c.span(), format!("memory-limit {m:?}: expected a whole number of KiB, MiB or GiB, as \"4GiB\""));
560
                            None
561
                        }
562
                    },
563
                    None => None,
564
                }
565
            }
566
            "cpu-limit" => {
567
                cpus = match ctx.one_integer(c) {
568
                    Some(n) if (1..=MAX_CPUS).contains(&n) => Some(n as u32),
569
                    Some(n) => {
570
                        ctx.err(c.span(), format!("cpu-limit {n} must be from 1 to {MAX_CPUS}"));
571
                        None
572
                    }
573
                    None => None,
574
                }
575
            }
576
            "network" => {
577
                network = match ctx.one_string(c).as_deref() {
578
                    Some("isolated") => Some(Network::Isolated),
579
                    // Loopback only: the guest's `lo` comes up, and it still has
580
                    // no device that reaches anything outside it.
581
                    Some("loopback") => Some(Network::Loopback),
582
                    Some("egress") => {
583
                        ctx.err(c.span(), "network \"egress\" is not in M0; M0 has \"isolated\" and \"loopback\"");
584
                        None
585
                    }
586
                    Some(other) => {
587
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
588
                        None
589
                    }
590
                    None => None,
591
                }
592
            }
593
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
594
        }
595
    }
596
    if rootfs.is_none() && !seen.contains("rootfs") {
597
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
598
    }
599
    if !named {
600
        return None;
601
    }
602
    Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env })
603
}
604
605
/// An `env` block of `NAME "value"` lines, into `env`.
606
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
607
    if !c.entries().is_empty() {
608
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
609
    }
610
    for e in block(ctx, c) {
611
        let k = e.name().value().to_string();
612
        let valid =
613
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
614
        if !valid {
615
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
616
            continue;
617
        }
618
        ctx.no_children(e);
619
        if let Some(v) = ctx.one_string(e) {
620
            if env.insert(k.clone(), v).is_some() {
621
                ctx.err(e.span(), format!("environment {k} is set twice"));
622
            }
623
        }
624
    }
625
}
626
627
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
628
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
629
    let (mut source, mut target, mut size) = (None, None, None);
630
    let mut seen = BTreeSet::new();
631
    for c in block(ctx, node) {
632
        let key = c.name().value();
633
        if !seen.insert(key.to_string()) {
634
            ctx.err(c.span(), format!("`{key}` is given twice"));
635
            continue;
636
        }
637
        ctx.no_children(c);
638
        match key {
639
            "source" => {
640
                source = match ctx.one_string(c).as_deref() {
641
                    Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs),
642
                    Some("ephemeral://disk") => Some(MountSource::Disk),
643
                    Some(other) => {
644
                        ctx.err(
645
                            c.span(),
646
                            format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""),
647
                        );
648
                        None
649
                    }
650
                    None => None,
651
                }
652
            }
653
            "target" => {
654
                target = match ctx.one_string(c) {
655
                    Some(t) if mount_target_ok(&t) => Some(t),
656
                    Some(t) => {
657
                        ctx.err(
658
                            c.span(),
659
                            format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"),
660
                        );
661
                        None
662
                    }
663
                    None => None,
664
                }
665
            }
666
            "size" => {
667
                size = match ctx.one_string(c) {
668
                    Some(m) => match parse_memory(&m) {
669
                        Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32),
670
                        Some(_) => {
671
                            ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB"));
672
                            None
673
                        }
674
                        None => {
675
                            ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\""));
676
                            None
677
                        }
678
                    },
679
                    None => None,
680
                }
681
            }
682
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
683
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
684
        }
685
    }
686
    for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] {
687
        if missing && !seen.contains(name) {
688
            ctx.err(node.span(), format!("a mount needs a {name}"));
689
        }
690
    }
691
    Some(Mount { source: source?, target: target?, size_mib: size? })
692
}
693
694
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
695
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
696
    if !node.entries().is_empty() {
697
        ctx.err(node.span(), "`output` takes a block of artifacts");
698
    }
699
    for a in block(ctx, node) {
700
        if a.name().value() != "artifact" {
701
            ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value()));
702
            continue;
703
        }
704
        let Some(name) = ctx.one_string(a) else { continue };
705
        if !ctx.name_ok(a.span(), "artifact", &name) {
706
            continue;
707
        }
708
        let mut path = None;
709
        for c in block(ctx, a) {
710
            ctx.no_children(c);
711
            match c.name().value() {
712
                "path" => match ctx.one_string(c) {
713
                    Some(p) if output_path_ok(&p) => path = Some(p),
714
                    Some(p) => ctx.err(
715
                        c.span(),
716
                        format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"),
717
                    ),
718
                    None => {}
719
                },
720
                "format" => {
721
                    if ctx.one_string(c).as_deref() != Some("blob") {
722
                        ctx.err(c.span(), "artifact format is \"blob\"");
723
                    }
724
                }
725
                other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")),
726
            }
727
        }
728
        let Some(path) = path else {
729
            ctx.err(a.span(), format!("artifact {name:?} needs a path"));
730
            continue;
731
        };
732
        if job.outputs.iter().any(|o| o.name == name) {
733
            ctx.err(a.span(), format!("artifact {name:?} is given twice"));
734
        } else if job.outputs.len() == MAX_OUTPUTS {
735
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts"));
736
        } else {
737
            job.outputs.push(Output { name, path });
738
        }
739
    }
740
}
741
742
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
743
    let name = ctx.one_string(node)?;
744
    let named = ctx.name_ok(node.span(), "job", &name);
745
    let mut job = Job {
746
        name: name.clone(),
747
        sandbox: String::new(),
748
        scope: vec![],
749
        depends_on: vec![],
750
        env: BTreeMap::new(),
751
        exec: vec![],
752
        mounts: vec![],
753
        outputs: vec![],
754
    };
755
    let mut seen = BTreeSet::new();
756
    let mut outputs_at = None;
757
    for c in block(ctx, node) {
758
        let key = c.name().value();
759
        if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) {
760
            ctx.err(c.span(), format!("`{key}` is given twice"));
761
        }
762
        match key {
763
            "sandbox" => {
764
                ctx.no_children(c);
765
                job.sandbox = ctx.one_string(c).unwrap_or_default();
766
            }
767
            "scope" => {
768
                ctx.no_children(c);
769
                for g in ctx.strings(c) {
770
                    match globset::Glob::new(&g) {
771
                        Ok(_) => job.scope.push(g),
772
                        Err(e) => ctx.err(c.span(), format!("scope {g:?}: {e}")),
773
                    }
774
                }
775
            }
776
            "depends-on" => {
777
                ctx.no_children(c);
778
                job.depends_on.extend(ctx.strings(c));
779
            }
780
            "env" => parse_env(ctx, c, &mut job.env),
781
            "exec" => {
782
                ctx.no_children(c);
783
                job.exec = ctx.strings(c);
784
                if job.exec.is_empty() {
785
                    ctx.err(c.span(), "`exec` needs a program and its arguments");
786
                } else if job.exec.iter().any(|a| a.contains('\0')) {
787
                    ctx.err(c.span(), "`exec` arguments cannot contain NUL");
788
                }
789
            }
790
            "mount" => {
791
                if !c.entries().is_empty() {
792
                    ctx.err(c.span(), "`mount` takes a block of source, target and size");
793
                }
794
                if let Some(m) = parse_mount(ctx, c) {
795
                    if job.mounts.len() == MAX_MOUNTS {
796
                        ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts"));
797
                    } else if let Some(other) = job.mounts.iter().find(|o| {
798
                        o.target == m.target
799
                            || m.target.starts_with(&format!("{}/", o.target))
800
                            || o.target.starts_with(&format!("{}/", m.target))
801
                    }) {
802
                        ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target));
803
                    } else {
804
                        job.mounts.push(m);
805
                    }
806
                }
807
            }
808
            "output" => {
809
                outputs_at = Some(c.span());
810
                parse_output(ctx, c, &mut job);
811
            }
812
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
813
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
814
        }
815
    }
816
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
817
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
818
    // A tmpfs is gone when the job ends; nothing on it can be kept.
819
    for o in &job.outputs {
820
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
821
            ctx.err(
822
                outputs_at.unwrap_or(node.span()),
823
                format!(
824
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
825
                    o.name, m.target
826
                ),
827
            );
828
        }
829
    }
830
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
831
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
832
    }
833
    if job.exec.is_empty() && !seen.contains("exec") {
834
        ctx.err(node.span(), format!("job {name:?} needs an exec"));
835
    }
836
    job.depends_on.sort();
837
    job.depends_on.dedup();
838
    // Kept even when incomplete, so its references are checked too; the
839
    // problems above already make the pipeline invalid.
840
    named.then_some(job)
841
}