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