OXYGENHUB
# 8c0a4e3a8a30 oxygenflow / crates / flow-core / src / pipeline.rs
30.8 KiB · Rust · last changed Job artifacts: output declarations, extraction and upload 1 month ago source blame history raw
e0918b88 Andy McCall 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,
a72abeee Andy McCall ↶ 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>,
e0918b88 Andy McCall 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>,
fc10d9e8 Andy McCall ↶ 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>,
a67b97e9 Andy McCall ↶ 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>,
fc10d9e8 Andy McCall ↶ 92
}
93
c4285535 Andy McCall ↶ 94
impl Job {
a72abeee Andy McCall ↶ 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
c4285535 Andy McCall ↶ 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
fc10d9e8 Andy McCall ↶ 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
    }
a67b97e9 Andy McCall ↶ 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
    })
fc10d9e8 Andy McCall ↶ 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 != ".."))
e0918b88 Andy McCall 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"];
a67b97e9 Andy McCall ↶ 209
const LATER_JOB: &[&str] = &["secret", "attest"];
e0918b88 Andy McCall 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
impl Pipeline {
311
    /// Parse and validate a pipeline document.
312
    pub fn parse(src: &str) -> Result<Pipeline, Problems> {
313
        let doc = match KdlDocument::parse_v2(src) {
314
            Ok(d) => d,
315
            Err(e) => {
316
                let mut ctx = Ctx { src, problems: Vec::new() };
317
                for d in &e.diagnostics {
318
                    let msg = d.message.clone().or_else(|| d.label.clone()).unwrap_or_else(|| "not valid KDL 2.0".into());
319
                    ctx.err(d.span, msg);
320
                }
321
                if ctx.problems.is_empty() {
322
                    ctx.problems.push(Problem { line: 1, message: "not valid KDL 2.0".into() });
323
                }
324
                return Err(Problems(ctx.problems));
325
            }
326
        };
327
        let mut ctx = Ctx { src, problems: Vec::new() };
328
        let mut schema = None;
329
        let mut sandboxes = BTreeMap::new();
330
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
331
        for node in doc.nodes() {
332
            match node.name().value() {
333
                "schema-version" => {
334
                    ctx.no_children(node);
335
                    match ctx.one_integer(node) {
336
                        Some(1) => schema = Some(1),
337
                        Some(n) => ctx.err(node.span(), format!("schema-version {n} is not supported; this runner reads 1")),
338
                        None => {}
339
                    }
340
                }
341
                "sandbox" => {
342
                    if let Some(sb) = parse_sandbox(&mut ctx, node) {
343
                        if sandboxes.contains_key(&sb.name) {
344
                            ctx.err(node.span(), format!("sandbox {:?} is declared twice", sb.name));
345
                        } else {
346
                            sandboxes.insert(sb.name.clone(), sb);
347
                        }
348
                    }
349
                }
350
                "job" => {
351
                    if let Some(job) = parse_job(&mut ctx, node) {
352
                        if jobs.contains_key(&job.name) {
353
                            ctx.err(node.span(), format!("job {:?} is declared twice", job.name));
354
                        } else {
355
                            jobs.insert(job.name.clone(), (job, node.span()));
356
                        }
357
                    }
358
                }
359
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
360
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, sandbox or job")),
361
            }
362
        }
363
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
364
            ctx.problems.push(Problem { line: 1, message: "`schema-version 1` is required".into() });
365
        }
366
        if jobs.is_empty() && ctx.problems.is_empty() {
367
            ctx.problems.push(Problem { line: 1, message: "a pipeline needs at least one job".into() });
368
        }
369
        // References and cycles.
370
        for (job, span) in jobs.values() {
371
            if !job.sandbox.is_empty() && !sandboxes.contains_key(&job.sandbox) {
372
                ctx.err(*span, format!("job {:?} uses sandbox {:?}, which is not declared", job.name, job.sandbox));
373
            }
374
            for d in &job.depends_on {
375
                if !jobs.contains_key(d) {
376
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
377
                } else if d == &job.name {
378
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
379
                }
380
            }
381
        }
382
        let graph: BTreeMap<&str, Vec<&str>> =
383
            jobs.values().map(|(j, _)| (j.name.as_str(), j.depends_on.iter().map(String::as_str).collect())).collect();
384
        if let Some(cycle) = find_cycle(&graph) {
385
            let span = jobs[cycle[0]].1;
386
            ctx.err(span, format!("jobs depend on each other in a cycle: {}", cycle.join(" → ")));
387
        }
388
        if !ctx.problems.is_empty() {
389
            ctx.problems.sort_by_key(|p| p.line);
390
            return Err(Problems(ctx.problems));
391
        }
392
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect() })
393
    }
