OXYGENHUB
# 46fe2c815cc3 oxygenflow / crates / flow-core / src / pipeline.rs
57.9 KiB · Rust · last changed Artifacts between jobs: input { artifact "x" from "job"; path "/src/x" } 21 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
    /// The hosts a `network "egress"` sandbox may reach (oxygenflow#55):
139
    /// names, or `*.` and a domain for any name under it; sorted. Its jobs
140
    /// reach them through the runner's proxy, and nothing else.
141
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142
    pub egress_allow: Vec<String>,
143
    /// How long each job here may run, in seconds (`timeout "2h"`), when
144
    /// the job does not say; the hub caps it at the site's maximum.
145
    #[serde(default, skip_serializing_if = "Option::is_none")]
146
    pub timeout_s: Option<u64>,
147
    /// The architecture the jobs need (`arch "riscv64"`, spec §11.3);
148
    /// without it, the rootfs image's.
149
    #[serde(default, skip_serializing_if = "Option::is_none")]
150
    pub arch: Option<String>,
151
}
152
153
/// An architecture name as runners report it: `x86_64`, `aarch64`, `riscv64`.
154
pub fn arch_ok(a: &str) -> bool {
155
    (2..=16).contains(&a.len()) && a.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
156
}
157
158
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
159
pub struct Job {
160
    pub name: String,
161
    pub sandbox: String,
162
    /// Glob patterns over repository paths (spec §6.2).
163
    pub scope: Vec<String>,
164
    /// Sorted.
165
    pub depends_on: Vec<String>,
166
    pub env: BTreeMap<String, String>,
167
    /// Executed as is: no shell, no expansion (spec §2.2 I2).
168
    pub exec: Vec<String>,
169
    /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`).
170
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171
    pub mounts: Vec<Mount>,
172
    /// Files the job produces, sorted by name (spec §5.2 `output`).
173
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174
    pub outputs: Vec<Output>,
175
    /// Artifacts of jobs this one depends on, put in its workspace before it
176
    /// starts (oxygenflow#58), sorted by path. The runner checks each against
177
    /// the producing job's receipt; this job's receipt lists them as inputs.
178
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179
    pub inputs: Vec<Input>,
180
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
181
    /// supplies each by name, the runner mounts it as a file.
182
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183
    pub secrets: Vec<Secret>,
184
    /// Identities the job asks the hub for (oxygenhub#103): an OIDC token
185
    /// for an audience, mounted as a file like a secret.
186
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187
    pub identities: Vec<Identity>,
188
    /// Labels a runner must have to take this job (`runs-on`), sorted;
189
    /// with its sandbox's, what the hub matches against a runner's.
190
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191
    pub runs_on: Vec<String>,
192
    /// How long the job may run, in seconds (`timeout "90m"`); its
193
    /// sandbox's when absent, else the site's default (oxygenflow#23).
194
    #[serde(default, skip_serializing_if = "Option::is_none")]
195
    pub timeout_s: Option<u64>,
196
    /// When it runs, over the pipeline's `on` (oxygenflow#24).
197
    #[serde(default, skip_serializing_if = "Option::is_none")]
198
    pub on: Option<On>,
199
}
200
201
impl Job {
202
    /// The job's environment on top of its sandbox's: what it runs with.
203
    pub fn in_sandbox(&self, sandbox: &Sandbox) -> Job {
204
        let mut job = self.clone();
205
        for (k, v) in &sandbox.env {
206
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
207
        }
208
        job.runs_on.extend(sandbox.runs_on.iter().cloned());
209
        job.runs_on.sort();
210
        job.runs_on.dedup();
211
        job.timeout_s = job.timeout_s.or(sandbox.timeout_s);
212
        job
213
    }
214
215
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
216
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
217
    /// sets itself keeps the pipeline's value.
218
    pub fn with_flow_env(&self, vars: &[(String, String)]) -> Job {
219
        let mut job = self.clone();
220
        for (k, v) in vars {
221
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
222
        }
223
        job
224
    }
225
}
226
227
/// An ephemeral scratch mount: empty at the start of every job and gone
228
/// after it, so it cannot carry anything between jobs (spec §2.2 I4) --
229
/// unless it names a `cache` (spec §5.4, #28): then a disk kept from an
230
/// earlier passing job may start it, and the receipt says which.
231
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
232
pub struct Mount {
233
    pub source: MountSource,
234
    /// An absolute path under `/src` or `/tmp`.
235
    pub target: String,
236
    pub size_mib: u32,
237
    /// `cache "name"`: keep this disk after a passing job and start later
238
    /// jobs from it (disk mounts only).
239
    #[serde(default, skip_serializing_if = "Option::is_none")]
240
    pub cache: Option<String>,
241
    /// `key "Cargo.lock" ...`: files whose content picks the cache entry.
242
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
243
    pub key: Vec<String>,
244
}
245
246
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
247
#[serde(rename_all = "lowercase")]
248
pub enum MountSource {
249
    /// `ephemeral://tmpfs`: memory in the sandbox, counted against its memory-limit.
250
    Tmpfs,
251
    /// `ephemeral://disk`: a fresh sparse disk on the runner, for large build trees.
252
    Disk,
253
}
254
255
impl MountSource {
256
    pub fn as_str(self) -> &'static str {
257
        match self {
258
            MountSource::Tmpfs => "ephemeral://tmpfs",
259
            MountSource::Disk => "ephemeral://disk",
260
        }
261
    }
262
}
263
264
/// A file a job produces (spec §5.2): read from the job's disks when it
265
/// ends, kept in the repository as a blob, and named by its receipt.
266
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267
pub struct Output {
268
    /// The artifact's name, unique in the job.
269
    pub name: String,
270
    /// A clean absolute path under `/src`, not on a tmpfs mount.
271
    pub path: String,
272
    /// `when "always"`: kept from a failing job too, such as a test report
273
    /// (oxygenflow#37). From a failing job it is never in the receipt.
274
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
275
    pub always: bool,
276
    /// An artifact is one file; a report is a directory with a
277
    /// `report.toml`, kept file by file with a manifest (oxygenhub#127).
278
    #[serde(default, skip_serializing_if = "OutputKind::is_artifact")]
279
    pub kind: OutputKind,
280
}
281
282
/// An artifact a job takes from a job it depends on (oxygenflow#58):
283
/// `input { artifact "tfplan" from "plan"; path "/src/tfplan" }`.
284
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
285
pub struct Input {
286
    /// The producing job's artifact.
287
    pub artifact: String,
288
    /// The producing job: one this job depends on.
289
    pub from: String,
290
    /// Where it is put: a clean absolute path under `/src`.
291
    pub path: String,
292
}
293
294
/// The most inputs a job takes.
295
const MAX_INPUTS: usize = 8;
296
297
/// What an [`Output`] is.
298
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
299
#[serde(rename_all = "lowercase")]
300
pub enum OutputKind {
301
    #[default]
302
    Artifact,
303
    Report,
304
}
305
306
impl OutputKind {
307
    pub fn is_artifact(&self) -> bool {
308
        *self == OutputKind::Artifact
309
    }
310
311
    pub fn word(&self) -> &'static str {
312
        match self {
313
            OutputKind::Artifact => "artifact",
314
            OutputKind::Report => "report",
315
        }
316
    }
