OXYGENHUB
# f3b5b86e23ef oxygenflow / crates / flow-core / src / tests.rs
39.6 KiB · Rust · last changed A job's finish report says where its time went: image, prepare, run, finish (refs #35) 9 days ago source blame history raw
e0918b88 Andy McCall 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:?}");
fc10d9e8 Andy McCall ↶ 79
    assert!(has(14, "mount source \"oxy://workspace\" is not in M0 yet"), "{ps:?}");
e0918b88 Andy McCall 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() {
ea81796b Andy McCall ↶ 98
    let src = format!("schema-version 1\nsandbox \"s\" {{\n  runtime \"wasm\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"wifi\"\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n");
e0918b88 Andy McCall 99
    let ps = problems(&src);
28cc603f Andy McCall ↶ 100
    // A WASM sandbox has no image (oxygenflow#75).
101
    assert!(ps.iter().any(|(l, m)| *l == 2 && m.contains("takes no rootfs")), "{ps:?}");
e0918b88 Andy McCall 102
    assert!(ps.iter().any(|(l, m)| *l == 5 && m.contains("isolated")), "{ps:?}");
103
    assert!(problems("schema-version 2\n").iter().any(|(_, m)| m.contains("schema-version 2")));
104
    assert!(problems(&format!(
105
        "schema-version 1\nsandbox \"s\" {{ rootfs \"cas://blob/{}\"; }}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}",
106
        &ROOTFS[..60]
107
    ))
108
    .iter()
109
    .any(|(_, m)| m.contains("64 lower-case hex")));
110
}
111
112
#[test]
113
fn cycles_are_found() {
114
    let ps = problems(&doc(
115
        "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",
116
    ));
117
    assert!(ps.iter().any(|(_, m)| m.contains("cycle: a → c → b → a")), "{ps:?}");
118
    assert!(ps.iter().any(|(_, m)| m.contains("\"d\" depends on itself")), "{ps:?}");
119
}
120
121
#[test]
122
fn invalid_kdl_has_a_line() {
123
    let ps = problems("schema-version 1\njob \"a\" {\n  exec \"unterminated\n}\n");
124
    assert!(!ps.is_empty() && ps[0].0 >= 2, "{ps:?}");
125
}
126
127
#[test]
128
fn canonical_form_ignores_layout_and_order() {
129
    let a = Pipeline::parse(&doc(
130
        "job \"b\" { sandbox \"alpine\"; exec \"true\"; }\njob \"a\" {\n  sandbox \"alpine\"\n  depends-on \"b\"\n  exec \"x\"\n}\n",
131
    ))
132
    .unwrap();
133
    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();
134
    assert_eq!(a.canonical(), b.canonical());
135
    assert_eq!(a.hash(), b.hash());
136
    let c = Pipeline::parse(&doc(
137
        "job \"b\" { sandbox \"alpine\"; exec \"true\"; }\njob \"a\" { sandbox \"alpine\"; depends-on \"b\"; exec \"y\"; }\n",
138
    ))
139
    .unwrap();
140
    assert_ne!(a.hash(), c.hash());
141
    let sb = &a.sandboxes["alpine"];
142
    assert_ne!(a.jobs["a"].hash(sb), c.jobs["a"].hash(sb));
143
    assert_eq!(a.jobs["b"].hash(sb), c.jobs["b"].hash(sb), "an unchanged job keeps its hash");
144
    // The canonical form round-trips through Oxygen's strict decoder.
145
    oxy_core::cbor::decode(&a.canonical()).unwrap();
146
}
147
148
#[test]
149
fn scope_tree_hash_sees_only_the_scope() {
150
    let f = |b: u8| [b; 32];
151
    let scope = vec!["src/**".to_string()];
152
    let base = scope_tree_hash(&scope, [("src/a.rs", f(1)), ("README.md", f(2))]);
153
    assert_eq!(base, scope_tree_hash(&scope, [("README.md", f(9)), ("src/a.rs", f(1))]), "outside the scope, and order, do not count");
154
    assert_ne!(base, scope_tree_hash(&scope, [("src/a.rs", f(3)), ("README.md", f(2))]));
155
    assert_ne!(scope_tree_hash(&[], [("README.md", f(2))]), scope_tree_hash(&[], [("README.md", f(9))]), "no scope is the whole tree");
156
}
157
158
#[test]
159
fn receipts_hash_as_oxygen_objects() {
160
    let r = Receipt {
161
        job_name: "lint".into(),
162
        pipeline_hash: [1; 32],
163
        scope_tree_hash: [2; 32],
164
        inputs: vec![],
165
        environment: [3; 32],
166
        outputs: vec![],
167
        exit_code: 0,
168
        duration_ms: 1234,
169
    };
170
    let body = r.encode();
171
    let mut want = format!("oxy1 receipt {}\0", body.len()).into_bytes();
172
    want.extend_from_slice(&body);
173
    assert_eq!(r.hash(), *blake3::hash(&want).as_bytes());
174
    let json = serde_json::to_string(&r).unwrap();
175
    assert_eq!(serde_json::from_str::<Receipt>(&json).unwrap(), r);
47c1d64d Andy McCall ↶ 176
    // The same receipt as the Oxygen object a runner signs.
177
    assert_eq!(r.to_oxy().hash().0, r.hash());
178
    assert_eq!(r.to_oxy().encode(), body);
e0918b88 Andy McCall 179
    let mut r2 = r.clone();
180
    r2.exit_code = 1;
181
    assert_ne!(r.hash(), r2.hash());
fc10d9e8 Andy McCall ↶ 182
}
183
184
#[test]
185
fn ephemeral_mounts_parse_sort_and_hash() {
186
    let plain = Pipeline::parse(&doc("job \"b\" {\n    sandbox \"alpine\"\n    exec \"cargo\" \"build\"\n}\n")).unwrap();
187
    let p = Pipeline::parse(&doc(r#"job "b" {
188
    sandbox "alpine"
189
    mount {
190
        source "ephemeral://tmpfs"
191
        target "/tmp/cache"
192
        size "256MiB"
193
    }
194
    mount {
195
        source "ephemeral://disk"
196
        target "/src/target"
197
        size "8GiB"
198
    }
199
    exec "cargo" "build"
200
}
201
"#))
202
    .unwrap();
203
    let b = &p.jobs["b"];
204
    assert_eq!(
205
        b.mounts,
206
        vec![
d60aac6c Andy McCall ↶ 207
            Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 8192, cache: None, key: vec![] },
208
            Mount { source: MountSource::Tmpfs, target: "/tmp/cache".into(), size_mib: 256, cache: None, key: vec![] },
fc10d9e8 Andy McCall ↶ 209
        ],
210
        "sorted by target"
211
    );
212
    assert_ne!(p.hash(), plain.hash(), "mounts are part of the job");
213
    // A job without mounts encodes as before mounts existed: no field 6.
214
    let v = oxy_core::cbor::decode(&plain.canonical()).unwrap();
215
    let text = format!("{v:?}");
216
    assert!(!text.contains("ephemeral"), "{text}");
217
    let json = serde_json::to_value(&plain.jobs["b"]).unwrap();
218
    assert!(json.get("mounts").is_none(), "absent from the API too");
219
    let back: Job = serde_json::from_value(json).unwrap();
220
    assert!(back.mounts.is_empty());
221
}
222
223
#[test]
d60aac6c Andy McCall ↶ 224
fn a_disk_mount_can_be_a_cache_keyed_by_files() {
225
    let src = |extra: &str| {
226
        doc(&format!(
227
            "job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n        source \"ephemeral://disk\"\n        target \"/src/target\"\n        size \"8GiB\"\n{extra}    }}\n    exec \"cargo\" \"build\"\n}}\n"
228
        ))
229
    };
230
    let plain = Pipeline::parse(&src("")).unwrap();
231
    let cached = Pipeline::parse(&src("        cache \"target\"\n        key \"Cargo.lock\" \"rust-toolchain.toml\"\n")).unwrap();
232
    let m = &cached.jobs["b"].mounts[0];
233
    assert_eq!((m.cache.as_deref(), m.key.clone()), (Some("target"), vec!["Cargo.lock".to_string(), "rust-toolchain.toml".to_string()]));
234
    assert_ne!(cached.hash(), plain.hash(), "a cache is part of the job");
235
    let keyless = Pipeline::parse(&src("        cache \"target\"\n")).unwrap();
236
    assert_ne!(keyless.hash(), cached.hash());
237
    let ps = problems(&src("        cache \"no spaces\"\n"));
238
    assert!(ps.iter().any(|(_, m)| m.contains("cache name")), "{ps:?}");
239
    let ps = problems(&src("        key \"Cargo.lock\"\n"));
240
    assert!(ps.iter().any(|(_, m)| m.contains("goes with `cache`")), "{ps:?}");
241
    let ps = problems(&src("        cache \"t\"\n        key \"../x\"\n"));
242
    assert!(ps.iter().any(|(_, m)| m.contains("path in the repository")), "{ps:?}");
243
    let tmpfs = doc("job \"b\" {\n    sandbox \"alpine\"\n    mount {\n        source \"ephemeral://tmpfs\"\n        target \"/tmp/c\"\n        size \"64MiB\"\n        cache \"c\"\n    }\n    exec \"true\"\n}\n");
244
    assert!(problems(&tmpfs).iter().any(|(_, m)| m.contains("only an ephemeral://disk")));
245
}
246
247
#[test]
fc10d9e8 Andy McCall ↶ 248
fn mounts_are_checked() {
249
    let job = |mount: &str| doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n    mount {{\n{mount}\n    }}\n    exec \"true\"\n}}\n"));