394
395
    /// Jobs in an order where each comes after what it depends on (ties by name).
396
    pub fn order(&self) -> Vec<&Job> {
397
        let mut done: BTreeSet<&str> = BTreeSet::new();
398
        let mut out = Vec::with_capacity(self.jobs.len());
399
        while out.len() < self.jobs.len() {
400
            let before = out.len();
401
            for j in self.jobs.values() {
402
                if !done.contains(j.name.as_str()) && j.depends_on.iter().all(|d| done.contains(d.as_str())) {
403
                    out.push(j);
404
                }
405
            }
406
            for j in &out[before..] {
407
                done.insert(j.name.as_str());
408
            }
409
            if out.len() == before {
410
                break; // validated pipelines have no cycles
411
            }
412
        }
413
        out
414
    }
415
416
    /// Each job's depth: 0 without dependencies, else one more than its deepest dependency.
417
    pub fn depths(&self) -> BTreeMap<&str, usize> {
418
        let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
419
        for j in self.order() {
420
            let d = j.depends_on.iter().map(|x| depth.get(x.as_str()).copied().unwrap_or(0) + 1).max().unwrap_or(0);
421
            depth.insert(j.name.as_str(), d);
422
        }
423
        depth
424
    }
425
}
426
427
fn find_cycle<'a>(graph: &BTreeMap<&'a str, Vec<&'a str>>) -> Option<Vec<&'a str>> {
428
    #[derive(Clone, Copy, PartialEq)]
429
    enum Mark {
430
        Visiting,
431
        Done,
432
    }
433
    fn visit<'a>(
434
        n: &'a str,
435
        graph: &BTreeMap<&'a str, Vec<&'a str>>,
436
        marks: &mut BTreeMap<&'a str, Mark>,
437
        stack: &mut Vec<&'a str>,
438
    ) -> Option<Vec<&'a str>> {
439
        match marks.get(n) {
440
            Some(Mark::Done) => return None,
441
            Some(Mark::Visiting) => {
442
                let at = stack.iter().position(|s| *s == n).unwrap_or(0);
443
                let mut c = stack[at..].to_vec();
444
                c.push(n);
445
                return Some(c);
446
            }
447
            None => {}
448
        }
449
        marks.insert(n, Mark::Visiting);
450
        stack.push(n);
451
        for d in graph.get(n).into_iter().flatten() {
452
            if graph.contains_key(d) && *d != n {
453
                if let Some(c) = visit(d, graph, marks, stack) {
454
                    return Some(c);
455
                }
456
            }
457
        }
458
        stack.pop();
459
        marks.insert(n, Mark::Done);
460
        None
461
    }
462
    let mut marks = BTreeMap::new();
463
    for n in graph.keys() {
464
        let mut stack = Vec::new();
465
        if let Some(c) = visit(n, graph, &mut marks, &mut stack) {
466
            return Some(c);
467
        }
468
    }
469
    None
470
}
471
472
fn block<'a>(ctx: &mut Ctx, node: &'a KdlNode) -> &'a [KdlNode] {
473
    match node.children() {
474
        Some(d) => d.nodes(),
475
        None => {
476
            ctx.err(node.span(), format!("`{}` needs a block {{ … }}", node.name().value()));
477
            &[]
478
        }
479
    }