317
}
318
319
/// A secret the job reads as a file (spec §9): never in the environment.
320
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
321
pub struct Secret {
322
    /// Which secret, as the hub names it; unique in the job.
323
    pub name: String,
324
    /// A clean absolute path under `/run/secrets/`, unique in the job;
325
    /// the file is 0400, the job's user's, on an in-memory mount.
326
    pub mount_path: String,
327
}
328
329
/// An OIDC token the hub mints for the job (oxygenhub#103), naming the
330
/// repository, bookmark, run and job, for `audience`; read as a file.
331
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
332
pub struct Identity {
333
    /// Who the token is for (`sts.amazonaws.com`, a Vault role's audience, …).
334
    pub audience: String,
335
    /// A clean absolute path under `/run/secrets/`, unique among the
336
    /// job's secrets and identities.
337
    pub mount_path: String,
338
}
339
340
/// A secret's mount path: under `/run/secrets/`, no `.` or `..`.
341
pub fn secret_path_ok(p: &str) -> bool {
342
    p.strip_prefix("/run/secrets/").is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
343
}
344
345
/// Artifacts a job may declare.
346
pub const MAX_OUTPUTS: usize = 16;
347
348
/// Whether `p` is a clean absolute path strictly under `/src`, with nothing
349
/// a debugfs command line would have to quote.
350
fn output_path_ok(p: &str) -> bool {
351
    p.strip_prefix("/src/").is_some_and(|rest| {
352
        !rest.is_empty()
353
            && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
354
            && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
355
    })
356
}
357
358
/// Mounts a job may declare.
359
pub const MAX_MOUNTS: usize = 4;
360
/// The most secrets a job may mount.
361
pub const MAX_SECRETS: usize = 8;
362
/// The most identities a job may ask for.
363
pub const MAX_IDENTITIES: usize = 4;
364
/// Sizes a mount may have: 16 MiB to 64 GiB.
365
pub const MIN_MOUNT_MIB: u64 = 16;
366
pub const MAX_MOUNT_MIB: u64 = 64 * 1024;
367
368
/// Whether `t` is a clean absolute path strictly under `/src` or `/tmp`.
369
fn mount_target_ok(t: &str) -> bool {
370
    let under = t.strip_prefix("/src/").or_else(|| t.strip_prefix("/tmp/"));
371
    under.is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
372
}
373
374
/// One problem in a pipeline, at a 1-based line.
375
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
376
pub struct Problem {
377
    pub line: usize,
378
    pub message: String,
379
}
380
381
impl fmt::Display for Problem {
382
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383
        write!(f, "line {}: {}", self.line, self.message)
384
    }
385
}
386
387
/// Every problem found, in line order.
388
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
389
pub struct Problems(pub Vec<Problem>);
390
391
impl fmt::Display for Problems {
392
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393
        let lines: Vec<String> = self.0.iter().map(|p| p.to_string()).collect();
394
        f.write_str(&lines.join("\n"))
395
    }
396
}
397
398
const MIN_MEMORY_MIB: u64 = 64;
399
const MAX_MEMORY_MIB: u64 = 16 * 1024;
400
const MAX_CPUS: i128 = 16;
401
402
/// Nodes of the full spec that M0 recognises and refuses.
403
const LATER_TOP: &[&str] = &["publish", "secret"];
404
const LATER_JOB: &[&str] = &["attest"];
405
406
struct Ctx<'a> {
407
    src: &'a str,
408
    problems: Vec<Problem>,
409
}
410
411
impl Ctx<'_> {
412
    fn line(&self, span: SourceSpan) -> usize {
413
        let at = span.offset().min(self.src.len());
414
        self.src[..at].bytes().filter(|b| *b == b'\n').count() + 1
415
    }
416
417
    fn err(&mut self, span: SourceSpan, message: impl Into<String>) {
418
        let line = self.line(span);
419
        self.problems.push(Problem { line, message: message.into() });
420
    }
421
422
    /// The node's arguments as strings; properties and other types are errors.
423
    fn strings(&mut self, node: &KdlNode) -> Vec<String> {
424
        let mut out = Vec::new();
425
        for e in node.entries() {
426
            if let Some(k) = e.name() {
427
                self.err(e.span(), format!("`{}` takes no property `{}`", node.name().value(), k.value()));
428
                continue;
429
            }
430
            match e.value() {
431
                KdlValue::String(s) => out.push(s.clone()),
432
                other => self.err(e.span(), format!("`{}` takes strings, not {}", node.name().value(), kind_of(other))),
433
            }
434
        }
435
        out
436
    }
437
438
    /// Exactly one string argument.
439
    fn one_string(&mut self, node: &KdlNode) -> Option<String> {
440
        let v = self.strings(node);
441
        if v.len() != 1 {
442
            self.err(node.span(), format!("`{}` takes one string", node.name().value()));
443
            return None;
444
        }
445
        v.into_iter().next()
446
    }
447
448
    fn one_integer(&mut self, node: &KdlNode) -> Option<i128> {
449
        match node.entries() {
450
            [e] if e.name().is_none() => match e.value() {
451
                KdlValue::Integer(n) => Some(*n),
452
                other => {
453
                    self.err(e.span(), format!("`{}` takes an integer, not {}", node.name().value(), kind_of(other)));
454
                    None
455
                }
456
            },
457
            _ => {
458
                self.err(node.span(), format!("`{}` takes one integer", node.name().value()));
459
                None
460
            }
461
        }
462
    }
463
464
    fn no_children(&mut self, node: &KdlNode) {
465
        if node.children().is_some() {
466
            self.err(node.span(), format!("`{}` takes no block", node.name().value()));
467
        }
468
    }
469
470
    fn name_ok(&mut self, span: SourceSpan, what: &str, name: &str) -> bool {
471
        let ok = !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
472
        if !ok {
473
            self.err(span, format!("{what} name {name:?} must be 1 to 64 of A-Z a-z 0-9 . _ -"));
474
        }
475
        ok
476
    }
477
}
478
479
use kdl::{KdlDocument, KdlNode, KdlValue};
480
use miette::SourceSpan;
481
482
fn kind_of(v: &KdlValue) -> &'static str {
483
    match v {
484
        KdlValue::String(_) => "a string",
485
        KdlValue::Integer(_) => "an integer",
486
        KdlValue::Float(_) => "a number",
487
        KdlValue::Bool(_) => "a boolean",
488
        KdlValue::Null => "null",
489
    }