250
    let has = |src: String, want: &str| {
251
        let ps = problems(&src);
252
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{want:?} not in {ps:?}");
253
    };
254
    has(job("source \"oxy://workspace\"\ntarget \"/src/x\"\nsize \"1GiB\""), "not in M0 yet");
255
    has(job("source \"ephemeral://disk\"\ntarget \"/etc/x\"\nsize \"1GiB\""), "under /src or /tmp");
256
    has(job("source \"ephemeral://disk\"\ntarget \"/src\"\nsize \"1GiB\""), "under /src or /tmp");
257
    has(job("source \"ephemeral://disk\"\ntarget \"/src/../etc\"\nsize \"1GiB\""), "without . or ..");
258
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x/\"\nsize \"1GiB\""), "trailing /");
259
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"8MiB\""), "from 16MiB to 64GiB");
260
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"65GiB\""), "from 16MiB to 64GiB");
261
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"lots\""), "whole number");
262
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\""), "needs a size");
263
    has(job("source \"ephemeral://disk\"\ntarget \"/src/x\"\nsize \"1GiB\"\nreadonly #true"), "`readonly` is not in M0 yet");
264
    has(job("source \"ephemeral://disk\"\nsource \"ephemeral://tmpfs\"\ntarget \"/src/x\"\nsize \"1GiB\""), "given twice");
265
    let two = |a: &str, b: &str| {
266
        doc(&format!(
267
            "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"
268
        ))
269
    };
270
    has(two("/src/t", "/src/t/deps"), "overlaps");
271
    has(two("/src/t", "/src/t"), "overlaps");
272
    assert!(Pipeline::parse(&two("/src/t", "/src/tt")).is_ok(), "a shared prefix is not nesting");
273
    let five: String =
274
        (0..5).map(|i| format!("    mount {{ source \"ephemeral://tmpfs\"; target \"/tmp/m{i}\"; size \"16MiB\"; }}\n")).collect();
