OXYGENHUB
# 720eb8191059 oxygenflow / crates / flow-core / src / tests.rs
23.1 KiB · Rust · last changed A job's identity: identity blocks name an audience and a mount path 1 month ago source blame history raw
1
use crate::*;
2
3
const ROOTFS: &str = "9b5c2a1e8f3d4e7a6b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a";
4
5
fn doc(body: &str) -> String {
6
    format!(
7
        "schema-version 1\n\nsandbox \"alpine\" {{\n    runtime \"microvm\"\n    rootfs \"cas://blob/{ROOTFS}\"\n    memory-limit \"512MiB\"\n    cpu-limit 2\n    network \"isolated\"\n}}\n\n{body}"
8
    )
9
}
10
11
fn problems(src: &str) -> Vec<(usize, String)> {
12
    Pipeline::parse(src).unwrap_err().0.into_iter().map(|p| (p.line, p.message)).collect()
13
}
14
15
#[test]
16
fn parses_the_m0_example() {
17
    let p = Pipeline::parse(&doc(r#"job "lint" {
18
    sandbox "alpine"
19
    scope "src/**" "README.md"
20
    env {
21
        LANG "C.UTF-8"
22
    }
23
    exec "grep" "-rn" "TODO" "src"
24
}
25
26
job "test" {
27
    sandbox "alpine"
28
    depends-on "lint"
29
    exec "sh" "-eu" "test.sh"
30
}
31
"#))
32
    .unwrap();
33
    let sb = &p.sandboxes["alpine"];
34
    assert_eq!((sb.runtime, sb.memory_mib, sb.cpus, sb.network), (Runtime::Microvm, 512, 2, Network::Isolated));
35
    assert_eq!(sb.rootfs, ROOTFS);
36
    let lint = &p.jobs["lint"];
37
    assert_eq!(lint.exec, ["grep", "-rn", "TODO", "src"]);
38
    assert_eq!(lint.scope, ["src/**", "README.md"]);
39
    assert_eq!(lint.env["LANG"], "C.UTF-8");
40
    assert_eq!(p.jobs["test"].depends_on, ["lint"]);
41
    assert_eq!(p.order().iter().map(|j| j.name.as_str()).collect::<Vec<_>>(), ["lint", "test"]);
42
    assert_eq!(p.depths()["test"], 1);
43
}
44
45
#[test]
46
fn defaults_and_gib() {
47
    let p = Pipeline::parse(&format!(
48
        "schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  memory-limit \"2GiB\"\n}}\njob \"a\" {{\n  sandbox \"s\"\n  exec \"true\"\n}}\n"
49
    ))
50
    .unwrap();
51
    let s = &p.sandboxes["s"];
52
    assert_eq!((s.runtime, s.memory_mib, s.cpus, s.network), (Runtime::Microvm, 2048, 1, Network::Isolated));
53
}
54
55
#[test]
56
fn shell_metacharacters_stay_literal_argv() {
57
    // Spec §10.1: every byte reaches argv as written; nothing adds a shell.
58
    let p = Pipeline::parse(&doc(r#"job "x" { sandbox "alpine"; exec "echo" "a; rm -rf / && $(id) | `x`" "b\nc"; }"#)).unwrap();
59
    assert_eq!(p.jobs["x"].exec, ["echo", "a; rm -rf / && $(id) | `x`", "b\nc"]);
60
}
61
62
#[test]
63
fn problems_carry_lines_and_are_all_reported() {
64
    let src = doc(r#"job "a" {
65
    sandbox "missing"
66
    exec "true"
67
    mount { source "oxy://workspace"; }
68
}
69
job "b" {
70
    sandbox "alpine"
71
    depends-on "nope"
72
    exec
73
}
74
flavour "x"
75
"#);
76
    let ps = problems(&src);
77
    let has = |line: usize, text: &str| ps.iter().any(|(l, m)| *l == line && m.contains(text));
78
    assert!(has(11, "which is not declared") && has(11, "\"missing\""), "{ps:?}");
79
    assert!(has(14, "mount source \"oxy://workspace\" is not in M0 yet"), "{ps:?}");
80
    assert!(has(16, "\"nope\""), "{ps:?}");
81
    assert!(has(19, "`exec` needs a program"), "{ps:?}");
82
    assert!(has(21, "unknown node `flavour`"), "{ps:?}");
83
}
84
85
#[test]
86
fn typed_values_are_not_coerced() {
87
    let src = format!(
88
        "schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  cpu-limit \"2\"\n  memory-limit \"1.5GiB\"\n}}\njob \"a\" {{\n  sandbox \"s\"\n  exec \"echo\" 1\n}}\n"
89
    );
90
    let ps = problems(&src);
91
    assert!(ps.iter().any(|(l, m)| *l == 4 && m.contains("integer, not a string")), "{ps:?}");
92
    assert!(ps.iter().any(|(l, m)| *l == 5 && m.contains("whole number")), "{ps:?}");
93
    assert!(ps.iter().any(|(l, m)| *l == 9 && m.contains("strings, not an integer")), "{ps:?}");
94
}
95
96
#[test]
97
fn m0_refuses_what_it_cannot_honour() {
98
    let src = format!("schema-version 1\nsandbox \"s\" {{\n  runtime \"wasm\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"egress\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n");
99
    let ps = problems(&src);
100
    assert!(ps.iter().any(|(l, m)| *l == 3 && m.contains("wasm")), "{ps:?}");
101
    assert!(ps.iter().any(|(l, m)| *l == 5 && m.contains("isolated")), "{ps:?}");
102
    assert!(problems("schema-version 2\n").iter().any(|(_, m)| m.contains("schema-version 2")));
103
    assert!(problems(&format!(
104
        "schema-version 1\nsandbox \"s\" {{ rootfs \"cas://blob/{}\"; }}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}",
105
        &ROOTFS[..60]
106
    ))
107
    .iter()
108
    .any(|(_, m)| m.contains("64 lower-case hex")));
109
}
110
111
#[test]
112
fn cycles_are_found() {
113
    let ps = problems(&doc(
114
        "job \"a\" { sandbox \"alpine\"; depends-on \"c\"; exec \"true\"; }\njob \"b\" { sandbox \"alpine\"; depends-on \"a\"; exec \"true\"; }\njob \"c\" { sandbox \"alpine\"; depends-on \"b\"; exec \"true\"; }\njob \"d\" { sandbox \"alpine\"; depends-on \"d\"; exec \"true\"; }\n",
115
    ));
116
    assert!(ps.iter().any(|(_, m)| m.contains("cycle: a → c → b → a")), "{ps:?}");
117
    assert!(ps.iter().any(|(_, m)| m.contains("\"d\" depends on itself")), "{ps:?}");
118
}
119
120
#[test]
121
fn invalid_kdl_has_a_line() {
122
    let ps = problems("schema-version 1\njob \"a\" {\n  exec \"unterminated\n}\n");
123
    assert!(!ps.is_empty() && ps[0].0 >= 2, "{ps:?}");
124
}
125
126
#[test]
127
fn canonical_form_ignores_layout_and_order() {
128
    let a = Pipeline::parse(&doc(
129
        "job \"b\" { sandbox \"alpine\"; exec \"true\"; }\njob \"a\" {\n  sandbox \"alpine\"\n  depends-on \"b\"\n  exec \"x\"\n}\n",
130
    ))
131
    .unwrap();
132
    let b = Pipeline::parse(&doc("// comment\njob \"a\" { exec \"x\"; depends-on \"b\"; sandbox \"alpine\"; }\n\n\njob \"b\" { exec \"true\"; sandbox \"alpine\"; }\n")).unwrap();
133
    assert_eq!(a.canonical(), b.canonical());
134
    assert_eq!(a.hash(), b.hash());
135
    let c = Pipeline::parse(&doc(
136
        "job \"b\" { sandbox \"alpine\"; exec \"true\"; }\njob \"a\" { sandbox \"alpine\"; depends-on \"b\"; exec \"y\"; }\n",
137
    ))
138
    .unwrap();
139
    assert_ne!(a.hash(), c.hash());
140
    let sb = &a.sandboxes["alpine"];
141
    assert_ne!(a.jobs["a"].hash(sb), c.jobs["a"].hash(sb));
142
    assert_eq!(a.jobs["b"].hash(sb), c.jobs["b"].hash(sb), "an unchanged job keeps its hash");
143
    // The canonical form round-trips through Oxygen's strict decoder.
144
    oxy_core::cbor::decode(&a.canonical()).unwrap();
145
}
146
147
#[test]
148
fn scope_tree_hash_sees_only_the_scope() {
149
    let f = |b: u8| [b; 32];
150
    let scope = vec!["src/**".to_string()];
151
    let base = scope_tree_hash(&scope, [("src/a.rs", f(1)), ("README.md", f(2))]);
152
    assert_eq!(base, scope_tree_hash(&scope, [("README.md", f(9)), ("src/a.rs", f(1))]), "outside the scope, and order, do not count");
153
    assert_ne!(base, scope_tree_hash(&scope, [("src/a.rs", f(3)), ("README.md", f(2))]));
154
    assert_ne!(scope_tree_hash(&[], [("README.md", f(2))]), scope_tree_hash(&[], [("README.md", f(9))]), "no scope is the whole tree");
155
}
156
157
#[test]
158
fn receipts_hash_as_oxygen_objects() {
159
    let r = Receipt {
160
        job_name: "lint".into(),
161
        pipeline_hash: [1; 32],
162
        scope_tree_hash: [2; 32],
163
        inputs: vec![],
164
        environment: [3; 32],
165
        outputs: vec![],
166
        exit_code: 0,
167
        duration_ms: 1234,
168
    };
169
    let body = r.encode();
170
    let mut want = format!("oxy1 receipt {}\0", body.len()).into_bytes();
171
    want.extend_from_slice(&body);
172
    assert_eq!(r.hash(), *blake3::hash(&want).as_bytes());
173
    let json = serde_json::to_string(&r).unwrap();
174
    assert_eq!(serde_json::from_str::<Receipt>(&json).unwrap(), r);
175
    // The same receipt as the Oxygen object a runner signs.
176
    assert_eq!(r.to_oxy().hash().0, r.hash());
177
    assert_eq!(r.to_oxy().encode(), body);
178
    let mut r2 = r.clone();
179
    r2.exit_code = 1;
180
    assert_ne!(r.hash(), r2.hash());
181
}
182
183
#[test]
184
fn ephemeral_mounts_parse_sort_and_hash() {
185
    let plain = Pipeline::parse(&doc("job \"b\" {\n    sandbox \"alpine\"\n    exec \"cargo\" \"build\"\n}\n")).unwrap();
186
    let p = Pipeline::parse(&doc(r#"job "b" {
187
    sandbox "alpine"
188
    mount {
189
        source "ephemeral://tmpfs"
190
        target "/tmp/cache"
191
        size "256MiB"
192
    }
193
    mount {
194
        source "ephemeral://disk"
195
        target "/src/target"
196
        size "8GiB"
197
    }
198
    exec "cargo" "build"
199
}
200
"#))
201
    .unwrap();
202
    let b = &p.jobs["b"];
203
    assert_eq!(
204
        b.mounts,
205
        vec![
206
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192 },
207
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256 },
208
        ],
209
        "sorted by target"
210
    );
211
    assert_ne!(p.hash(), plain.hash(), "mounts are part of the job");
212
    // A job without mounts encodes as before mounts existed: no field 6.
213
    let v = oxy_core::cbor::decode(&plain.canonical()).unwrap();
214
    let text = format!("{v:?}");
215
    assert!(!text.contains("ephemeral"), "{text}");
216
    let json = serde_json::to_value(&plain.jobs["b"]).unwrap();
217
    assert!(json.get("mounts").is_none(), "absent from the API too");
218
    let back: Job = serde_json::from_value(json).unwrap();
219
    assert!(back.mounts.is_empty());
220
}
221
222
#[test]
223
fn mounts_are_checked() {
224
    let job = |mount: &str| doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n{mount}\n    }}\n    exec \"true\"\n}}\n"));