490
}
491
492
/// `"512MiB"`, `"4GiB"`, `"65536KiB"` to MiB.
493
fn parse_memory(s: &str) -> Option<u64> {
494
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
495
    let n: u64 = num.parse().ok()?;
496
    let mib = match unit {
497
        "KiB" => n.is_multiple_of(1024).then_some(n / 1024)?,
498
        "MiB" => n,
499
        "GiB" => n.checked_mul(1024)?,
500
        _ => return None,
501
    };
502
    Some(mib)
503
}
504
505
/// The line of the first escape KDL 2.0 does not have but shells and C
506
/// do (`\0NN`, `\e`, `\xNN`), as someone colouring output writes them.
507
fn foreign_escape_line(src: &str) -> Option<usize> {
508
    src.lines()
509
        .position(|l| {
510
            let b = l.as_bytes();
511
            b.windows(2).enumerate().any(|(i, w)| {
512
                w[0] == b'\\'
513
                    && (i == 0 || b[i - 1] != b'\\')
514
                    && (w[1] == b'e' || w[1] == b'x' || (w[1] == b'0' && b.get(i + 2).is_some_and(u8::is_ascii_digit)))
515
            })
516
        })
517
        .map(|i| i + 1)
518
}
519
520
impl Pipeline {
521
    /// Parse and validate a pipeline document.
522
    pub fn parse(src: &str) -> Result<Pipeline, Problems> {
523
        let doc = match KdlDocument::parse_v2(src) {
524
            Ok(d) => d,
525
            Err(e) => {
526
                let mut ctx = Ctx { src, problems: Vec::new() };
527
                for d in &e.diagnostics {
528
                    let msg = d.message.clone().or_else(|| d.label.clone()).unwrap_or_else(|| "not valid KDL 2.0".into());
529
                    ctx.err(d.span, msg);
530
                }
531
                if ctx.problems.is_empty() {
532
                    ctx.problems.push(Problem { line: 1, message: "not valid KDL 2.0".into() });
533
                }
534
                if let Some(line) = foreign_escape_line(src) {
535
                    ctx.problems.push(Problem {
536
                        line,
537
                        message: "KDL strings have no \\033, \\e or \\x escapes: write ESC (for ANSI colours) as \\u{1b}".into(),
538
                    });
539
                }
540
                return Err(Problems(ctx.problems));
541
            }
542
        };
543
        let mut ctx = Ctx { src, problems: Vec::new() };
544
        let mut schema = None;
545
        let mut sandboxes = BTreeMap::new();
546
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
547
        let mut on = None;
548
        for node in doc.nodes() {
549
            match node.name().value() {
550
                "on" => {
551
                    if on.is_some() {
552
                        ctx.err(node.span(), "`on` is given twice");
553
                    }
554
                    on = Some(parse_on(&mut ctx, node));
555
                }
556
                "schema-version" => {
557
                    ctx.no_children(node);
558
                    match ctx.one_integer(node) {
559
                        Some(1) => schema = Some(1),
560
                        Some(n) => ctx.err(node.span(), format!("schema-version {n} is not supported; this runner reads 1")),
561
                        None => {}
562
                    }
563
                }
564
                "sandbox" => {
565
                    if let Some(sb) = parse_sandbox(&mut ctx, node) {
566
                        if sandboxes.contains_key(&sb.name) {
567
                            ctx.err(node.span(), format!("sandbox {:?} is declared twice", sb.name));
568
                        } else {
569
                            sandboxes.insert(sb.name.clone(), sb);
570
                        }
571
                    }
572
                }
573
                "job" => {
574
                    if let Some(job) = parse_job(&mut ctx, node) {
575
                        if jobs.contains_key(&job.name) {
576
                            ctx.err(node.span(), format!("job {:?} is declared twice", job.name));
577
                        } else {
578
                            jobs.insert(job.name.clone(), (job, node.span()));
579
                        }
580
                    }
581
                }
582
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
583
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, sandbox or job")),
584
            }
585
        }
586
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
587
            ctx.problems.push(Problem { line: 1, message: "`schema-version 1` is required".into() });
588
        }
589
        if jobs.is_empty() && ctx.problems.is_empty() {
590
            ctx.problems.push(Problem { line: 1, message: "a pipeline needs at least one job".into() });
591
        }
592
        // References and cycles.
593
        for (job, span) in jobs.values() {
594
            if !job.sandbox.is_empty() && !sandboxes.contains_key(&job.sandbox) {
595
                ctx.err(*span, format!("job {:?} uses sandbox {:?}, which is not declared", job.name, job.sandbox));
596
            }
597
            for d in &job.depends_on {
598
                if !jobs.contains_key(d) {
599
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
600
                } else if d == &job.name {
601
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
602
                }
603
            }
604
            // Inputs come from a job this one depends on, which declares
605
            // that artifact (oxygenflow#58).
606
            for i in &job.inputs {
607
                let Some((producer, _)) = jobs.get(&i.from) else {
608
                    ctx.err(*span, format!("job {:?} takes an input from {:?}, which is not declared", job.name, i.from));
609
                    continue;
610
                };
611
                if !job.depends_on.contains(&i.from) {
612
                    ctx.err(*span, format!("job {:?} takes {:?} from {:?}: add depends-on \"{}\"", job.name, i.artifact, i.from, i.from));
613
                }
614
                match producer.outputs.iter().find(|o| o.name == i.artifact) {
615
                    Some(o) if o.kind == OutputKind::Artifact => {}
616
                    Some(_) => ctx.err(*span, format!("{:?} of job {:?} is a report; inputs are artifacts", i.artifact, i.from)),
617
                    None => ctx.err(*span, format!("job {:?} declares no artifact {:?}", i.from, i.artifact)),
618
                }
619
            }
620
        }
621
        let graph: BTreeMap<&str, Vec<&str>> =
622
            jobs.values().map(|(j, _)| (j.name.as_str(), j.depends_on.iter().map(String::as_str).collect())).collect();
623
        if let Some(cycle) = find_cycle(&graph) {
624
            let span = jobs[cycle[0]].1;
625
            ctx.err(span, format!("jobs depend on each other in a cycle: {}", cycle.join(" → ")));
626
        }
627
        if !ctx.problems.is_empty() {
628
            ctx.problems.sort_by_key(|p| p.line);
629
            return Err(Problems(ctx.problems));
630
        }
631
        Ok(Pipeline { schema_version: 1, sandboxes, jobs: jobs.into_iter().map(|(k, (j, _))| (k, j)).collect(), on })
632
    }
