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