275
    has(doc(&format!("job \"b\" {{\n    sandbox \"alpine\"\n{five}    exec \"true\"\n}}\n")), "at most 4 mounts");
e0918b88 Andy McCall 276
}
eb5c4db6 Andy McCall ↶ 277
278
#[test]
ea81796b Andy McCall ↶ 279
fn loopback_and_allow_listed_egress() {
eb5c4db6 Andy McCall ↶ 280
    let src = |net: &str| {
ea81796b Andy McCall ↶ 281
        format!("schema-version 1\nsandbox \"s\" {{\n  rootfs \"cas://blob/{ROOTFS}\"\n  network {net}\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n")
eb5c4db6 Andy McCall ↶ 282
    };
ea81796b Andy McCall ↶ 283
    let p = Pipeline::parse(&src("\"loopback\"")).unwrap();
eb5c4db6 Andy McCall ↶ 284
    assert_eq!(p.sandboxes["s"].network, Network::Loopback);
ea81796b Andy McCall ↶ 285
    assert_ne!(p.hash(), Pipeline::parse(&src("\"isolated\"")).unwrap().hash(), "the network is part of the sandbox");
286
287
    // Egress names its hosts (oxygenflow#55), sorted; the sandbox then needs
288
    // a runner offering the egress label.
289
    let p =
290
        Pipeline::parse(&src("\"egress\" { allow \"registry.opentofu.org\" \"*.googleapis.com\"; allow \"*.googleapis.com\"; }")).unwrap();
291
    let s = &p.sandboxes["s"];
292
    assert_eq!(s.network, Network::Egress);
293
    assert_eq!(s.egress_allow, ["*.googleapis.com", "registry.opentofu.org"]);
294
    assert_eq!(s.runs_on, [crate::EGRESS_LABEL]);
295
    let other = Pipeline::parse(&src("\"egress\" { allow \"registry.opentofu.org\"; }")).unwrap();
296
    assert_ne!(p.hash(), other.hash(), "the allow-list is part of the sandbox");
297
298
    let has = |net: &str, want: &str| {
299
        let ps = problems(&src(net));
300
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{net}: {ps:?}");
301
    };
302
    has("\"egress\"", "needs a block");
303
    has("\"egress\" { }", "at least one `allow");
304
    has("\"egress\" { allow \"Example.COM\"; }", "lower-case host name");
305
    has("\"egress\" { allow \"localhost\"; }", "lower-case host name");
306
    has("\"egress\" { allow \"example.com:8443\"; }", "lower-case host name");
307
    has("\"egress\" { deny \"example.com\"; }", "unknown egress setting");
308
    has("\"loopback\" { allow \"example.com\"; }", "takes no block");
309
    let syscall = format!(
310
        "schema-version 1\nsandbox \"s\" {{\n  runtime \"syscall\"\n  rootfs \"cas://blob/{ROOTFS}\"\n  network \"egress\" {{ allow \"example.com\"; }}\n}}\njob \"a\" {{ sandbox \"s\"; exec \"true\"; }}\n"
311
    );
312
    assert!(problems(&syscall).iter().any(|(_, m)| m.contains("needs runtime \"microvm\"")));
313
}
314
315
#[test]
316
fn egress_patterns_match_names_and_names_under_a_domain() {
317
    let allow = vec!["*.googleapis.com".to_string(), "registry.opentofu.org".to_string()];
318
    for ok in ["storage.googleapis.com", "a.b.googleapis.com", "REGISTRY.opentofu.org.", "registry.opentofu.org"] {
319
        assert!(crate::egress_allows(&allow, ok), "{ok}");
320
    }
321
    for no in ["googleapis.com", "evilgoogleapis.com", "googleapis.com.evil.org", "opentofu.org", "x.registry.opentofu.org", ""] {
322
        assert!(!crate::egress_allows(&allow, no), "{no}");
323
    }
eb5c4db6 Andy McCall ↶ 324
}
c4285535 Andy McCall ↶ 325
326
#[test]
e2c12561 Andy McCall ↶ 327
fn secrets_mount_as_files_and_are_part_of_the_job() {
328
    let plain = Pipeline::parse(&doc("job \"d\" {\n    sandbox \"alpine\"\n    exec \"deploy\"\n}\n")).unwrap();
329
    let p = Pipeline::parse(&doc(r#"job "d" {
330
    sandbox "alpine"
331
    secret "token" {
332
        mount-path "/run/secrets/token"
333
    }
334
    secret "aws" {
335
        mount-path "/run/secrets/aws/credentials"
336
    }
337
    exec "deploy"
338
}"#))
339
    .unwrap();
340
    let d = &p.jobs["d"];
341
    assert_eq!(
342
        d.secrets,
343
        vec![
344
            Secret { name: "aws".into(), mount_path: "/run/secrets/aws/credentials".into() },
345
            Secret { name: "token".into(), mount_path: "/run/secrets/token".into() },
346
        ],
347
        "sorted by name"
348
    );
349
    assert_ne!(p.hash(), plain.hash(), "secrets are part of the job");
350
    for (bad, why) in [
351
        ("secret \"t\" {\n    mount-path \"/tmp/t\"\n}", "under /run/secrets/"),
352
        ("secret \"t\" {\n    mount-path \"/run/secrets/../x\"\n}", "under /run/secrets/"),
353
        ("secret \"t\" {\n}", "needs a mount-path"),
354
        ("secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"t\" {\n    mount-path \"/run/secrets/b\"\n}", "given twice"),
355
        (
356
            "secret \"t\" {\n    mount-path \"/run/secrets/a\"\n}\nsecret \"u\" {\n    mount-path \"/run/secrets/a\"\n}",
720eb819 Andy McCall ↶ 357
            "two files mount at",
e2c12561 Andy McCall ↶ 358
        ),
359
        ("secret \"t\" {\n    vault-ref \"x\"\n    mount-path \"/run/secrets/a\"\n}", "vault-ref"),
360
    ] {
361
        let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n    sandbox \"alpine\"\n    {bad}\n    exec \"deploy\"\n}}\n"))).unwrap_err();