225
    let has = |src: String, want: &str| {
226
        let ps = problems(&src);
227
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{want:?} not in {ps:?}");
228
    };
229
    has(job("source \"oxy://workspace\"\ntarget \"/src/x\"\nsize \"1GiB\""), "not in M0 yet");
230
    has(job("source \"ephemeral://disk\"\ntarget \"/etc/x\"\nsize \"1GiB\""), "under /src or /tmp");
231
    has(job("source \"ephemeral://disk\"\ntarget \"/src\"\nsize \"1GiB\""), "under /src or /tmp");
232
    has(job("source \"ephemeral://disk\"\ntarget \"/src/../etc\"\nsize \"1GiB\""), "without . or ..");
233
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x/\"\nsize \"1GiB\""), "trailing /");
234
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"8MiB\""), "from 16MiB to 64GiB");
235
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"65GiB\""), "from 16MiB to 64GiB");
236
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"lots\""), "whole number");
237
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\""), "needs a size");
238
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"1GiB\"\nreadonly #true"), "`readonly` is not in M0 yet");
239
    has(job("source \"ephemeral://disk\"\nsource \"ephemeral://tmpfs\"\ntarget \"/src/x\"\nsize \"1GiB\""), "given twice");
240
    let two = |a: &str, b: &str| {
241
        doc(&format!(
242
            "job \"b\" {{\n    sandbox \"alpine\"\n    mount {{ source \"ephemeral://disk\"; target \"{a}\"; size \"1GiB\"; }}\n    mount {{ source \"ephemeral://disk\"; target \"{b}\"; size \"1GiB\"; }}\n    exec \"true\"\n}}\n"
243
        ))
244
    };