480
}
481
482
fn parse_sandbox(ctx: &mut Ctx, node: &KdlNode) -> Option<Sandbox> {
483
    let name = ctx.one_string(node)?;
484
    let named = ctx.name_ok(node.span(), "sandbox", &name);
485
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
486
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
487
    let mut seen = BTreeSet::new();
a72abeee Andy McCall ↶ 488
    let mut env = BTreeMap::new();
e0918b88 Andy McCall 489
    for c in block(ctx, node) {
490
        let key = c.name().value();
491
        if !seen.insert(key.to_string()) {
492
            ctx.err(c.span(), format!("`{key}` is given twice"));
a72abeee Andy McCall ↶ 493
        }
494
        if key == "env" {
495
            parse_env(ctx, c, &mut env);
496
            continue;
e0918b88 Andy McCall 497
        }
498
        ctx.no_children(c);
499
        match key {
500
            "runtime" => {
501
                runtime = match ctx.one_string(c).as_deref() {
502
                    Some("microvm") => Some(Runtime::Microvm),
503
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
504
                    Some("wasm") => {
505
                        ctx.err(c.span(), "runtime \"wasm\" is not in M0 yet; use \"microvm\" or \"syscall\"");
506
                        None
507
                    }
508
                    Some(other) => {
509
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
510
                        None
511
                    }
512
                    None => None,
513
                }
514
            }
515
            "rootfs" => {
516
                rootfs = match ctx.one_string(c) {
517
                    Some(r) => match r.strip_prefix("cas://blob/") {
518
                        Some(h) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) => {
519
                            Some(h.to_string())
520
                        }
521
                        _ => {
522
                            ctx.err(c.span(), format!("rootfs {r:?}: expected \"cas://blob/\" and 64 lower-case hex digits"));
523
                            None
524
                        }
525
                    },
526
                    None => None,
527
                }
528
            }
529
            "memory-limit" => {
530
                memory = match ctx.one_string(c) {
531
                    Some(m) => match parse_memory(&m) {
532
                        Some(mib) if (MIN_MEMORY_MIB..=MAX_MEMORY_MIB).contains(&mib) => Some(mib as u32),
533
                        Some(_) => {
534
                            ctx.err(c.span(), format!("memory-limit {m:?} must be from 64MiB to 16GiB"));
535
                            None
536
                        }
537
                        None => {
538
                            ctx.err(c.span(), format!("memory-limit {m:?}: expected a whole number of KiB, MiB or GiB, as \"4GiB\""));
539
                            None
540
                        }
541
                    },
542
                    None => None,
543
                }
544
            }
545
            "cpu-limit" => {
546
                cpus = match ctx.one_integer(c) {
547
                    Some(n) if (1..=MAX_CPUS).contains(&n) => Some(n as u32),
548
                    Some(n) => {
549
                        ctx.err(c.span(), format!("cpu-limit {n} must be from 1 to {MAX_CPUS}"));
550
                        None
551
                    }
552
                    None => None,
553
                }
554
            }
555
            "network" => {
556
                network = match ctx.one_string(c).as_deref() {
557
                    Some("isolated") => Some(Network::Isolated),
eb5c4db6 Andy McCall ↶ 558
                    // Loopback only: the guest's `lo` comes up, and it still has
559
                    // no device that reaches anything outside it.
560
                    Some("loopback") => Some(Network::Loopback),
561
                    Some("egress") => {
562
                        ctx.err(c.span(), "network \"egress\" is not in M0; M0 has \"isolated\" and \"loopback\"");
e0918b88 Andy McCall 563
                        None
564
                    }
565
                    Some(other) => {
566
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
567
                        None
568
                    }
569
                    None => None,
570
                }
571
            }
572
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
573
        }
574
    }
575
    if rootfs.is_none() && !seen.contains("rootfs") {
576
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
577
    }
578
    if !named {
579
        return None;
a72abeee Andy McCall ↶ 580
    }
581
    Some(Sandbox { name, runtime: runtime?, rootfs: rootfs?, memory_mib: memory?, cpus: cpus?, network: network?, env })
582
}
583
584
/// An `env` block of `NAME "value"` lines, into `env`.
585
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
586
    if !c.entries().is_empty() {
587
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
588
    }