362
        assert!(e.to_string().contains(why), "{bad}: {e}");
363
    }
364
}
365
366
#[test]
720eb819 Andy McCall ↶ 367
fn identities_are_asked_for_by_audience_and_mount_like_secrets() {
368
    let plain = Pipeline::parse(&doc("job \"d\" {\n    sandbox \"alpine\"\n    exec \"deploy\"\n}\n")).unwrap();
369
    let p = Pipeline::parse(&doc(r#"job "d" {
370
    sandbox "alpine"
371
    identity "sts.amazonaws.com" {
372
        mount-path "/run/secrets/aws-token"
373
    }
374
    identity "https://vault.example" {
375
        mount-path "/run/secrets/vault-token"
376
    }
377
    secret "key" {
378
        mount-path "/run/secrets/key"
379
    }
380
    exec "deploy"
381
}"#))
382
    .unwrap();
383
    let d = &p.jobs["d"];
384
    assert_eq!(
385
        d.identities.iter().map(|i| i.audience.as_str()).collect::<Vec<_>>(),
386
        ["https://vault.example", "sts.amazonaws.com"],
387
        "sorted"
388
    );
389
    assert_ne!(p.hash(), plain.hash(), "identities are part of the job");
390
    for (bad, why) in [
391
        ("identity \"a b\" {\n    mount-path \"/run/secrets/x\"\n}", "without spaces"),
392
        ("identity \"a\" {\n    mount-path \"/tmp/x\"\n}", "under /run/secrets/"),
393
        ("identity \"a\" {\n}", "needs a mount-path"),
394
        ("identity \"a\" {\n    mount-path \"/run/secrets/x\"\n}\nidentity \"a\" {\n    mount-path \"/run/secrets/y\"\n}", "given twice"),
395
        (
396
            "identity \"a\" {\n    mount-path \"/run/secrets/x\"\n}\nsecret \"s\" {\n    mount-path \"/run/secrets/x\"\n}",
397
            "two files mount at",
398
        ),
399
    ] {
400
        let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n    sandbox \"alpine\"\n    {bad}\n    exec \"deploy\"\n}}\n"))).unwrap_err();
401
        assert!(e.to_string().contains(why), "{bad}: {e}");
402
    }
403
}
404
405
#[test]
1abadd4a Andy McCall ↶ 406
fn a_sandbox_may_name_its_architecture() {
407
    let plain = Pipeline::parse(&doc("job \"b\" {\n    sandbox \"alpine\"\n    exec \"make\"\n}\n")).unwrap();
408
    assert_eq!(plain.sandboxes["alpine"].arch, None);
409
    let src =
410
        doc("job \"b\" {\n    sandbox \"alpine\"\n    exec \"make\"\n}\n").replace("memory-limit", "arch \"riscv64\"\n    memory-limit");
411
    let p = Pipeline::parse(&src).unwrap();
412
    assert_eq!(p.sandboxes["alpine"].arch.as_deref(), Some("riscv64"));
413
    assert_ne!(p.hash(), plain.hash(), "the architecture is part of the sandbox");
414
    let bad = src.replace("riscv64", "RISC V");
415
    assert!(Pipeline::parse(&bad).unwrap_err().to_string().contains("not an architecture name"));
416
    assert!(arch_ok("x86_64") && arch_ok("aarch64") && !arch_ok("x") && !arch_ok("A64"));
417
}
418
419
#[test]
c4285535 Andy McCall ↶ 420
fn jobs_are_told_what_they_build() {
421
    let p = Pipeline::parse(&doc(r#"job "build" {
422
    sandbox "alpine"
423
    env {
424
        FLOW_BOOKMARK "pinned-by-the-pipeline"
425
    }
426
    exec "make"
427
}"#))
428
    .unwrap();
429
    let job = p.jobs["build"].clone();
430
    let claim = crate::api::Claim {
431
        job_id: 7,
432
        run_id: 40,
433
        repo: "andy/demo".into(),
434
        clone_url: "https://hub/andy/demo".into(),
435
        token: "t".into(),
436
        heads: vec!["aa".into(), "bb".into()],
437
        pipeline_hash: "00".into(),
438
        job: job.clone(),
439
        sandbox: p.sandboxes["alpine"].clone(),
440
        timeout_s: 60,
441
        subrepos: vec![],
442
        state: "cc".into(),
443
        bookmark: "feature/x".into(),
444
        run_number: 3,
c58efc6a Andy McCall ↶ 445
        env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(),
e2c12561 Andy McCall ↶ 446
        secrets: vec![],
46fe2c81 Andy McCall ↶ 447
        inputs: vec![],
1b160d37 Andy McCall ↶ 448
        expected_ms: Some(17_700),
f201e1bc Andy McCall ↶ 449
        checkpoint: None,
c4285535 Andy McCall ↶ 450
    };
451
    let env = job.with_flow_env(&claim.flow_env()).env;
c58efc6a Andy McCall ↶ 452
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
453
    assert_eq!(env["FLOW_REPO"], "andy/demo", "a hub variable cannot pose as a FLOW_ fact");
c4285535 Andy McCall ↶ 454
    assert_eq!(env["FLOW_HEADS"], "aa+bb");
455
    assert_eq!(env["FLOW_STATE"], "cc");
456
    assert_eq!(env["FLOW_RUN"], "3", "the run's number in its repository, not the hub's id");
457
    assert_eq!(env["FLOW_JOB"], "build");
458
    assert_eq!(env["FLOW_BOOKMARK"], "pinned-by-the-pipeline", "the pipeline's own value wins");
459
    // Unknown values (an older hub) are left out, not empty.
e2c12561 Andy McCall ↶ 460
    let old = crate::api::Claim {
461
        state: String::new(),
462
        bookmark: String::new(),
463
        run_number: 0,
464
        env: Default::default(),
465
        secrets: vec![],
466
        ..claim
467
    };
c4285535 Andy McCall ↶ 468
    let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect();
469
    assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]);