245
    has(two("/src/t", "/src/t/deps"), "overlaps");
246
    has(two("/src/t", "/src/t"), "overlaps");
247
    assert!(Pipeline::parse(&two("/src/t", "/src/tt")).is_ok(), "a shared prefix is not nesting");
248
    let five: String =
249
        (0..5).map(|i| format!("    mount {{ source \"ephemeral://tmpfs\"; target \"/tmp/m{i}\"; size \"16MiB\"; }}\n")).collect();
250
    has(doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n{five}    exec \"true\"\n}}\n")), "at most 4 mounts");
251
}
252
253
#[test]
254
fn loopback_is_in_m0_and_egress_is_not() {
255
    let src = |net: &str| {
256
        format!("schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"{net}\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n")
257
    };
258
    let p = Pipeline::parse(&src("loopback")).unwrap();
259
    assert_eq!(p.sandboxes["s"].network, Network::Loopback);
260
    assert_ne!(p.hash(), Pipeline::parse(&src("isolated")).unwrap().hash(), "the network is part of the sandbox");
261
    assert!(problems(&src("egress")).iter().any(|(l, m)| *l == 4 && m.contains("not in M0")));
262
}
263
264
#[test]
265
fn secrets_mount_as_files_and_are_part_of_the_job() {
266
    let plain = Pipeline::parse(&doc("job \"d\" {\n    sandbox \"alpine\"\n    exec \"deploy\"\n}\n")).unwrap();
267
    let p = Pipeline::parse(&doc(r#"job "d" {
268
    sandbox "alpine"
269
    secret "token" {
270
        mount-path "/run/secrets/token"
271
    }
272
    secret "aws" {
273
        mount-path "/run/secrets/aws/credentials"
274
    }
275
    exec "deploy"
276
}"#))
277
    .unwrap();
278
    let d = &p.jobs["d"];
279
    assert_eq!(
280
        d.secrets,
281
        vec![
282
            Secret { name: "aws".into(), mount_path: "/run/secrets/aws/credentials".into() },
283
            Secret { name: "token".into(), mount_path: "/run/secrets/token".into() },
284
        ],
285
        "sorted by name"
286
    );
287
    assert_ne!(p.hash(), plain.hash(), "secrets are part of the job");
288
    for (bad, why) in [
289
        ("secret \"t\" {\n    mount-path \"/tmp/t\"\n}", "under /run/secrets/"),
290
        ("secret \"t\" {\n    mount-path \"/run/secrets/../x\"\n}", "under /run/secrets/"),
291
        ("secret \"t\" {\n}", "needs a mount-path"),
292
        ("secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"t\" {\n    mount-path \"/run/secrets/b\"\n}", "given twice"),
293
        (
294
            "secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"u\" {\n    mount-path \"/run/secrets/a\"\n}",
295
            "two files mount at",
296
        ),
297
        ("secret \"t\" {\n    vault-ref \"x\"\n    mount-path \"/run/secrets/a\"\n}", "vault-ref"),
298
    ] {
299
        let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n    sandbox \"alpine\"\n    {bad}\n    exec \"deploy\"\n}}\n"))).unwrap_err();
300
        assert!(e.to_string().contains(why), "{bad}: {e}");
301
    }
