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