1b160d37 Andy McCall ↶ 470
    // The expected time goes with the claim, and an older hub's claim
471
    // without it still reads (#76).
472
    let json = serde_json::to_value(&old).unwrap();
473
    assert_eq!(json["expected_ms"], 17_700);
474
    let mut without = json.clone();
475
    without.as_object_mut().unwrap().remove("expected_ms");
476
    assert_eq!(serde_json::from_value::<crate::api::Claim>(without).unwrap().expected_ms, None);
c4285535 Andy McCall ↶ 477
}
a72abeee Andy McCall ↶ 478
479
#[test]
480
fn a_sandbox_env_is_where_jobs_start() {
481
    let src = |sandbox_env: &str| {
482
        format!(
483
            "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"
484
        )
485
    };
486
    let with =
487
        Pipeline::parse(&src("    env {\n        PATH \"/opt/llvm-mos/bin:/usr/bin:/bin\"\n        X16EMU \"/opt/x16emu\"\n    }\n"))
488
            .unwrap();
489
    let sb = &with.sandboxes["mos"];
490
    assert_eq!(sb.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin");
491
    let job = with.jobs["build"].in_sandbox(sb);
492
    assert_eq!(job.env["PATH"], "/opt/llvm-mos/bin:/usr/bin:/bin", "from the sandbox");
493
    assert_eq!(job.env["X16EMU"], "/opt/job", "the job's own value wins");
494
    // The sandbox's environment is part of the pipeline's hash; a sandbox
495
    // without one hashes as before.
496
    let without = Pipeline::parse(&src("")).unwrap();
497
    assert!(without.sandboxes["mos"].env.is_empty());
498
    assert_ne!(with.hash(), without.hash());
499
    let bad = problems(&src("    env {\n        BAD-NAME \"x\"\n    }\n"));
500
    assert!(bad.iter().any(|(_, m)| m.contains("letters, digits and _")), "{bad:?}");
501
}
a67b97e9 Andy McCall ↶ 502
503
#[test]
504
fn artifacts_are_declared_checked_and_hashed() {
505
    let src = |out: &str| {
506
        doc(&format!(
507
            "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"
508
        ))
509
    };
510
    let p = Pipeline::parse(&src(
511
        "    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",
512
    ))
513
    .unwrap();
514
    let names: Vec<(&str, &str)> = p.jobs["build"].outputs.iter().map(|o| (o.name.as_str(), o.path.as_str())).collect();
515
    assert_eq!(names, [("docs", "/src/docs/tool.1"), ("tool", "/src/target/release/tool")], "sorted by name");
516
    // Artifacts are part of the job's hash; a job without them hashes as before.
517
    let plain = Pipeline::parse(&src("")).unwrap();
518
    let sb = &p.sandboxes["alpine"];
519
    assert_ne!(p.jobs["build"].hash(sb), plain.jobs["build"].hash(sb));
520
    assert!(plain.jobs["build"].outputs.is_empty());
521
    let json = serde_json::to_string(&plain.jobs["build"]).unwrap();
522
    assert!(!json.contains("outputs"), "no outputs field when there are none: {json}");
523
524
    let bad = |out: &str| problems(&src(out)).into_iter().map(|(_, m)| m).collect::<Vec<_>>().join("\n");
525
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/etc/passwd\"\n        }\n    }\n")
526
        .contains("must be an absolute path under /src"));
527
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/../etc\"\n        }\n    }\n")
528
        .contains("must be an absolute path under /src"));
529
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/tmp/a\"\n        }\n    }\n").contains("tmpfs"));
530
    assert!(bad("    output {\n        artifact \"x\" {\n        }\n    }\n").contains("needs a path"));
531
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n        }\n        artifact \"x\" {\n            path \"/src/b\"\n        }\n    }\n")
532
        .contains("given twice"));
533
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n            format \"tar\"\n        }\n    }\n")
534
        .contains("format"));
9a6fac2b Andy McCall ↶ 535
536
    // oxygenflow#37: `when "always"` keeps a failing job's report; the
537
    // default hashes and serialises as before.
538
    let p = Pipeline::parse(&src(
539
        "    output {\n        artifact \"junit\" {\n            path \"/src/target/junit.xml\"\n            when \"always\"\n        }\n        artifact \"tool\" {\n            path \"/src/target/tool\"\n            when \"passed\"\n        }\n    }\n",
540
    ))
541
    .unwrap();
542
    let outs = &p.jobs["build"].outputs;
543
    assert!(outs[0].always && outs[0].name == "junit");
544
    assert!(!outs[1].always);
545
    let json = serde_json::to_string(&outs[1]).unwrap();
546
    assert!(!json.contains("always"), "{json}");
547
    assert!(serde_json::to_string(&outs[0]).unwrap().contains("\"always\":true"));
548
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n            when \"failed\"\n        }\n    }\n")
549
        .contains("\"passed\" (the default) or \"always\""));
6d53fb7d Andy McCall ↶ 550
551
    // oxygenhub#127: a report is a directory output; it shares names with
552
    // artifacts, and its kind is part of the job's hash.