302
}
303
304
#[test]
305
fn identities_are_asked_for_by_audience_and_mount_like_secrets() {
306
    let plain = Pipeline::parse(&doc("job \"d\" {\n    sandbox \"alpine\"\n    exec \"deploy\"\n}\n")).unwrap();
307
    let p = Pipeline::parse(&doc(r#"job "d" {
308
    sandbox "alpine"
309
    identity "sts.amazonaws.com" {
310
        mount-path "/run/secrets/aws-token"
311
    }
312
    identity "https://vault.example" {
313
        mount-path "/run/secrets/vault-token"
314
    }
315
    secret "key" {
316
        mount-path "/run/secrets/key"
317
    }
318
    exec "deploy"
319
}"#))
320
    .unwrap();
321
    let d = &p.jobs["d"];
322
    assert_eq!(
323
        d.identities.iter().map(|i| i.audience.as_str()).collect::<Vec<_>>(),
324
        ["https://vault.example", "sts.amazonaws.com"],
325
        "sorted"
326
    );
327
    assert_ne!(p.hash(), plain.hash(), "identities are part of the job");
328
    for (bad, why) in [
329
        ("identity \"a b\" {\n    mount-path \"/run/secrets/x\"\n}", "without spaces"),
330
        ("identity \"a\" {\n    mount-path \"/tmp/x\"\n}", "under /run/secrets/"),
331
        ("identity \"a\" {\n}", "needs a mount-path"),
332
        ("identity \"a\" {\n    mount-path \"/run/secrets/x\"\n}\nidentity \"a\" {\n    mount-path \"/run/secrets/y\"\n}", "given twice"),
333
        (
334
            "identity \"a\" {\n    mount-path \"/run/secrets/x\"\n}\nsecret \"s\" {\n    mount-path \"/run/secrets/x\"\n}",
335
            "two files mount at",
336
        ),
337
    ] {
338
        let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n    sandbox \"alpine\"\n    {bad}\n    exec \"deploy\"\n}}\n"))).unwrap_err();