589
    for e in block(ctx, c) {
590
        let k = e.name().value().to_string();
591
        let valid =
592
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
593
        if !valid {
594
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
595
            continue;
596
        }
597
        ctx.no_children(e);
598
        if let Some(v) = ctx.one_string(e) {
599
            if env.insert(k.clone(), v).is_some() {
600
                ctx.err(e.span(), format!("environment {k} is set twice"));
601
            }
602
        }
e0918b88 Andy McCall 603
    }
604
}
605
fc10d9e8 Andy McCall ↶ 606
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
607
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
608
    let (mut source, mut target, mut size) = (None, None, None);
609
    let mut seen = BTreeSet::new();
610
    for c in block(ctx, node) {
611
        let key = c.name().value();
612
        if !seen.insert(key.to_string()) {
613
            ctx.err(c.span(), format!("`{key}` is given twice"));
614
            continue;
615
        }
616
        ctx.no_children(c);
617
        match key {
618
            "source" => {
619
                source = match ctx.one_string(c).as_deref() {
620
                    Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs),
621
                    Some("ephemeral://disk") => Some(MountSource::Disk),
622
                    Some(other) => {
623
                        ctx.err(
624
                            c.span(),
625
                            format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""),
626
                        );
627
                        None
628
                    }
629
                    None => None,
630
                }
631
            }
632
            "target" => {
633
                target = match ctx.one_string(c) {
634
                    Some(t) if mount_target_ok(&t) => Some(t),
635
                    Some(t) => {
636
                        ctx.err(
637
                            c.span(),
638
                            format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"),
639
                        );
640
                        None
641
                    }
642
                    None => None,
643
                }
644
            }
645
            "size" => {
646
                size = match ctx.one_string(c) {
647
                    Some(m) => match parse_memory(&m) {
648
                        Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32),
649
                        Some(_) => {
650
                            ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB"));
651
                            None
652
                        }
653
                        None => {
654
                            ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\""));
655
                            None
656
                        }
657
                    },
658
                    None => None,
659
                }
660
            }
661
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
662
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
663
        }
664
    }
665
    for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] {
666
        if missing && !seen.contains(name) {
667
            ctx.err(node.span(), format!("a mount needs a {name}"));
668
        }
669
    }
670
    Some(Mount { source: source?, target: target?, size_mib: size? })
671
}
672
a67b97e9 Andy McCall ↶ 673
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
674
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
675
    if !node.entries().is_empty() {
676
        ctx.err(node.span(), "`output` takes a block of artifacts");
677
    }
678
    for a in block(ctx, node) {
679
        if a.name().value() != "artifact" {
680
            ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value()));
681
            continue;
682
        }
683
        let Some(name) = ctx.one_string(a) else { continue };
684
        if !ctx.name_ok(a.span(), "artifact", &name) {
685
            continue;
686
        }
687
        let mut path = None;
688
        for c in block(ctx, a) {
689
            ctx.no_children(c);
690
            match c.name().value() {
691
                "path" => match ctx.one_string(c) {
692
                    Some(p) if output_path_ok(&p) => path = Some(p),
693
                    Some(p) => ctx.err(
694
                        c.span(),
695
                        format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"),
696
                    ),
697
                    None => {}
698
                },
699
                "format" => {
700
                    if ctx.one_string(c).as_deref() != Some("blob") {
701
                        ctx.err(c.span(), "artifact format is \"blob\"");
702
                    }
703
                }
704
                other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")),
705
            }
706
        }
707
        let Some(path) = path else {
708
            ctx.err(a.span(), format!("artifact {name:?} needs a path"));
709
            continue;
710
        };
711
        if job.outputs.iter().any(|o| o.name == name) {
712
            ctx.err(a.span(), format!("artifact {name:?} is given twice"));
713
        } else if job.outputs.len() == MAX_OUTPUTS {
714
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts"));
715
        } else {
716
            job.outputs.push(Output { name, path });
717
        }
718
    }
719
}
720
e0918b88 Andy McCall 721
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
722
    let name = ctx.one_string(node)?;