633
634
    /// Jobs in an order where each comes after what it depends on (ties by name).
635
    pub fn order(&self) -> Vec<&Job> {
636
        let mut done: BTreeSet<&str> = BTreeSet::new();
637
        let mut out = Vec::with_capacity(self.jobs.len());
638
        while out.len() < self.jobs.len() {
639
            let before = out.len();
640
            for j in self.jobs.values() {
641
                if !done.contains(j.name.as_str()) && j.depends_on.iter().all(|d| done.contains(d.as_str())) {
642
                    out.push(j);
643
                }
644
            }
645
            for j in &out[before..] {
646
                done.insert(j.name.as_str());
647
            }
648
            if out.len() == before {
649
                break; // validated pipelines have no cycles
650
            }
651
        }
652
        out
653
    }
654
655
    /// Each job's depth: 0 without dependencies, else one more than its deepest dependency.
656
    pub fn depths(&self) -> BTreeMap<&str, usize> {
657
        let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
658
        for j in self.order() {
659
            let d = j.depends_on.iter().map(|x| depth.get(x.as_str()).copied().unwrap_or(0) + 1).max().unwrap_or(0);
660
            depth.insert(j.name.as_str(), d);
661
        }
662
        depth
663
    }
664
}
665
666
fn find_cycle<'a>(graph: &BTreeMap<&'a str, Vec<&'a str>>) -> Option<Vec<&'a str>> {
667
    #[derive(Clone, Copy, PartialEq)]
668
    enum Mark {
669
        Visiting,
670
        Done,
671
    }
672
    fn visit<'a>(
673
        n: &'a str,
674
        graph: &BTreeMap<&'a str, Vec<&'a str>>,
675
        marks: &mut BTreeMap<&'a str, Mark>,
676
        stack: &mut Vec<&'a str>,
677
    ) -> Option<Vec<&'a str>> {
678
        match marks.get(n) {
679
            Some(Mark::Done) => return None,
680
            Some(Mark::Visiting) => {
681
                let at = stack.iter().position(|s| *s == n).unwrap_or(0);
682
                let mut c = stack[at..].to_vec();
683
                c.push(n);
684
                return Some(c);
685
            }
686
            None => {}
687
        }
688
        marks.insert(n, Mark::Visiting);
689
        stack.push(n);
690
        for d in graph.get(n).into_iter().flatten() {
691
            if graph.contains_key(d) && *d != n {
692
                if let Some(c) = visit(d, graph, marks, stack) {
693
                    return Some(c);
694
                }
695
            }
696
        }
697
        stack.pop();
698
        marks.insert(n, Mark::Done);
699
        None
700
    }
701
    let mut marks = BTreeMap::new();
702
    for n in graph.keys() {
703
        let mut stack = Vec::new();
704
        if let Some(c) = visit(n, graph, &mut marks, &mut stack) {
705
            return Some(c);
706
        }
707
    }
708
    None
709
}
710
711
fn block<'a>(ctx: &mut Ctx, node: &'a KdlNode) -> &'a [KdlNode] {
712
    match node.children() {
713
        Some(d) => d.nodes(),
714
        None => {
715
            ctx.err(node.span(), format!("`{}` needs a block {{ … }}", node.name().value()));
716
            &[]
717
        }
718
    }
719
}
720
721
fn parse_sandbox(ctx: &mut Ctx, node: &KdlNode) -> Option<Sandbox> {
722
    let name = ctx.one_string(node)?;
723
    let named = ctx.name_ok(node.span(), "sandbox", &name);
724
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
725
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
726
    let mut seen = BTreeSet::new();
727
    let mut env = BTreeMap::new();
728
    let mut runs_on = Vec::new();
729
    let mut timeout_s = None;
730
    let mut arch: Option<String> = None;
731
    let mut egress_allow = Vec::new();
732
    for c in block(ctx, node) {
733
        let key = c.name().value();
734
        if key != "runs-on" && !seen.insert(key.to_string()) {
735
            ctx.err(c.span(), format!("`{key}` is given twice"));
736
        }
737
        if key == "env" {
738
            parse_env(ctx, c, &mut env);
739
            continue;
740
        }
741
        if key == "runs-on" {
742
            ctx.no_children(c);
743
            parse_labels(ctx, c, &mut runs_on);
744
            continue;
745
        }
746
        if key != "network" {
747
            ctx.no_children(c);
748
        }
749
        match key {
750
            "runtime" => {
751
                runtime = match ctx.one_string(c).as_deref() {
752
                    Some("microvm") => Some(Runtime::Microvm),
753
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
754
                    Some("wasm") => {
755
                        ctx.err(c.span(), "runtime \"wasm\" is not in M0 yet; use \"microvm\" or \"syscall\"");
756
                        None
757
                    }
758
                    Some(other) => {
759
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
760
                        None
761
                    }
762
                    None => None,
763
                }
764
            }
765
            "rootfs" => {
766
                rootfs = match ctx.one_string(c) {
767
                    Some(r) => match r.strip_prefix("cas://blob/") {
768
                        Some(h) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) => {
769
                            Some(h.to_string())
770
                        }
771
                        _ => {
772
                            ctx.err(c.span(), format!("rootfs {r:?}: expected \"cas://blob/\" and 64 lower-case hex digits"));
773
                            None
774
                        }
775
                    },
776
                    None => None,
777
                }
778
            }
779
            "arch" => {
780
                arch = match ctx.one_string(c) {
781
                    Some(a) if arch_ok(&a) => Some(a),
782
                    Some(a) => {
783
                        ctx.err(c.span(), format!("arch {a:?} is not an architecture name such as x86_64, aarch64 or riscv64"));
784
                        None
785
                    }
786
                    None => None,
787
                }
788
            }
789
            "memory-limit" => {
790
                memory = match ctx.one_string(c) {
791
                    Some(m) => match parse_memory(&m) {
792
                        Some(mib) if (MIN_MEMORY_MIB..=MAX_MEMORY_MIB).contains(&mib) => Some(mib as u32),
793
                        Some(_) => {
794
                            ctx.err(c.span(), format!("memory-limit {m:?} must be from 64MiB to 16GiB"));
795
                            None
796
                        }
797
                        None => {
798
                            ctx.err(c.span(), format!("memory-limit {m:?}: expected a whole number of KiB, MiB or GiB, as \"4GiB\""));
799
                            None
800
                        }
801
                    },
802
                    None => None,
803
                }
804
            }
805
            "cpu-limit" => {
806
                cpus = match ctx.one_integer(c) {
807
                    Some(n) if (1..=MAX_CPUS).contains(&n) => Some(n as u32),
808
                    Some(n) => {
809
                        ctx.err(c.span(), format!("cpu-limit {n} must be from 1 to {MAX_CPUS}"));
810
                        None
811
                    }
812
                    None => None,
813
                }
814
            }
815
            "network" => {
816
                let kind = ctx.one_string(c);
817
                if kind.as_deref() != Some("egress") {
818
                    ctx.no_children(c);
819
                }
820
                network = match kind.as_deref() {
821
                    Some("isolated") => Some(Network::Isolated),
822
                    // Loopback only: the guest's `lo` comes up, and it still has
823
                    // no device that reaches anything outside it.
824
                    Some("loopback") => Some(Network::Loopback),
825
                    // Allow-listed egress (oxygenflow#55): still no network
826
                    // device; a proxy in the guest relays to the runner, which
827
                    // lets through only the hosts named here.
828
                    Some("egress") => {
829
                        egress_allow = parse_egress(ctx, c);
830
                        Some(Network::Egress)
831
                    }
832
                    Some(other) => {
833
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
834
                        None
835
                    }
836
                    None => None,
837
                }
838
            }
839
            "timeout" => timeout_s = parse_timeout(ctx, c),
840
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
841
        }
842
    }