339
        assert!(e.to_string().contains(why), "{bad}: {e}");
340
    }
341
}
342
343
#[test]
344
fn jobs_are_told_what_they_build() {
345
    let p = Pipeline::parse(&doc(r#"job "build" {
346
    sandbox "alpine"
347
    env {
348
        FLOW_BOOKMARK "pinned-by-the-pipeline"
349
    }
350
    exec "make"
351
}"#))
352
    .unwrap();
353
    let job = p.jobs["build"].clone();
354
    let claim = crate::api::Claim {
355
        job_id: 7,
356
        run_id: 40,
357
        repo: "andy/demo".into(),
358
        clone_url: "https://hub/andy/demo".into(),
359
        token: "t".into(),
360
        heads: vec!["aa".into(), "bb".into()],
361
        pipeline_hash: "00".into(),
362
        job: job.clone(),
363
        sandbox: p.sandboxes["alpine"].clone(),
364
        timeout_s: 60,
365
        subrepos: vec![],
366
        state: "cc".into(),
367
        bookmark: "feature/x".into(),
368
        run_number: 3,
369
        env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(),
370
        secrets: vec![],
371
    };
372
    let env = job.with_flow_env(&claim.flow_env()).env;
373
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
374
    assert_eq!(env["FLOW_REPO"], "andy/demo", "a hub variable cannot pose as a FLOW_ fact");
375
    assert_eq!(env["FLOW_HEADS"], "aa+bb");
376
    assert_eq!(env["FLOW_STATE"], "cc");
377
    assert_eq!(env["FLOW_RUN"], "3", "the run's number in its repository, not the hub's id");
378
    assert_eq!(env["FLOW_JOB"], "build");
379
    assert_eq!(env["FLOW_BOOKMARK"], "pinned-by-the-pipeline", "the pipeline's own value wins");
380
    // Unknown values (an older hub) are left out, not empty.
381
    let old = crate::api::Claim {
382
        state: String::new(),
383
        bookmark: String::new(),
384
        run_number: 0,
385
        env: Default::default(),
386
        secrets: vec![],
387
        ..claim
388
    };
389
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
390
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
391
}
392
393
#[test]
394
fn a_sandbox_env_is_where_jobs_start() {
395
    let src = |sandbox_env: &str| {
396
        format!(
397
            "schema-version 1\nsandbox \"mos\" {{\n    rootfs \"cas://blob/{ROOTFS}\"\n{sandbox_env}}}\njob \"build\" {{\n    sandbox \"mos\"\n    env {{\n        X16EMU \"/opt/job\"\n    }}\n    exec \"make\"\n}}\n"
398
        )
399
    };
400
    let with =
401
        Pipeline::parse(&src("    env {\n        PATH \"/opt/llvm-mos/bin:/usr/bin:/bin\"\n        X16EMU \"/opt/x16emu\"\n    }\n"))
402
            .unwrap();
403
    let sb = &with.sandboxes["mos"];
404
    assert_eq!(sb.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin");
405
    let job = with.jobs["build"].in_sandbox(sb);
406
    assert_eq!(job.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "from the sandbox");
407
    assert_eq!(job.env["X16EMU"], "/opt/job", "the job's own value wins");
408
    // The sandbox's environment is part of the pipeline's hash; a sandbox
409
    // without one hashes as before.
410
    let without = Pipeline::parse(&src("")).unwrap();
411
    assert!(without.sandboxes["mos"].env.is_empty());
412
    assert_ne!(with.hash(), without.hash());
413
    let bad = problems(&src("    env {\n        BAD-NAME \"x\"\n    }\n"));
414
    assert!(bad.iter().any(|(_, m)| m.contains("letters, digits and _")), "{bad:?}");
415
}
416
417
#[test]
418
fn artifacts_are_declared_checked_and_hashed() {
419
    let src = |out: &str| {
420
        doc(&format!(
421
            "job \"build\" {{\n    sandbox \"alpine\"\n    mount {{\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"1GiB\"\n    }}\n    mount {{\n        source \"ephemeral://tmpfs\"\n        target \"/src/tmp\"\n        size \"64MiB\"\n    }}\n    exec \"make\"\n{out}}}\n"
422
        ))
423
    };
424
    let p = Pipeline::parse(&src(
425
        "    output {\n        artifact \"tool\" {\n            path \"/src/target/release/tool\"\n            format \"blob\"\n        }\n        artifact \"docs\" {\n            path \"/src/docs/tool.1\"\n        }\n    }\n",
426
    ))
427
    .unwrap();
428
    let names: Vec<(&str, &str)> = p.jobs["build"].outputs.iter().map(|o| (o.name.as_str(), o.path.as_str())).collect();
429
    assert_eq!(names, [("docs", "/src/docs/tool.1"), ("tool", "/src/target/release/tool")], "sorted by name");
430
    // Artifacts are part of the job's hash; a job without them hashes as before.
431
    let plain = Pipeline::parse(&src("")).unwrap();
432
    let sb = &p.sandboxes["alpine"];
433
    assert_ne!(p.jobs["build"].hash(sb), plain.jobs["build"].hash(sb));
434
    assert!(plain.jobs["build"].outputs.is_empty());
435
    let json = serde_json::to_string(&plain.jobs["build"]).unwrap();
436
    assert!(!json.contains("outputs"), "no outputs field when there are none: {json}");
437
438
    let bad = |out: &str| problems(&src(out)).into_iter().map(|(_, m)| m).collect::<Vec<_>>().join("\n");
439
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/etc/passwd\"\n        }\n    }\n")
440
        .contains("must be an absolute path under /src"));
441
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/../etc\"\n        }\n    }\n")
442
        .contains("must be an absolute path under /src"));