723
    let named = ctx.name_ok(node.span(), "job", &name);
fc10d9e8 Andy McCall ↶ 724
    let mut job = Job {
725
        name: name.clone(),
726
        sandbox: String::new(),
727
        scope: vec![],
728
        depends_on: vec![],
729
        env: BTreeMap::new(),
730
        exec: vec![],
731
        mounts: vec![],
a67b97e9 Andy McCall ↶ 732
        outputs: vec![],
fc10d9e8 Andy McCall ↶ 733
    };
e0918b88 Andy McCall 734
    let mut seen = BTreeSet::new();
a67b97e9 Andy McCall ↶ 735
    let mut outputs_at = None;
e0918b88 Andy McCall 736
    for c in block(ctx, node) {
737
        let key = c.name().value();
fc10d9e8 Andy McCall ↶ 738
        if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) {
e0918b88 Andy McCall 739
            ctx.err(c.span(), format!("`{key}` is given twice"));
740
        }
741
        match key {
742
            "sandbox" => {
743
                ctx.no_children(c);
744
                job.sandbox = ctx.one_string(c).unwrap_or_default();
745
            }
746
            "scope" => {
747
                ctx.no_children(c);
748
                for g in ctx.strings(c) {
749
                    match globset::Glob::new(&g) {
750
                        Ok(_) => job.scope.push(g),
751
                        Err(e) => ctx.err(c.span(), format!("scope {g:?}: {e}")),
752
                    }
753
                }
754
            }
755
            "depends-on" => {
756
                ctx.no_children(c);
757
                job.depends_on.extend(ctx.strings(c));
758
            }
a72abeee Andy McCall ↶ 759
            "env" => parse_env(ctx, c, &mut job.env),
e0918b88 Andy McCall 760
            "exec" => {
761
                ctx.no_children(c);
762
                job.exec = ctx.strings(c);
763
                if job.exec.is_empty() {
764
                    ctx.err(c.span(), "`exec` needs a program and its arguments");
765
                } else if job.exec.iter().any(|a| a.contains('\0')) {
766
                    ctx.err(c.span(), "`exec` arguments cannot contain NUL");
767
                }
768
            }
fc10d9e8 Andy McCall ↶ 769
            "mount" => {
770
                if !c.entries().is_empty() {
771
                    ctx.err(c.span(), "`mount` takes a block of source, target and size");
772
                }
773
                if let Some(m) = parse_mount(ctx, c) {
774
                    if job.mounts.len() == MAX_MOUNTS {
775
                        ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts"));
776
                    } else if let Some(other) = job.mounts.iter().find(|o| {
777
                        o.target == m.target
778
                            || m.target.starts_with(&format!("{}/", o.target))
779
                            || o.target.starts_with(&format!("{}/", m.target))
780
                    }) {
781
                        ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target));
782
                    } else {
783
                        job.mounts.push(m);
784
                    }
785
                }
786
            }
a67b97e9 Andy McCall ↶ 787
            "output" => {
788
                outputs_at = Some(c.span());
789
                parse_output(ctx, c, &mut job);
790
            }
e0918b88 Andy McCall 791
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
792
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
793
        }
794
    }
fc10d9e8 Andy McCall ↶ 795
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
a67b97e9 Andy McCall ↶ 796
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
797
    // A tmpfs is gone when the job ends; nothing on it can be kept.
798
    for o in &job.outputs {
799
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
800
            ctx.err(
801
                outputs_at.unwrap_or(node.span()),
802
                format!(
803
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
804
                    o.name, m.target
805
                ),
806
            );
807
        }
808
    }
e0918b88 Andy McCall 809
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
810
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
811
    }
812
    if job.exec.is_empty() && !seen.contains("exec") {
813
        ctx.err(node.span(), format!("job {name:?} needs an exec"));
814
    }
815
    job.depends_on.sort();
816
    job.depends_on.dedup();
817
    // Kept even when incomplete, so its references are checked too; the
818
    // problems above already make the pipeline invalid.
819
    named.then_some(job)
820
}