Job artifacts: output declarations, extraction and upload a67b97e9a717
A job may declare up to 16 artifacts, output { artifact "name" { path
"/src/…" } }, part of its hash only when present; paths on tmpfs mounts
are refused. After a passing job the runner reads each artifact from the
job's disk with debugfs, uploads it, checks the hub kept the same blob, and
names the blobs in the receipt's outputs. An artifact that cannot be kept
fails the job, without a receipt.
closes #11
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 07:54 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a67b97e9a71707e2155e2f6ebc0f6a1cff98faa39a190ce378fb70d2c1246635- Depends on
- 47c1d64dec8a a72abeeedd78 e0918b887850 fc10d9e80383
- Depended on by
- 3554afefb6aa f201e1bc5bcd 5cf1e8aefbb6 e87c1c138187 f4e51e63582d e20897216221 46fe2c815cc3 89171fca079c d60aac6c3f8a 8f211e20bea5 6d53fb7d00ec 9a6fac2b40a9 ea56c56eaf90 720eb8191059 e2c125610f43 280ef951d193 bf7bb1b80fa3 02b127d2c721 3007d8f011fa e576e08617dc b647ab72d51e
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 21 atoms, 1 tree operation, 7 files
Browse the tree at this change
added crates/flow/src/outputs.rs +104 −0
| 1 | + | //! A job's artifacts (spec §5.2): read from its disks after the microVM |
|
| 2 | + | //! has stopped, with debugfs, so the runner needs no mount rights. |
|
| 3 | + | ||
| 4 | + | use anyhow::{bail, Context, Result}; |
|
| 5 | + | use flow_core::{Job, MountSource}; |
|
| 6 | + | use std::path::{Path, PathBuf}; |
|
| 7 | + | ||
| 8 | + | /// Largest artifact a runner takes: 512 MiB. |
|
| 9 | + | pub const MAX_ARTIFACT_BYTES: u64 = 512 << 20; |
|
| 10 | + | ||
| 11 | + | /// Where guest path `path` lives on the host: the disk image, and the path |
|
| 12 | + | /// inside its file system. The workspace is `/src`; each `ephemeral://disk` |
|
| 13 | + | /// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does. |
|
| 14 | + | pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) { |
|
| 15 | + | for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() { |
|
| 16 | + | if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) { |
|
| 17 | + | return (scratch.join(format!("mount-{i}.ext4")), format!("/{rest}")); |
|
| 18 | + | } |
|
| 19 | + | } |
|
| 20 | + | (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string()) |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | fn debugfs(image: &Path, request: &str) -> Result<String> { |
|
| 24 | + | let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?; |
|
| 25 | + | if !out.status.success() { |
|
| 26 | + | bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim()); |
|
| 27 | + | } |
|
| 28 | + | Ok(String::from_utf8_lossy(&out.stdout).into_owned()) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// Read regular file `inner` of ext4 image `image`, at most |
|
| 32 | + | /// [`MAX_ARTIFACT_BYTES`]. Paths come from a checked pipeline: absolute, |
|
| 33 | + | /// clean, and free of quotes and spaces. |
|
| 34 | + | pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> { |
|
| 35 | + | let stat = debugfs(image, &format!("stat \"{inner}\""))?; |
|
| 36 | + | if !stat.contains("Type: regular") { |
|
| 37 | + | bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)"); |
|
| 38 | + | } |
|
| 39 | + | let size: u64 = stat |
|
| 40 | + | .split("Size: ") |
|
| 41 | + | .nth(1) |
|
| 42 | + | .and_then(|s| s.split_whitespace().next()) |
|
| 43 | + | .and_then(|s| s.parse().ok()) |
|
| 44 | + | .context("debugfs gave no size")?; |
|
| 45 | + | if size > MAX_ARTIFACT_BYTES { |
|
| 46 | + | bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20); |
|
| 47 | + | } |
|
| 48 | + | let dest = scratch.join("artifact.out"); |
|
| 49 | + | let _ = std::fs::remove_file(&dest); |
|
| 50 | + | debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?; |
|
| 51 | + | let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?; |
|
| 52 | + | let _ = std::fs::remove_file(&dest); |
|
| 53 | + | if bytes.len() as u64 != size { |
|
| 54 | + | bail!("{inner}: read {} bytes of {size}", bytes.len()); |
|
| 55 | + | } |
|
| 56 | + | Ok(bytes) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | #[cfg(test)] |
|
| 60 | + | mod tests { |
|
| 61 | + | use super::*; |
|
| 62 | + | use flow_core::Mount; |
|
| 63 | + | ||
| 64 | + | fn job() -> Job { |
|
| 65 | + | Job { |
|
| 66 | + | name: "build".into(), |
|
| 67 | + | sandbox: "rust".into(), |
|
| 68 | + | scope: vec![], |
|
| 69 | + | depends_on: vec![], |
|
| 70 | + | env: Default::default(), |
|
| 71 | + | exec: vec!["make".into()], |
|
| 72 | + | mounts: vec![ |
|
| 73 | + | Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 }, |
|
| 74 | + | Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 }, |
|
| 75 | + | Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024 }, |
|
| 76 | + | ], |
|
| 77 | + | outputs: vec![], |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | #[test] |
|
| 82 | + | fn paths_are_found_on_the_disk_that_holds_them() { |
|
| 83 | + | let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch")); |
|
| 84 | + | assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into())); |
|
| 85 | + | assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into())); |
|
| 86 | + | assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into())); |
|
| 87 | + | assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent"); |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | #[test] |
|
| 91 | + | fn files_are_read_back_from_an_ext4_image() { |
|
| 92 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 93 | + | let tree = dir.path().join("tree"); |
|
| 94 | + | std::fs::create_dir_all(tree.join("release")).unwrap(); |
|
| 95 | + | let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); |
|
| 96 | + | std::fs::write(tree.join("release/tool"), &body).unwrap(); |
|
| 97 | + | std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap(); |
|
| 98 | + | let image = dir.path().join("disk.ext4"); |
|
| 99 | + | crate::workspace::image(&tree, &image).unwrap(); |
|
| 100 | + | assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body); |
|
| 101 | + | assert!(read(&image, "/release/missing", dir.path()).is_err()); |
|
| 102 | + | assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file")); |
|
| 103 | + | } |
|
| 104 | + | } |
modified crates/flow-core/src/canonical.rs +4 −0
| … | 68 unchanged lines |
||
| 69 | 69 | ), |
|
| 70 | 70 | )); |
|
| 71 | 71 | } |
|
| 72 | + | // Artifacts as [name, path] sorted by name; absent when there are none. |
|
| 73 | + | if !j.outputs.is_empty() { |
|
| 74 | + | fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect()))); |
|
| 75 | + | } |
|
| 72 | 76 | Value::map(fields) |
|
| 73 | 77 | } |
|
| 74 | 78 | ||
| … | 41 unchanged lines |
modified crates/flow-core/src/pipeline.rs +94 −1
| … | 85 unchanged lines |
||
| 86 | 86 | /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`). |
|
| 87 | 87 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 88 | 88 | pub mounts: Vec<Mount>, |
|
| 89 | + | /// Files the job produces, sorted by name (spec §5.2 `output`). |
|
| 90 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 91 | + | pub outputs: Vec<Output>, |
|
| 89 | 92 | } |
|
| 90 | 93 | ||
| 91 | 94 | impl Job { |
|
| … | 44 unchanged lines |
||
| 136 | 139 | MountSource::Disk => "ephemeral://disk", |
|
| 137 | 140 | } |
|
| 138 | 141 | } |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | /// A file a job produces (spec §5.2): read from the job's disks when it |
|
| 145 | + | /// ends, kept in the repository as a blob, and named by its receipt. |
|
| 146 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 147 | + | pub struct Output { |
|
| 148 | + | /// The artifact's name, unique in the job. |
|
| 149 | + | pub name: String, |
|
| 150 | + | /// A clean absolute path under `/src`, not on a tmpfs mount. |
|
| 151 | + | pub path: String, |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | /// Artifacts a job may declare. |
|
| 155 | + | pub const MAX_OUTPUTS: usize = 16; |
|
| 156 | + | ||
| 157 | + | /// Whether `p` is a clean absolute path strictly under `/src`, with nothing |
|
| 158 | + | /// a debugfs command line would have to quote. |
|
| 159 | + | fn output_path_ok(p: &str) -> bool { |
|
| 160 | + | p.strip_prefix("/src/").is_some_and(|rest| { |
|
| 161 | + | !rest.is_empty() |
|
| 162 | + | && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..") |
|
| 163 | + | && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\') |
|
| 164 | + | }) |
|
| 139 | 165 | } |
|
| 140 | 166 | ||
| 141 | 167 | /// Mounts a job may declare. |
|
| … | 38 unchanged lines |
||
| 180 | 206 | ||
| 181 | 207 | /// Nodes of the full spec that M0 recognises and refuses. |
|
| 182 | 208 | const LATER_TOP: &[&str] = &["publish", "secret"]; |
|
| 183 | - | const LATER_JOB: &[&str] = &["output", "secret", "attest"]; |
|
| 209 | + | const LATER_JOB: &[&str] = &["secret", "attest"]; |
|
| 184 | 210 | ||
| 185 | 211 | struct Ctx<'a> { |
|
| 186 | 212 | src: &'a str, |
|
| … | 457 unchanged lines |
||
| 644 | 670 | Some(Mount { source: source?, target: target?, size_mib: size? }) |
|
| 645 | 671 | } |
|
| 646 | 672 | ||
| 673 | + | /// `output { artifact "name" { path "/src/target/release/tool" } }`. |
|
| 674 | + | fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) { |
|
| 675 | + | if !node.entries().is_empty() { |
|
| 676 | + | ctx.err(node.span(), "`output` takes a block of artifacts"); |
|
| 677 | + | } |
|
| 678 | + | for a in block(ctx, node) { |
|
| 679 | + | if a.name().value() != "artifact" { |
|
| 680 | + | ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value())); |
|
| 681 | + | continue; |
|
| 682 | + | } |
|
| 683 | + | let Some(name) = ctx.one_string(a) else { continue }; |
|
| 684 | + | if !ctx.name_ok(a.span(), "artifact", &name) { |
|
| 685 | + | continue; |
|
| 686 | + | } |
|
| 687 | + | let mut path = None; |
|
| 688 | + | for c in block(ctx, a) { |
|
| 689 | + | ctx.no_children(c); |
|
| 690 | + | match c.name().value() { |
|
| 691 | + | "path" => match ctx.one_string(c) { |
|
| 692 | + | Some(p) if output_path_ok(&p) => path = Some(p), |
|
| 693 | + | Some(p) => ctx.err( |
|
| 694 | + | c.span(), |
|
| 695 | + | format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"), |
|
| 696 | + | ), |
|
| 697 | + | None => {} |
|
| 698 | + | }, |
|
| 699 | + | "format" => { |
|
| 700 | + | if ctx.one_string(c).as_deref() != Some("blob") { |
|
| 701 | + | ctx.err(c.span(), "artifact format is \"blob\""); |
|
| 702 | + | } |
|
| 703 | + | } |
|
| 704 | + | other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")), |
|
| 705 | + | } |
|
| 706 | + | } |
|
| 707 | + | let Some(path) = path else { |
|
| 708 | + | ctx.err(a.span(), format!("artifact {name:?} needs a path")); |
|
| 709 | + | continue; |
|
| 710 | + | }; |
|
| 711 | + | if job.outputs.iter().any(|o| o.name == name) { |
|
| 712 | + | ctx.err(a.span(), format!("artifact {name:?} is given twice")); |
|
| 713 | + | } else if job.outputs.len() == MAX_OUTPUTS { |
|
| 714 | + | ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts")); |
|
| 715 | + | } else { |
|
| 716 | + | job.outputs.push(Output { name, path }); |
|
| 717 | + | } |
|
| 718 | + | } |
|
| 719 | + | } |
|
| 720 | + | ||
| 647 | 721 | fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> { |
|
| 648 | 722 | let name = ctx.one_string(node)?; |
|
| 649 | 723 | let named = ctx.name_ok(node.span(), "job", &name); |
|
| … | 5 unchanged lines |
||
| 655 | 729 | env: BTreeMap::new(), |
|
| 656 | 730 | exec: vec![], |
|
| 657 | 731 | mounts: vec![], |
|
| 732 | + | outputs: vec![], |
|
| 658 | 733 | }; |
|
| 659 | 734 | let mut seen = BTreeSet::new(); |
|
| 735 | + | let mut outputs_at = None; |
|
| 660 | 736 | for c in block(ctx, node) { |
|
| 661 | 737 | let key = c.name().value(); |
|
| 662 | 738 | if key != "scope" && key != "depends-on" && key != "mount" && !seen.insert(key.to_string()) { |
|
| … | 45 unchanged lines |
||
| 708 | 784 | } |
|
| 709 | 785 | } |
|
| 710 | 786 | } |
|
| 787 | + | "output" => { |
|
| 788 | + | outputs_at = Some(c.span()); |
|
| 789 | + | parse_output(ctx, c, &mut job); |
|
| 790 | + | } |
|
| 711 | 791 | n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")), |
|
| 712 | 792 | other => ctx.err(c.span(), format!("unknown job setting `{other}`")), |
|
| 713 | 793 | } |
|
| 714 | 794 | } |
|
| 715 | 795 | job.mounts.sort_by(|a, b| a.target.cmp(&b.target)); |
|
| 796 | + | job.outputs.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 797 | + | // A tmpfs is gone when the job ends; nothing on it can be kept. |
|
| 798 | + | for o in &job.outputs { |
|
| 799 | + | if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) { |
|
| 800 | + | ctx.err( |
|
| 801 | + | outputs_at.unwrap_or(node.span()), |
|
| 802 | + | format!( |
|
| 803 | + | "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk", |
|
| 804 | + | o.name, m.target |
|
| 805 | + | ), |
|
| 806 | + | ); |
|
| 807 | + | } |
|
| 808 | + | } |
|
| 716 | 809 | if job.sandbox.is_empty() && !seen.contains("sandbox") { |
|
| 717 | 810 | ctx.err(node.span(), format!("job {name:?} needs a sandbox")); |
|
| 718 | 811 | } |
|
| … | 9 unchanged lines |
modified crates/flow-core/src/tests.rs +34 −0
| … | 323 unchanged lines |
||
| 324 | 324 | let bad = problems(&src(" env {\n BAD-NAME \"x\"\n }\n")); |
|
| 325 | 325 | assert!(bad.iter().any(|(_, m)| m.contains("letters, digits and _")), "{bad:?}"); |
|
| 326 | 326 | } |
|
| 327 | + | ||
| 328 | + | #[test] |
|
| 329 | + | fn artifacts_are_declared_checked_and_hashed() { |
|
| 330 | + | let src = |out: &str| { |
|
| 331 | + | doc(&format!( |
|
| 332 | + | "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" |
|
| 333 | + | )) |
|
| 334 | + | }; |
|
| 335 | + | let p = Pipeline::parse(&src( |
|
| 336 | + | " 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", |
|
| 337 | + | )) |
|
| 338 | + | .unwrap(); |
|
| 339 | + | let names: Vec<(&str, &str)> = p.jobs["build"].outputs.iter().map(|o| (o.name.as_str(), o.path.as_str())).collect(); |
|
| 340 | + | assert_eq!(names, [("docs", "/src/docs/tool.1"), ("tool", "/src/target/release/tool")], "sorted by name"); |
|
| 341 | + | // Artifacts are part of the job's hash; a job without them hashes as before. |
|
| 342 | + | let plain = Pipeline::parse(&src("")).unwrap(); |
|
| 343 | + | let sb = &p.sandboxes["alpine"]; |
|
| 344 | + | assert_ne!(p.jobs["build"].hash(sb), plain.jobs["build"].hash(sb)); |
|
| 345 | + | assert!(plain.jobs["build"].outputs.is_empty()); |
|
| 346 | + | let json = serde_json::to_string(&plain.jobs["build"]).unwrap(); |
|
| 347 | + | assert!(!json.contains("outputs"), "no outputs field when there are none: {json}"); |
|
| 348 | + | ||
| 349 | + | let bad = |out: &str| problems(&src(out)).into_iter().map(|(_, m)| m).collect::<Vec<_>>().join("\n"); |
|
| 350 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/etc/passwd\"\n }\n }\n") |
|
| 351 | + | .contains("must be an absolute path under /src")); |
|
| 352 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/../etc\"\n }\n }\n") |
|
| 353 | + | .contains("must be an absolute path under /src")); |
|
| 354 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/tmp/a\"\n }\n }\n").contains("tmpfs")); |
|
| 355 | + | assert!(bad(" output {\n artifact \"x\" {\n }\n }\n").contains("needs a path")); |
|
| 356 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n }\n artifact \"x\" {\n path \"/src/b\"\n }\n }\n") |
|
| 357 | + | .contains("given twice")); |
|
| 358 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n format \"tar\"\n }\n }\n") |
|
| 359 | + | .contains("format")); |
|
| 360 | + | } |
modified crates/flow/src/main.rs +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | mod cpio; |
|
| 5 | 5 | mod home; |
|
| 6 | 6 | mod microvm; |
|
| 7 | + | mod outputs; |
|
| 7 | 8 | mod runner; |
|
| 8 | 9 | mod workspace; |
|
| 9 | 10 | ||
| … | 288 unchanged lines |
modified crates/flow/src/runner.rs +60 −3
| … | 47 unchanged lines |
||
| 48 | 48 | Ok(()) |
|
| 49 | 49 | } |
|
| 50 | 50 | ||
| 51 | + | /// Upload artifact `name` of job `job` (spec §5.2); the hub keeps it as |
|
| 52 | + | /// a blob of the job's repository and answers with its ObjectRef. |
|
| 53 | + | pub fn artifact(&self, job: i64, name: &str, bytes: &[u8]) -> Result<String> { |
|
| 54 | + | let url = format!("{}/api/v1/flow/jobs/{job}/artifacts/{name}", self.base); |
|
| 55 | + | let mut delays = RETRY_DELAYS.iter(); |
|
| 56 | + | loop { |
|
| 57 | + | let req = self |
|
| 58 | + | .agent |
|
| 59 | + | .post(&url) |
|
| 60 | + | .timeout(Duration::from_secs(600)) |
|
| 61 | + | .set("Authorization", &format!("Bearer {}", self.token)) |
|
| 62 | + | .set("Content-Type", "application/octet-stream"); |
|
| 63 | + | match req.send_bytes(bytes) { |
|
| 64 | + | Ok(r) => { |
|
| 65 | + | let v: serde_json::Value = r.into_json()?; |
|
| 66 | + | return v["blob"].as_str().map(String::from).context("the hub named no blob"); |
|
| 67 | + | } |
|
| 68 | + | Err(ureq::Error::Status(code, r)) => { |
|
| 69 | + | let text = r.into_string().unwrap_or_default(); |
|
| 70 | + | match delays.next() { |
|
| 71 | + | Some(d) if code >= 500 => std::thread::sleep(*d), |
|
| 72 | + | _ => bail!("{url}: HTTP {code}: {text}"), |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | Err(e) => bail!("{url}: {e}"), |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 51 | 80 | pub fn finish(&self, job: i64, f: &Finish) -> Result<()> { |
|
| 52 | 81 | self.post(&format!("/flow/jobs/{job}/finish"), f)?; |
|
| 53 | 82 | Ok(()) |
|
| … | 207 unchanged lines |
||
| 261 | 290 | Duration::from_secs(claim.timeout_s), |
|
| 262 | 291 | &mut |r| sink.push(r), |
|
| 263 | 292 | )?; |
|
| 264 | - | let receipt = outcome.exit_code.map(|code| Receipt { |
|
| 293 | + | // A passing job's artifacts, read from its disks and uploaded; a |
|
| 294 | + | // declared artifact that cannot be kept fails the job. |
|
| 295 | + | let mut outputs = vec![]; |
|
| 296 | + | let mut output_error = None; |
|
| 297 | + | if outcome.exit_code == Some(0) { |
|
| 298 | + | for o in &claim.job.outputs { |
|
| 299 | + | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 300 | + | let kept = crate::outputs::read(&image, &inner, &scratch).and_then(|bytes| { |
|
| 301 | + | let blob = Repo::blob_ref(&bytes); |
|
| 302 | + | let named = hub.artifact(claim.job_id, &o.name, &bytes)?; |
|
| 303 | + | if named != blob.to_hex() { |
|
| 304 | + | bail!("the hub kept it as {named}, not {}", blob.to_hex()); |
|
| 305 | + | } |
|
| 306 | + | note(&mut sink, format!("artifact {}: {} bytes, blob {}", o.name, bytes.len(), &named[..12])); |
|
| 307 | + | Ok(blob.0) |
|
| 308 | + | }); |
|
| 309 | + | match kept { |
|
| 310 | + | Ok(b) => outputs.push(b), |
|
| 311 | + | Err(e) => { |
|
| 312 | + | output_error = Some(format!("artifact {}: {e:#}", o.name)); |
|
| 313 | + | break; |
|
| 314 | + | } |
|
| 315 | + | } |
|
| 316 | + | } |
|
| 317 | + | } |
|
| 318 | + | if let Some(e) = &output_error { |
|
| 319 | + | note(&mut sink, e.clone()); |
|
| 320 | + | } |
|
| 321 | + | let receipt = outcome.exit_code.filter(|_| output_error.is_none()).map(|code| Receipt { |
|
| 265 | 322 | job_name: claim.job.name.clone(), |
|
| 266 | 323 | pipeline_hash: hex32(&claim.pipeline_hash), |
|
| 267 | 324 | scope_tree_hash: scope_hash, |
|
| 268 | 325 | inputs: vec![], |
|
| 269 | 326 | environment: outcome.environment.hash(), |
|
| 270 | - | outputs: vec![], |
|
| 327 | + | outputs, |
|
| 271 | 328 | exit_code: code as u32, |
|
| 272 | 329 | duration_ms: outcome.duration_ms, |
|
| 273 | 330 | }); |
|
| … | 6 unchanged lines |
||
| 280 | 337 | duration_ms: started.elapsed().as_millis() as u64, |
|
| 281 | 338 | receipt, |
|
| 282 | 339 | signed_receipt, |
|
| 283 | - | error: outcome.timed_out.then(|| "stopped at the time limit".to_string()), |
|
| 340 | + | error: output_error.or_else(|| outcome.timed_out.then(|| "stopped at the time limit".to_string())), |
|
| 284 | 341 | }) |
|
| 285 | 342 | })(); |
|
| 286 | 343 | let _ = std::fs::remove_dir_all(&scratch); |
|
| … | 93 unchanged lines |
modified docs/m0.md +14 −4
| … | 52 unchanged lines |
||
| 53 | 53 | - Names are 1 to 64 characters from `[A-Za-z0-9._-]`. |
|
| 54 | 54 | - `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1). |
|
| 55 | 55 | A job that wants a shell names one, as `sh` above; nothing adds one. |
|
| 56 | - | - `output`, `publish` and `secret` are recognised and refused with |
|
| 57 | - | "not in M0", so a pipeline written for the full spec fails clearly. |
|
| 56 | + | - `publish` and `secret` are recognised and refused with "not in M0", so |
|
| 57 | + | a pipeline written for the full spec fails clearly. |
|
| 58 | + | - `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares |
|
| 59 | + | up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under |
|
| 60 | + | `/src`, and not on a tmpfs mount, which is gone when the job ends. After |
|
| 61 | + | a job passes, the runner reads each artifact from the job's disks with |
|
| 62 | + | `debugfs`: the workspace, or the `ephemeral://disk` mount holding it. An |
|
| 63 | + | artifact is at most 512 MiB. The runner uploads each one with |
|
| 64 | + | `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes). The hub keeps |
|
| 65 | + | it as a blob of the repository, and the receipt's `outputs` name those |
|
| 66 | + | blobs in artifact order. A declared artifact that cannot be kept fails |
|
| 67 | + | the job, and the job gets no receipt. |
|
| 58 | 68 | - `mount` is accepted for **ephemeral scratch** only (up to 4 per job): |
|
| 59 | 69 | `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`. |
|
| 60 | 70 | `ephemeral://tmpfs` is memory in the guest and counts against its |
|
| … | 132 unchanged lines |
||
| 193 | 203 | ## 7. Not in M0 |
|
| 194 | 204 | ||
| 195 | 205 | WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree |
|
| 196 | - | into an image); mounts other than ephemeral scratch, `output` and artifacts; receipt reuse; provenance |
|
| 197 | - | attestations and composite signatures; policy gates; secrets; proposal |
|
| 206 | + | into an image); mounts other than ephemeral scratch; artifacts as inputs of later jobs; receipt reuse; provenance |
|
| 207 | + | attestations; policy gates; secrets; proposal |
|
| 198 | 208 | runs; scope-based skipping; re-run and cancel from the hub. |