553
    let as_kind = |kind: &str| {
554
        Pipeline::parse(&src(&format!("    output {{\n        {kind} \"perf\" {{\n            path \"/src/target/report\"\n            when \"always\"\n        }}\n    }}\n"))).unwrap()
555
    };
556
    let (report, artifact) = (as_kind("report"), as_kind("artifact"));
557
    let o = &report.jobs["build"].outputs[0];
558
    assert!(o.kind == OutputKind::Report && o.always && o.path == "/src/target/report");
559
    assert!(serde_json::to_string(o).unwrap().contains("\"kind\":\"report\""));
560
    assert!(!serde_json::to_string(&artifact.jobs["build"].outputs[0]).unwrap().contains("kind"));
561
    assert_ne!(report.jobs["build"].hash(sb), artifact.jobs["build"].hash(sb));
562
    assert!(bad("    output {\n        artifact \"x\" {\n            path \"/src/a\"\n        }\n        report \"x\" {\n            path \"/src/b\"\n        }\n    }\n")
563
        .contains("given twice"));
564
    assert!(bad("    output {\n        report \"x\" {\n        }\n    }\n").contains("needs a path"));
a67b97e9 Andy McCall ↶ 565
}
b647ab72 Andy McCall ↶ 566
567
/// metalgear feedback 19 (oxygenflow#17): `\033` in a string is not KDL;
568
/// the problems say to write ESC as `\u{1b}`, which parses.
569
#[test]
570
fn ansi_escapes_are_written_as_unicode() {
571
    let bad = problems(&doc("job \"a\" {\n    sandbox \"alpine\"\n    exec \"printf\" \"\\033[32mok\\033[0m\"\n}\n"));
572
    assert!(bad.iter().any(|(line, m)| *line == 13 && m.contains("\\u{1b}")), "{bad:?}");
573
    let p = Pipeline::parse(&doc("job \"a\" {\n    sandbox \"alpine\"\n    exec \"printf\" \"\\u{1b}[32mok\\u{1b}[0m\"\n}\n")).unwrap();
574
    assert_eq!(p.jobs["a"].exec[1], "\u{1b}[32mok\u{1b}[0m");
575
}
e576e086 Andy McCall ↶ 576
577
#[test]
578
fn runs_on_asks_for_runner_labels() {
579
    let src = doc("job \"cross\" {\n    sandbox \"alpine\"\n    runs-on \"riscv64\" \"big-disk\"\n    exec \"make\"\n}\n")
580
        .replace("    network", "    runs-on \"gpu\"\n    network");
581
    let p = Pipeline::parse(&src).unwrap();
582
    let job = &p.jobs["cross"];
583
    assert_eq!(job.runs_on, vec!["big-disk", "riscv64"], "sorted");
584
    let sandbox = &p.sandboxes["alpine"];
585
    assert_eq!(sandbox.runs_on, vec!["gpu"]);
586
    assert_eq!(job.in_sandbox(sandbox).runs_on, vec!["big-disk", "gpu", "riscv64"], "with its sandbox's");
587
    // A pipeline without runs-on serialises as before, so its jobs' hashes stay.
588
    let plain = Pipeline::parse(&doc("job \"x\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\n")).unwrap();
589
    assert!(!serde_json::to_string(&plain.jobs["x"]).unwrap().contains("runs_on"));
590
    let bad = problems(&doc("job \"x\" {\n    sandbox \"alpine\"\n    runs-on \"RISCV\"\n    exec \"true\"\n}\n"));
591
    assert!(bad.iter().any(|(_, m)| m.contains("a label is lower-case")), "{bad:?}");
592
    let none = problems(&doc("job \"y\" {\n    sandbox \"alpine\"\n    runs-on\n    exec \"true\"\n}\n"));
593
    assert!(none.iter().any(|(_, m)| m.contains("needs one or more labels")), "{none:?}");
594
}
02b127d2 Andy McCall ↶ 595
e87c1c13 Andy McCall ↶ 596
/// oxygenhub#244: a pipeline may lower its jobs' priority, for all of them
597
/// or one by one, never raise it.
598
#[test]
599
fn a_pipeline_lowers_its_jobs_priority_and_never_raises_it() {
600
    let jobs = "job \"nightly\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\njob \"check\" {\n    sandbox \"alpine\"\n    priority \"normal\"\n    exec \"true\"\n}\n";
601
    let p = Pipeline::parse(&format!("priority \"low\"\n{}", doc(jobs))).unwrap();
602
    assert_eq!(p.jobs["nightly"].priority.as_deref(), Some("low"), "the pipeline's");
603
    assert_eq!(p.jobs["check"].priority.as_deref(), Some("normal"), "its own");
604
    let one = Pipeline::parse(&doc("job \"a\" {\n    sandbox \"alpine\"\n    priority \"low\"\n    exec \"true\"\n}\n")).unwrap();
605
    assert_eq!(one.jobs["a"].priority.as_deref(), Some("low"));
606
    // None given: serialised as before, so its jobs' hashes stay.
607
    let plain = Pipeline::parse(&doc("job \"x\" {\n    sandbox \"alpine\"\n    exec \"true\"\n}\n")).unwrap();
608
    assert!(!serde_json::to_string(&plain.jobs["x"]).unwrap().contains("priority"));
609
    for high in ["high", "urgent"] {
610
        let bad = problems(&doc(&format!("job \"x\" {{\n    sandbox \"alpine\"\n    priority \"{high}\"\n    exec \"true\"\n}}\n")));
611
        assert!(bad.iter().any(|(_, m)| m.contains("not raise them")), "{high}: {bad:?}");
612
    }
613
    let odd = problems(&doc("job \"x\" {\n    sandbox \"alpine\"\n    priority \"soon\"\n    exec \"true\"\n}\n"));
614
    assert!(odd.iter().any(|(_, m)| m.contains("expected `low` or `normal`")), "{odd:?}");
615
}
616
02b127d2 Andy McCall ↶ 617
/// oxygenflow#23: `timeout` on a job, or on its sandbox for every job in
618
/// it, as a duration from a minute to a day.
619
#[test]
620
fn a_job_says_how_long_it_may_run() {
621
    use crate::pipeline::{parse_duration, Pipeline};
622
    assert_eq!(parse_duration("90m"), Some(5400));
623
    assert_eq!(parse_duration("1h30m"), Some(5400));
624
    assert_eq!(parse_duration("2h"), Some(7200));
625
    assert_eq!(parse_duration("45s"), Some(45));
626
    for bad in ["", "90", "m", "30m1h", "1h1h", "2d", "-1h"] {
627
        assert_eq!(parse_duration(bad), None, "{bad:?}");
628
    }
629
    let root = "a".repeat(64);
630
    let text = format!(
631
        "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"
632
    );
633
    let p = Pipeline::parse(&text).unwrap();
634
    let job = |n: &str| p.jobs[n].in_sandbox(&p.sandboxes["s"]);
635
    assert_eq!(job("long").timeout_s, Some(12600), "the job's own");
636
    assert_eq!(job("plain").timeout_s, Some(7200), "the sandbox's");
637
    let bad = text.replace("timeout \"3h30m\"", "timeout \"2d\"");
638
    assert!(Pipeline::parse(&bad).unwrap_err().to_string().contains("timeout"));
639
    let long = text.replace("timeout \"3h30m\"", "timeout \"25h\"");
640
    assert!(Pipeline::parse(&long).unwrap_err().to_string().contains("from 1m to 24h"));
641
}
bf7bb1b8 Andy McCall ↶ 642
643
/// oxygenflow#24: `on` says what starts a job, at pipeline or job level;
644
/// a job without one runs on every push and by hand, as before.
645
#[test]
646
fn on_says_what_starts_a_job() {
647
    use crate::pipeline::{Event, Pipeline};
648
    let root = "a".repeat(64);
649
    let text = format!(
650
        "schema-version 1\non {{\n    push \"main\" \"feature/*\"\n    manual\n}}\nsandbox \"s\" {{\n    runtime \"microvm\"\n    rootfs \"cas://blob/{root}\"\n    memory-limit \"1GiB\"\n    cpu-limit 1\n    network \"isolated\"\n}}\njob \"test\" {{\n    sandbox \"s\"\n    exec \"true\"\n}}\njob \"dist\" {{\n    sandbox \"s\"\n    on {{\n        tag \"v*\"\n        manual\n    }}\n    exec \"true\"\n}}\njob \"nightly\" {{\n    sandbox \"s\"\n    on {{\n        schedule \"0 3 * * *\"\n    }}\n    exec \"true\"\n}}\n"
651
    );
652
    let p = Pipeline::parse(&text).unwrap();
653
    let runs = |job: &str, e: Event| p.on_of(&p.jobs[job]).matches(e);
654
    assert!(runs("test", Event::Push("main")) && runs("test", Event::Push("feature/9-x")));
655
    assert!(!runs("test", Event::Push("develop")), "not a bookmark the pipeline names");
656
    assert!(!runs("test", Event::Tag("v1.0.0")) && runs("test", Event::Manual));
657
    assert!(runs("dist", Event::Tag("v0.3.1")) && !runs("dist", Event::Tag("nightly")));
658
    assert!(!runs("dist", Event::Push("main")) && runs("dist", Event::Manual));
659
    assert!(runs("nightly", Event::Schedule) && !runs("nightly", Event::Manual));
660
    // Without any `on`, as before.
661
    let plain = text.replace("on {\n    push \"main\" \"feature/*\"\n    manual\n}\n", "");
662
    let p = Pipeline::parse(&plain).unwrap();
663
    assert!(p.on_of(&p.jobs["test"]).matches(Event::Push("anything")) && p.on_of(&p.jobs["test"]).matches(Event::Manual));
664
    for (bad, why) in [
665
        ("on {\n}\n", "names no event"),
666
        ("on {\n    when \"x\"\n}\n", "unknown `on` event"),
667
        ("on {\n    schedule \"0 3 * *\"\n}\n", "five cron fields"),
668
    ] {
669
        let t = format!("{bad}{plain}");
670
        let e = Pipeline::parse(&t).unwrap_err().to_string();
671
        assert!(e.contains(why), "{bad:?}: {e}");
672
    }
46fe2c81 Andy McCall ↶ 673
}
674
675
#[test]
676
fn a_job_takes_an_artifact_of_a_job_it_depends_on() {
677
    let jobs = |apply: &str| {
678
        doc(&format!(
679
            "job \"plan\" {{\n    sandbox \"alpine\"\n    output {{ artifact \"tfplan\" {{ path \"/src/out/tfplan\"; }}; report \"r\" {{ path \"/src/out/r\"; }}; }}\n    exec \"plan\"\n}}\njob \"apply\" {{\n    sandbox \"alpine\"\n{apply}    exec \"apply\"\n}}\n"
680
        ))
681
    };
682
    let ok = jobs("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n");
683
    let p = Pipeline::parse(&ok).unwrap();
684
    assert_eq!(p.jobs["apply"].inputs, [crate::Input { artifact: "tfplan".into(), from: "plan".into(), path: "/src/tfplan".into() }]);
685
    let without = Pipeline::parse(&jobs("    depends-on \"plan\"\n")).unwrap();
686
    assert_ne!(p.hash(), without.hash(), "inputs are part of the job");
687
688
    let has = |apply: &str, want: &str| {
689
        let ps = problems(&jobs(apply));
690
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{apply}: {ps:?}");
691
    };
692
    has("    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n", "add depends-on \"plan\"");
693
    has("    depends-on \"plan\"\n    input { artifact \"nope\" from \"plan\"; path \"/src/x\"; }\n", "declares no artifact \"nope\"");
694
    has("    depends-on \"plan\"\n    input { artifact \"r\" from \"plan\"; path \"/src/x\"; }\n", "is a report");
695
    has("    input { artifact \"tfplan\" from \"ghost\"; path \"/src/x\"; }\n", "which is not declared");
696
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/etc/x\"; }\n", "absolute path under /src");
697
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" \"plan\"; path \"/src/x\"; }\n", "artifact \"tfplan\" from \"plan\"");
698
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; }\n", "needs a `path`");
699
    has(
700
        "    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n",
701
        "two inputs are put at",
702
    );
