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