843
    if rootfs.is_none() && !seen.contains("rootfs") {
844
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
845
    }
846
    if network == Some(Network::Egress) {
847
        if runtime == Some(Runtime::Syscall) {
848
            ctx.err(node.span(), format!("sandbox {name:?}: network \"egress\" needs runtime \"microvm\""));
849
        }
850
        // Only a runner that allows egress offers this label.
851
        if !runs_on.iter().any(|l| l == EGRESS_LABEL) {
852
            runs_on.push(EGRESS_LABEL.to_string());
853
            runs_on.sort();
854
        }
855
    }
856
    if !named {
857
        return None;
858
    }
859
    Some(Sandbox {
860
        name,
861
        runtime: runtime?,
862
        rootfs: rootfs?,
863
        memory_mib: memory?,
864
        cpus: cpus?,
865
        network: network?,
866
        env,
867
        runs_on,
868
        egress_allow,
869
        timeout_s,
870
        arch,
871
    })
872
}
873
874
/// The label a runner offers when it allows egress, and an egress sandbox
875
/// needs (oxygenflow#55).
876
pub const EGRESS_LABEL: &str = "egress";
877
878
/// The most hosts one sandbox may name.
879
const MAX_EGRESS_HOSTS: usize = 32;
880
881
/// Whether `pattern` is a host name, or `*.` and a domain: lower-case
882
/// letters, digits, `-` and dots, at least two labels.
883
pub fn egress_pattern_ok(pattern: &str) -> bool {
884
    let name = pattern.strip_prefix("*.").unwrap_or(pattern);
885
    let labels: Vec<&str> = name.split('.').collect();
886
    name.len() <= 253
887
        && labels.len() >= 2
888
        && labels.iter().all(|l| {
889
            !l.is_empty()
890
                && l.len() <= 63
891
                && !l.starts_with('-')
892
                && !l.ends_with('-')
893
                && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
894
        })
895
}
896
897
/// Whether `host` is one `patterns` allow: a name exactly, or a name under
898
/// a `*.` domain (not the domain itself). Case and a final dot are ignored.
899
pub fn egress_allows(patterns: &[String], host: &str) -> bool {
900
    let host = host.trim_end_matches('.').to_ascii_lowercase();
901
    patterns.iter().any(|p| match p.strip_prefix("*.") {
902
        Some(domain) => host.len() > domain.len() + 1 && host.ends_with(domain) && host[..host.len() - domain.len()].ends_with('.'),
903
        None => host == *p,
904
    })
905
}
906
907
/// `network "egress" { allow "*.googleapis.com" "registry.opentofu.org" }`.
908
fn parse_egress(ctx: &mut Ctx, node: &KdlNode) -> Vec<String> {
909
    let mut out = Vec::new();
910
    for c in block(ctx, node) {
911
        ctx.no_children(c);
912
        if c.name().value() != "allow" {
913
            ctx.err(c.span(), format!("unknown egress setting `{}`; egress takes `allow \"host\"...`", c.name().value()));
914
            continue;
915
        }
916
        for h in ctx.strings(c) {
917
            if egress_pattern_ok(&h) {
918
                out.push(h);
919
            } else {
920
                ctx.err(
921
                    c.span(),
922
                    format!("allow {h:?}: expected a lower-case host name such as \"registry.opentofu.org\", or \"*.\" and a domain"),
923
                );
924
            }
925
        }
926
    }
927
    out.sort();
928
    out.dedup();
929
    if out.is_empty() {
930
        ctx.err(node.span(), "network \"egress\" needs at least one `allow \"host\"`");
931
    } else if out.len() > MAX_EGRESS_HOSTS {
932
        ctx.err(node.span(), format!("network \"egress\" may allow at most {MAX_EGRESS_HOSTS} hosts"));
933
    }
934
    out
935
}
936
937
/// An `on` block: `push` and `tag` with globs (none: any), `manual`,
938
/// `schedule` with five-field cron lines.
939
fn parse_on(ctx: &mut Ctx, node: &KdlNode) -> On {
940
    let mut on = On::default();
941
    if !node.entries().is_empty() {
942
        ctx.err(node.span(), "`on` takes a block: on { push \"main\"; tag \"v*\"; manual }");
943
    }
944
    let globs = |ctx: &mut Ctx, c: &KdlNode| -> Vec<String> {
945
        let g = ctx.strings(c);
946
        for p in &g {
947
            if let Err(e) = globset::Glob::new(p) {
948
                ctx.err(c.span(), format!("{p:?}: {e}"));
949
            }
950
        }
951
        g
952
    };
953
    for c in block(ctx, node) {
954
        ctx.no_children(c);
955
        match c.name().value() {
956
            "push" => on.push.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
957
            "tag" => on.tag.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
958
            "manual" => on.manual = true,
959
            "schedule" => {
960
                for line in ctx.strings(c) {
961
                    if crate::cron::Cron::parse(&line).is_some() {
962
                        on.schedule.push(line);
963
                    } else {
964
                        ctx.err(
965
                            c.span(),
966
                            format!("schedule {line:?}: expected five cron fields (minute hour day month weekday), as \"0 3 * * *\""),
967
                        );
968
                    }
969
                }
970
            }
971
            other => ctx.err(c.span(), format!("unknown `on` event `{other}`; expected push, tag, manual or schedule")),
972
        }
973
    }
974
    if on.push.is_none() && on.tag.is_none() && !on.manual && on.schedule.is_empty() {
975
        ctx.err(node.span(), "`on` names no event, so nothing would run it");
976
    }
977
    on
978
}
979
980
/// The longest a pipeline may ask a job to run.
981
pub const MAX_TIMEOUT_S: u64 = 24 * 3600;
982
983
/// A duration: whole hours, minutes and seconds in that order, each
984
/// optional, as "90m", "2h", "1h30m" or "45s".
985
pub fn parse_duration(s: &str) -> Option<u64> {
986
    let (mut total, mut num, mut last) = (0u64, String::new(), 0u8);
987
    for ch in s.chars() {
988
        if ch.is_ascii_digit() {
989
            num.push(ch);
990
            continue;
991
        }
992
        let (unit, mult) = match ch {
993
            'h' => (1, 3600),
994
            'm' => (2, 60),
995
            's' => (3, 1),
996
            _ => return None,
997
        };
998
        // Each unit once, largest first, each with its number.
999
        if num.is_empty() || unit <= last {
1000
            return None;
1001
        }
1002
        last = unit;
1003
        total = total.checked_add(num.parse::<u64>().ok()?.checked_mul(mult)?)?;
1004
        num.clear();
1005
    }
1006
    (num.is_empty() && total > 0).then_some(total)
1007
}
1008
1009
/// A `timeout` line: one duration, from a minute to a day.
1010
fn parse_timeout(ctx: &mut Ctx, c: &KdlNode) -> Option<u64> {
1011
    let t = ctx.one_string(c)?;
1012
    match parse_duration(&t) {
1013
        Some(secs) if (60..=MAX_TIMEOUT_S).contains(&secs) => Some(secs),
1014
        Some(_) => {
1015
            ctx.err(c.span(), format!("timeout {t:?} must be from 1m to 24h"));
1016
            None
1017
        }
1018
        None => {
1019
            ctx.err(c.span(), format!("timeout {t:?}: expected hours, minutes and seconds, as \"90m\" or \"1h30m\""));
1020
            None
1021
        }
1022
    }
1023
}
1024
1025
/// The labels of a `runs-on` line: lower-case letters, digits, `-`, `_`
1026
/// and `.`, at most 64 bytes each; into `into`, sorted, once each.
1027
fn parse_labels(ctx: &mut Ctx, c: &KdlNode, into: &mut Vec<String>) {
1028
    let labels = ctx.strings(c);
1029
    if labels.is_empty() {
1030
        ctx.err(c.span(), "`runs-on` needs one or more labels, as runs-on \"riscv64\"");
1031
    }
1032
    for l in labels {
1033
        let ok = !l.is_empty()
1034
            && l.len() <= 64
1035
            && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.'));
1036
        if ok {
1037
            into.push(l);
1038
        } else {
1039
            ctx.err(c.span(), format!("runs-on {l:?}: a label is lower-case letters, digits, -, _ and ., at most 64"));
1040
        }
1041
    }
1042
    into.sort();
1043
    into.dedup();
1044
}
1045
1046
/// An `env` block of `NAME "value"` lines, into `env`.
1047
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
1048
    if !c.entries().is_empty() {
1049
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
1050
    }