bf7bb1b8 Andy McCall ↶ 703
}
e2089721 Andy McCall ↶ 704
705
#[test]
706
fn a_job_can_wait_for_approval() {
707
    let job = |approval: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{approval}    exec \"apply\"\n}}\n"));
708
    let p = Pipeline::parse(&job("    approval { by \"maintain\"; others-only #true; expires \"2h\"; }\n")).unwrap();
2be1fe4a Andy McCall ↶ 709
    assert_eq!(
710
        p.jobs["apply"].approval,
711
        Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200, sealed: false })
712
    );
e2089721 Andy McCall ↶ 713
    let plain = Pipeline::parse(&job("    approval\n")).unwrap();
714
    assert_eq!(
715
        plain.jobs["apply"].approval,
2be1fe4a Andy McCall ↶ 716
        Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400, sealed: false }),
e2089721 Andy McCall ↶ 717
        "defaults"
718
    );
2be1fe4a Andy McCall ↶ 719
    // Sealed (oxygenhub#235): on by default for a job with an environment,
720
    // as it says otherwise.
721
    let sealed = |extra: &str| Pipeline::parse(&job(extra)).unwrap().jobs["apply"].approval.clone().unwrap().sealed;
722
    assert!(sealed("    environment \"prod\"\n    approval\n"));