443
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/tmp/a\"\n        }\n    }\n").contains("tmpfs"));
444
    assert!(bad("    output {\n        artifact \"x\" {\n        }\n    }\n").contains("needs a path"));
445
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n        }\n        artifact \"x\" {\n            path \"/src/b\"\n        }\n    }\n")
446
        .contains("given twice"));
447
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n            format \"tar\"\n        }\n    }\n")
448
        .contains("format"));
449
}
450
451
/// metalgear feedback 19 (oxygenflow#17): `\033` in a string is not KDL;
452
/// the problems say to write ESC as `\u{1b}`, which parses.
453
#[test]
454
fn ansi_escapes_are_written_as_unicode() {
455
    let bad = problems(&doc("job \"a\" {\n    sandbox \"alpine\"\n    exec \"printf\" \"\\033[32mok\\033[0m\"\n}\n"));
456
    assert!(bad.iter().any(|(line, m)| *line == 13 && m.contains("\\u{1b}")), "{bad:?}");
457
    let p = Pipeline::parse(&doc("job \"a\" {\n    sandbox \"alpine\"\n    exec \"printf\" \"\\u{1b}[32mok\\u{1b}[0m\"\n}\n")).unwrap();
458
    assert_eq!(p.jobs["a"].exec[1], "\u{1b}[32mok\u{1b}[0m");
459
}
460
461
#[test]
462
fn runs_on_asks_for_runner_labels() {
463
    let src = doc("job \"cross\" {\n    sandbox \"alpine\"\n    runs-on \"riscv64\" \"big-disk\"\n    exec \"make\"\n}\n")
464
        .replace("    network", "    runs-on \"gpu\"\n    network");
465
    let p = Pipeline::parse(&src).unwrap();
466
    let job = &p.jobs["cross"];
467
    assert_eq!(job.runs_on, vec!["big-disk", "riscv64"], "sorted");
468
    let sandbox = &p.sandboxes["alpine"];
469
    assert_eq!(sandbox.runs_on, vec!["gpu"]);
470
    assert_eq!(job.in_sandbox(sandbox).runs_on, vec!["big-disk", "gpu", "riscv64"], "with its sandbox's");
471
    // A pipeline without runs-on serialises as before, so its jobs' hashes stay.
472
    let plain = Pipeline::parse(&doc("job \"x\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\n")).unwrap();
473
    assert!(!serde_json::to_string(&plain.jobs["x"]).unwrap().contains("runs_on"));
474
    let bad = problems(&doc("job \"x\" {\n    sandbox \"alpine\"\n    runs-on \"RISCV\"\n    exec \"true\"\n}\n"));
475
    assert!(bad.iter().any(|(_, m)| m.contains("a label is lower-case")), "{bad:?}");
476
    let none = problems(&doc("job \"y\" {\n    sandbox \"alpine\"\n    runs-on\n    exec \"true\"\n}\n"));
477
    assert!(none.iter().any(|(_, m)| m.contains("needs one or more labels")), "{none:?}");
478
}
479
480
/// oxygenflow#23: `timeout` on a job, or on its sandbox for every job in
481
/// it, as a duration from a minute to a day.
482
#[test]
483
fn a_job_says_how_long_it_may_run() {
484
    use crate::pipeline::{parse_duration, Pipeline};
485
    assert_eq!(parse_duration("90m"), Some(5400));
486
    assert_eq!(parse_duration("1h30m"), Some(5400));
487
    assert_eq!(parse_duration("2h"), Some(7200));
488
    assert_eq!(parse_duration("45s"), Some(45));
489
    for bad in ["", "90", "m", "30m1h", "1h1h", "2d", "-1h"] {
490
        assert_eq!(parse_duration(bad), None, "{bad:?}");
491
    }
492
    let root = "a".repeat(64);
493
    let text = format!(
494
        "schema-version 1\nsandbox \"s\" {{\n    runtime \"microvm\"\n    rootfs \"cas://blob/{root}\"\n    memory-limit \"1GiB\"\n    cpu-limit 1\n    network \"isolated\"\n    timeout \"2h\"\n}}\njob \"long\" {{\n    sandbox \"s\"\n    timeout \"3h30m\"\n    exec \"true\"\n}}\njob \"plain\" {{\n    sandbox \"s\"\n    exec \"true\"\n}}\n"
495
    );
496
    let p = Pipeline::parse(&text).unwrap();
497
    let job = |n: &str| p.jobs[n].in_sandbox(&p.sandboxes["s"]);
498
    assert_eq!(job("long").timeout_s, Some(12600), "the job's own");
499
    assert_eq!(job("plain").timeout_s, Some(7200), "the sandbox's");
500
    let bad = text.replace("timeout \"3h30m\"", "timeout \"2d\"");
501
    assert!(Pipeline::parse(&bad).unwrap_err().to_string().contains("timeout"));
502
    let long = text.replace("timeout \"3h30m\"", "timeout \"25h\"");
503
    assert!(Pipeline::parse(&long).unwrap_err().to_string().contains("from 1m to 24h"));
504
}