1051
    for e in block(ctx, c) {
1052
        let k = e.name().value().to_string();
1053
        let valid =
1054
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
1055
        if !valid {
1056
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
1057
            continue;
1058
        }
1059
        ctx.no_children(e);
1060
        if let Some(v) = ctx.one_string(e) {
1061
            if env.insert(k.clone(), v).is_some() {
1062
                ctx.err(e.span(), format!("environment {k} is set twice"));
1063
            }
1064
        }
1065
    }
1066
}
1067
1068
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
1069
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
1070
    let (mut source, mut target, mut size) = (None, None, None);
1071
    let (mut cache, mut keys) = (None, Vec::new());
1072
    let mut seen = BTreeSet::new();
1073
    for c in block(ctx, node) {
1074
        let key = c.name().value();
1075
        if !seen.insert(key.to_string()) {
1076
            ctx.err(c.span(), format!("`{key}` is given twice"));
1077
            continue;
1078
        }
1079
        ctx.no_children(c);
1080
        match key {
1081
            "source" => {
1082
                source = match ctx.one_string(c).as_deref() {
1083
                    Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs),
1084
                    Some("ephemeral://disk") => Some(MountSource::Disk),
1085
                    Some(other) => {
1086
                        ctx.err(
1087
                            c.span(),
1088
                            format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""),
1089
                        );
1090
                        None
1091
                    }
1092
                    None => None,
1093
                }
1094
            }
1095
            "target" => {
1096
                target = match ctx.one_string(c) {
1097
                    Some(t) if mount_target_ok(&t) => Some(t),
1098
                    Some(t) => {
1099
                        ctx.err(
1100
                            c.span(),
1101
                            format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"),
1102
                        );
1103
                        None
1104
                    }
1105
                    None => None,
1106
                }
1107
            }
1108
            "size" => {
1109
                size = match ctx.one_string(c) {
1110
                    Some(m) => match parse_memory(&m) {
1111
                        Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32),
1112
                        Some(_) => {
1113
                            ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB"));
1114
                            None
1115
                        }
1116
                        None => {
1117
                            ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\""));
1118
                            None
1119
                        }
1120
                    },
1121
                    None => None,
1122
                }
1123
            }
1124
            "cache" => {
1125
                cache = match ctx.one_string(c) {
1126
                    Some(n) if !n.is_empty() && n.len() <= 64 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) => {
1127
                        Some(n)
1128
                    }
1129
                    Some(n) => {
1130
                        ctx.err(c.span(), format!("cache name {n:?}: letters, digits, '.', '_' and '-', at most 64"));
1131
                        None
1132
                    }
1133
                    None => None,
1134
                }
1135
            }
1136
            "key" => {
1137
                keys = ctx.strings(c);
1138
                if keys.is_empty() {
1139
                    ctx.err(c.span(), "`key` names one or more files, as \"Cargo.lock\"");
1140
                }
1141
                for k in &keys {
1142
                    if k.starts_with('/') || k.split('/').any(|p| p.is_empty() || p == "." || p == "..") {
1143
                        ctx.err(c.span(), format!("key file {k:?} must be a path in the repository, without . or .."));
1144
                    }
1145
                }
1146
            }
1147
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
1148
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
1149
        }
1150
    }
1151
    for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] {
1152
        if missing && !seen.contains(name) {
1153
            ctx.err(node.span(), format!("a mount needs a {name}"));
1154
        }
1155
    }
1156
    if source == Some(MountSource::Tmpfs) && cache.is_some() {
1157
        ctx.err(node.span(), "only an ephemeral://disk mount can be a cache");
1158
    }
1159
    if !keys.is_empty() && cache.is_none() {
1160
        ctx.err(node.span(), "`key` goes with `cache`");
1161
    }
1162
    Some(Mount { source: source?, target: target?, size_mib: size?, cache, key: keys })