723
    assert!(!sealed("    environment \"prod\"\n    approval { sealed #false; }\n"));
724
    assert!(sealed("    approval { sealed #true; }\n"));
725
    assert_ne!(Pipeline::parse(&job("    approval { sealed #true; }\n")).unwrap().hash(), plain.hash(), "part of the job");
e2089721 Andy McCall ↶ 726
    assert_eq!(Pipeline::parse(&job("")).unwrap().jobs["apply"].approval, None);
727
    assert_ne!(p.hash(), plain.hash(), "the approval is part of the job");
728
    let has = |a: &str, want: &str| {
729
        let ps = problems(&job(a));
730
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{a}: {ps:?}");
731
    };
732
    has("    approval { by \"read\"; }\n", "expected \"write\", \"maintain\" or \"admin\"");
733
    has("    approval { others-only \"yes\"; }\n", "#true or #false");
734
    has("    approval { expires \"800h\"; }\n", "from 1m to 720h");
735
    has("    approval { when \"later\"; }\n", "unknown approval setting");
2be1fe4a Andy McCall ↶ 736
    has("    approval { sealed \"yes\"; }\n", "#true or #false");
e2089721 Andy McCall ↶ 737
}
f4e51e63 Andy McCall ↶ 738
739
#[test]
740
fn a_job_names_the_environment_it_deploys_to() {
741
    let job = |e: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{e}    exec \"apply\"\n}}\n"));
742
    let p = Pipeline::parse(&job("    environment \"prod\"\n")).unwrap();
743
    assert_eq!(p.jobs["apply"].environment.as_deref(), Some("prod"));
744
    assert_ne!(p.hash(), Pipeline::parse(&job("")).unwrap().hash(), "part of the job");
745
    assert!(problems(&job("    environment \"Prod env\"\n")).iter().any(|(_, m)| m.contains("1 to 32 of a-z 0-9 -")));
746
}
28cc603f Andy McCall ↶ 747
748
/// oxygenflow#75: a WASM sandbox needs no rootfs and has no network; its
749
/// job's exec names the module.
750
#[test]
751
fn a_wasm_sandbox_runs_a_module() {
752
    let src = "schema-version 1\nsandbox \"w\" {\n  runtime \"wasm\"\n  memory-limit \"512MiB\"\n}\njob \"t\" {\n  sandbox \"w\"\n  exec \"target/wasm32-wasip1/debug/deps/*.wasm\" \"--quiet\"\n}\n";
753
    let p = Pipeline::parse(src).unwrap_or_else(|ps| panic!("{:?}", ps.0));
754
    let sb = &p.sandboxes["w"];
755
    assert_eq!((sb.runtime, sb.rootfs.as_str(), sb.network, sb.memory_mib), (Runtime::Wasm, "", Network::Isolated, 512));
756
    let networked =
757
        "schema-version 1\nsandbox \"w\" {\n  runtime \"wasm\"\n  network \"loopback\"\n}\njob \"t\" { sandbox \"w\"; exec \"a.wasm\"; }\n";
758
    assert!(problems(networked).iter().any(|(_, m)| m.contains("has no network")));
759
}
1215f05f Andy McCall ↶ 760
761
#[test]
762
fn a_finish_says_where_its_time_went_and_an_older_one_still_reads() {
763
    let old = r#"{"exit_code":0,"duration_ms":5,"receipt":null,"error":null}"#;
764
    let mut f: crate::api::Finish = serde_json::from_str(old).unwrap();
765
    assert!(f.phases.is_empty());
766
    assert!(!serde_json::to_string(&f).unwrap().contains("phases"), "absent when there are none");
767
    f.phases.insert("prepare".into(), 1200);
768
    f.phases.insert("run".into(), 3400);
769
    let back: crate::api::Finish = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
770
    assert_eq!(back.phases["run"], 3400);
771
}