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