1163
}
1164
1165
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
1166
/// `secret "deploy-token" { mount-path "/run/secrets/token" }` (spec §9.1).
1167
/// The hub is the vault: the secret of that name set for the repository,
1168
/// its organisation or the site. `vault-ref` is not in this slice.
1169
fn parse_secret(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1170
    let Some(name) = ctx.one_string(node) else {
1171
        ctx.err(node.span(), "`secret` takes a name and a block with mount-path");
1172
        return;
1173
    };
1174
    if !ctx.name_ok(node.span(), "secret", &name) {
1175
        return;
1176
    }
1177
    let mut path = None;
1178
    for c in block(ctx, node) {
1179
        ctx.no_children(c);
1180
        match c.name().value() {
1181
            "mount-path" => match ctx.one_string(c) {
1182
                Some(p) if secret_path_ok(&p) => path = Some(p),
1183
                Some(p) => {
1184
                    ctx.err(c.span(), format!("secret mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
1185
                }
1186
                None => {}
1187
            },
1188
            "vault-ref" => ctx.err(c.span(), "`vault-ref` is not in M0; the hub supplies the secret of that name"),
1189
            other => ctx.err(c.span(), format!("unknown secret setting `{other}`")),
1190
        }
1191
    }
1192
    let Some(mount_path) = path else {
1193
        ctx.err(node.span(), format!("secret {name:?} needs a mount-path"));
1194
        return;
1195
    };
1196
    if job.secrets.iter().any(|s| s.name == name) {
1197
        ctx.err(node.span(), format!("secret {name:?} is given twice"));
1198
    } else if job.secrets.iter().any(|s| s.mount_path == mount_path) || job.identities.iter().any(|i| i.mount_path == mount_path) {
1199
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
1200
    } else if job.secrets.len() == MAX_SECRETS {
1201
        ctx.err(node.span(), format!("a job has at most {MAX_SECRETS} secrets"));
1202
    } else {
1203
        job.secrets.push(Secret { name, mount_path });
1204
    }
1205
}
1206
1207
/// `identity "sts.amazonaws.com" { mount-path "/run/secrets/aws-token" }`:
1208
/// the hub mints an OIDC token for that audience and the runner mounts it
1209
/// as a file (oxygenhub#103).
1210
fn parse_identity(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1211
    let Some(audience) = ctx.one_string(node) else {
1212
        ctx.err(node.span(), "`identity` takes an audience and a block with mount-path");
1213
        return;
1214
    };
1215
    let aud_ok = !audience.is_empty() && audience.len() <= 255 && !audience.chars().any(|c| c.is_whitespace() || c.is_control());
1216
    if !aud_ok {
1217
        ctx.err(node.span(), format!("identity audience {audience:?} must be 1 to 255 characters without spaces"));
1218
        return;
1219
    }
1220
    let mut path = None;
1221
    for c in block(ctx, node) {
1222
        ctx.no_children(c);
1223
        match c.name().value() {
1224
            "mount-path" => match ctx.one_string(c) {
1225
                Some(p) if secret_path_ok(&p) => path = Some(p),
1226
                Some(p) => {
1227
                    ctx.err(c.span(), format!("identity mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
1228
                }
1229
                None => {}
1230
            },
1231
            other => ctx.err(c.span(), format!("unknown identity setting `{other}`")),
1232
        }
1233
    }
1234
    let Some(mount_path) = path else {
1235
        ctx.err(node.span(), format!("identity {audience:?} needs a mount-path"));
1236
        return;
1237
    };
1238
    if job.identities.iter().any(|i| i.audience == audience) {
1239
        ctx.err(node.span(), format!("identity {audience:?} is given twice"));
1240
    } else if job.identities.iter().any(|i| i.mount_path == mount_path) || job.secrets.iter().any(|s| s.mount_path == mount_path) {
1241
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
1242
    } else if job.identities.len() == MAX_IDENTITIES {
1243
        ctx.err(node.span(), format!("a job has at most {MAX_IDENTITIES} identities"));
1244
    } else {
1245
        job.identities.push(Identity { audience, mount_path });
1246
    }
1247
}
1248
1249
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1250
    if !node.entries().is_empty() {
1251
        ctx.err(node.span(), "`output` takes a block of artifacts and reports");
1252
    }
1253
    for a in block(ctx, node) {
1254
        let kind = match a.name().value() {
1255
            "artifact" => OutputKind::Artifact,
1256
            "report" => OutputKind::Report,
1257
            other => {
1258
                ctx.err(
1259
                    a.span(),
1260
                    format!("unknown output `{other}`; outputs are `artifact \"name\" {{ path \"/src/file\" }}` and `report \"name\" {{ path \"/src/dir\" }}`"),
1261
                );
1262
                continue;
1263
            }
1264
        };
1265
        let word = kind.word();
1266
        let Some(name) = ctx.one_string(a) else { continue };
1267
        if !ctx.name_ok(a.span(), word, &name) {
1268
            continue;
1269
        }
1270
        let mut path = None;
1271
        let mut always = false;
1272
        for c in block(ctx, a) {
1273
            ctx.no_children(c);
1274
            match c.name().value() {
1275
                "path" => match ctx.one_string(c) {
1276
                    Some(p) if output_path_ok(&p) => path = Some(p),
1277
                    Some(p) => ctx
1278
                        .err(c.span(), format!("{word} path {p:?} must be an absolute path under /src, without . or .., quotes or spaces")),
1279
                    None => {}
1280
                },
1281
                "format" if kind == OutputKind::Artifact => {
1282
                    if ctx.one_string(c).as_deref() != Some("blob") {
1283
                        ctx.err(c.span(), "artifact format is \"blob\"");
1284
                    }
1285
                }
1286
                "when" => match ctx.one_string(c).as_deref() {
1287
                    Some("passed") => always = false,
1288
                    Some("always") => always = true,
1289
                    Some(_) => ctx.err(c.span(), format!("{word} `when` is \"passed\" (the default) or \"always\"")),
1290
                    None => {}
1291
                },
1292
                other => ctx.err(c.span(), format!("unknown {word} setting `{other}`")),
1293
            }
1294
        }
1295
        let Some(path) = path else {
1296
            ctx.err(a.span(), format!("{word} {name:?} needs a path"));
1297
            continue;
1298
        };
1299
        if job.outputs.iter().any(|o| o.name == name) {
1300
            ctx.err(a.span(), format!("output {name:?} is given twice (artifacts and reports share names)"));
1301
        } else if job.outputs.len() == MAX_OUTPUTS {
1302
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports"));
1303
        } else {
1304
            job.outputs.push(Output { name, path, always, kind });
1305
        }
1306
    }
1307
}
1308
1309
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
1310
fn parse_input(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1311
    if !node.entries().is_empty() {
1312
        ctx.err(node.span(), "`input` takes a block: input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\" }");
1313
    }
1314
    let (mut artifact, mut from, mut path) = (None, None, None);
1315
    for c in block(ctx, node) {
1316
        ctx.no_children(c);
1317
        match c.name().value() {
1318
            "artifact" => match ctx.strings(c).as_slice() {
1319
                [name, word, producer] if word == "from" => {
1320
                    if ctx.name_ok(c.span(), "artifact", name) && ctx.name_ok(c.span(), "job", producer) {
1321
                        artifact = Some(name.clone());
1322
                        from = Some(producer.clone());
1323
                    }
1324
                }
1325
                _ => ctx.err(c.span(), "`artifact` names the artifact and its job: artifact \"tfplan\" from \"plan\""),
1326
            },
1327
            "path" => match ctx.one_string(c) {
1328
                Some(p) if output_path_ok(&p) => path = Some(p),
1329
                Some(p) => {
1330
                    ctx.err(c.span(), format!("input path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"))
1331
                }
1332
                None => {}
1333
            },
1334
            other => ctx.err(c.span(), format!("unknown input setting `{other}`; an input has `artifact` and `path`")),
1335
        }
1336
    }
1337
    match (artifact, from, path) {
1338
        (Some(artifact), Some(from), Some(path)) => {
1339
            if job.inputs.iter().any(|i| i.path == path) {
1340
                ctx.err(node.span(), format!("two inputs are put at {path:?}"));
1341
            } else if job.inputs.len() == MAX_INPUTS {
1342
                ctx.err(node.span(), format!("a job takes at most {MAX_INPUTS} inputs"));
1343
            } else {
1344
                job.inputs.push(Input { artifact, from, path });
1345
            }
1346
        }
1347
        (a, _, p) => {
1348
            if a.is_some() && p.is_none() {
1349
                ctx.err(node.span(), "an input needs a `path`");
1350
            } else if a.is_none() && p.is_some() {
1351
                ctx.err(node.span(), "an input needs `artifact \"NAME\" from \"JOB\"`");
1352
            }
1353
        }
1354
    }
1355
}
1356
1357
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
1358
    let name = ctx.one_string(node)?;
1359
    let named = ctx.name_ok(node.span(), "job", &name);
1360
    let mut job = Job {
1361
        name: name.clone(),
1362
        sandbox: String::new(),
1363
        scope: vec![],
1364
        depends_on: vec![],
1365
        env: BTreeMap::new(),
1366
        exec: vec![],
1367
        mounts: vec![],
1368
        secrets: vec![],
1369
        identities: vec![],
1370
        outputs: vec![],
1371
        inputs: vec![],
1372
        runs_on: vec![],
1373
        timeout_s: None,
1374
        on: None,
1375
    };
1376
    let mut seen = BTreeSet::new();
1377
    let mut outputs_at = None;
1378
    for c in block(ctx, node) {
1379
        let key = c.name().value();
1380
        if key != "scope"
1381
            && key != "depends-on"
1382
            && key != "mount"
1383
            && key != "secret"
1384
            && key != "identity"
1385
            && key != "runs-on"
1386
            && !seen.insert(key.to_string())
1387
        {
1388
            ctx.err(c.span(), format!("`{key}` is given twice"));
1389
        }
1390
        match key {
1391
            "sandbox" => {
1392
                ctx.no_children(c);
1393
                job.sandbox = ctx.one_string(c).unwrap_or_default();
1394
            }
1395
            "scope" => {
1396
                ctx.no_children(c);
1397
                for g in ctx.strings(c) {
1398
                    match globset::Glob::new(&g) {
1399
                        Ok(_) => job.scope.push(g),
1400
                        Err(e) => ctx.err(c.span(), format!("scope {g:?}: {e}")),
1401
                    }
1402
                }
1403
            }
1404
            "depends-on" => {
1405
                ctx.no_children(c);
1406
                job.depends_on.extend(ctx.strings(c));
1407
            }
1408
            "runs-on" => {
1409
                ctx.no_children(c);
1410
                parse_labels(ctx, c, &mut job.runs_on);
1411
            }
1412
            "timeout" => {
1413
                ctx.no_children(c);
1414
                job.timeout_s = parse_timeout(ctx, c);
1415
            }
1416
            "on" => job.on = Some(parse_on(ctx, c)),
1417
            "env" => parse_env(ctx, c, &mut job.env),
1418
            "exec" => {
1419
                ctx.no_children(c);
1420
                job.exec = ctx.strings(c);
1421
                if job.exec.is_empty() {
1422
                    ctx.err(c.span(), "`exec` needs a program and its arguments");
1423
                } else if job.exec.iter().any(|a| a.contains('\0')) {
1424
                    ctx.err(c.span(), "`exec` arguments cannot contain NUL");
1425
                }
1426
            }
1427
            "mount" => {
1428
                if !c.entries().is_empty() {
1429
                    ctx.err(c.span(), "`mount` takes a block of source, target and size");
1430
                }
1431
                if let Some(m) = parse_mount(ctx, c) {
1432
                    if job.mounts.len() == MAX_MOUNTS {
1433
                        ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts"));
1434
                    } else if let Some(other) = job.mounts.iter().find(|o| {
1435
                        o.target == m.target
1436
                            || m.target.starts_with(&format!("{}/", o.target))
1437
                            || o.target.starts_with(&format!("{}/", m.target))
1438
                    }) {
1439
                        ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target));
1440
                    } else {
1441
                        job.mounts.push(m);
1442
                    }
1443
                }
1444
            }
1445
            "output" => {
1446
                outputs_at = Some(c.span());
1447
                parse_output(ctx, c, &mut job);
1448
            }
1449
            "input" => parse_input(ctx, c, &mut job),
1450
            "secret" => parse_secret(ctx, c, &mut job),
1451
            "identity" => parse_identity(ctx, c, &mut job),
1452
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
1453
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
1454
        }
1455
    }
1456
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1457
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1458
    job.inputs.sort_by(|a, b| a.path.cmp(&b.path));
1459
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
1460
    job.identities.sort_by(|a, b| a.audience.cmp(&b.audience));
1461
    // A tmpfs is gone when the job ends; nothing on it can be kept.
1462
    for o in &job.outputs {
1463
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
1464
            ctx.err(
1465
                outputs_at.unwrap_or(node.span()),
1466
                format!(
1467
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
1468
                    o.name, m.target
1469
                ),
1470
            );
1471
        }
1472
    }
1473
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
1474
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
1475
    }
1476
    if job.exec.is_empty() && !seen.contains("exec") {
1477
        ctx.err(node.span(), format!("job {name:?} needs an exec"));
1478
    }
1479
    job.depends_on.sort();
1480
    job.depends_on.dedup();
1481
    // Kept even when incomplete, so its references are checked too; the
1482
    // problems above already make the pipeline invalid.
1483
    named.then_some(job)
1484
}