Report outputs: a directory with report.toml, uploaded file by file with a manifest 6d53fb7d00ec
A job's output block takes report "NAME" { path; when } beside artifact.
The runner reads the directory without following links (debugfs rdump on
the MicroVM tier, checked components on the syscall tier), within 2000
files and 512 MiB, uploads each file to the hub's report files endpoint
and then a manifest under the report's name; the receipt names the
manifest. The kind is part of the job's hash.
refs andymccall/oxygenhub#127
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-10 23:41 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
6d53fb7d00eca0eeaf62fe6094a88849c03bd2e57846f9e747224566cf5b1001- Depends on
- 1abadd4a2fa6 3007d8f011fa 47c1d64dec8a 9a6fac2b40a9 a083cc802892 a67b97e9a717 e0918b887850 e2c125610f43 ea56c56eaf90 fc10d9e80383
- Depended on by
- 28cc603f737d e20897216221 46fe2c815cc3 ea81796bb757 89171fca079c d60aac6c3f8a
- 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
- 27 atoms, 0 tree operations, 7 files
Browse the tree at this change
modified crates/flow-core/src/canonical.rs +10 −2
| … | 72 unchanged lines |
||
| 73 | 73 | ), |
|
| 74 | 74 | )); |
|
| 75 | 75 | } |
|
| 76 | - | // Artifacts as [name, path] sorted by name; absent when there are none. |
|
| 76 | + | // Outputs as [name, path] sorted by name, a report's with "report" |
|
| 77 | + | // after them; absent when there are none. |
|
| 77 | 78 | if !j.outputs.is_empty() { |
|
| 78 | - | fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect()))); |
|
| 79 | + | let output = |o: &crate::pipeline::Output| { |
|
| 80 | + | let mut v = vec![text(&o.name), text(&o.path)]; |
|
| 81 | + | if !o.kind.is_artifact() { |
|
| 82 | + | v.push(text(o.kind.word())); |
|
| 83 | + | } |
|
| 84 | + | Value::Array(v) |
|
| 85 | + | }; |
|
| 86 | + | fields.push((7, Value::Array(j.outputs.iter().map(output).collect()))); |
|
| 79 | 87 | } |
|
| 80 | 88 | // Secrets as [name, mount path] sorted by name; absent when there are none. |
|
| 81 | 89 | if !j.secrets.is_empty() { |
|
| … | 50 unchanged lines |
modified crates/flow-core/src/lib.rs +2 −1
| … | 11 unchanged lines |
||
| 12 | 12 | ||
| 13 | 13 | pub use canonical::{object_hash, scope_tree_hash, Hash}; |
|
| 14 | 14 | pub use pipeline::{ |
|
| 15 | - | arch_ok, Identity, Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, Secret, PIPELINE_PATH, |
|
| 15 | + | arch_ok, Identity, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, Problems, Runtime, Sandbox, Secret, |
|
| 16 | + | PIPELINE_PATH, |
|
| 16 | 17 | }; |
|
| 17 | 18 | pub use receipt::{Environment, Receipt}; |
|
| 18 | 19 | ||
| … | 2 unchanged lines |
modified crates/flow-core/src/pipeline.rs +49 −17
| … | 253 unchanged lines |
||
| 254 | 254 | /// (oxygenflow#37). From a failing job it is never in the receipt. |
|
| 255 | 255 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 256 | 256 | pub always: bool, |
|
| 257 | + | /// An artifact is one file; a report is a directory with a |
|
| 258 | + | /// `report.toml`, kept file by file with a manifest (oxygenhub#127). |
|
| 259 | + | #[serde(default, skip_serializing_if = "OutputKind::is_artifact")] |
|
| 260 | + | pub kind: OutputKind, |
|
| 257 | 261 | } |
|
| 258 | 262 | ||
| 263 | + | /// What an [`Output`] is. |
|
| 264 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| 265 | + | #[serde(rename_all = "lowercase")] |
|
| 266 | + | pub enum OutputKind { |
|
| 267 | + | #[default] |
|
| 268 | + | Artifact, |
|
| 269 | + | Report, |
|
| 270 | + | } |
|
| 271 | + | ||
| 272 | + | impl OutputKind { |
|
| 273 | + | pub fn is_artifact(&self) -> bool { |
|
| 274 | + | *self == OutputKind::Artifact |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | pub fn word(&self) -> &'static str { |
|
| 278 | + | match self { |
|
| 279 | + | OutputKind::Artifact => "artifact", |
|
| 280 | + | OutputKind::Report => "report", |
|
| 281 | + | } |
|
| 282 | + | } |
|
| 283 | + | } |
|
| 284 | + | ||
| 259 | 285 | /// A secret the job reads as a file (spec §9): never in the environment. |
|
| 260 | 286 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 261 | 287 | pub struct Secret { |
|
| … | 796 unchanged lines |
||
| 1058 | 1084 | ||
| 1059 | 1085 | fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) { |
|
| 1060 | 1086 | if !node.entries().is_empty() { |
|
| 1061 | - | ctx.err(node.span(), "`output` takes a block of artifacts"); |
|
| 1087 | + | ctx.err(node.span(), "`output` takes a block of artifacts and reports"); |
|
| 1062 | 1088 | } |
|
| 1063 | 1089 | for a in block(ctx, node) { |
|
| 1064 | - | if a.name().value() != "artifact" { |
|
| 1065 | - | ctx.err(a.span(), format!("unknown output `{}`; outputs are `artifact \"name\" {{ path \"/src/…\" }}`", a.name().value())); |
|
| 1066 | - | continue; |
|
| 1067 | - | } |
|
| 1090 | + | let kind = match a.name().value() { |
|
| 1091 | + | "artifact" => OutputKind::Artifact, |
|
| 1092 | + | "report" => OutputKind::Report, |
|
| 1093 | + | other => { |
|
| 1094 | + | ctx.err( |
|
| 1095 | + | a.span(), |
|
| 1096 | + | format!("unknown output `{other}`; outputs are `artifact \"name\" {{ path \"/src/file\" }}` and `report \"name\" {{ path \"/src/dir\" }}`"), |
|
| 1097 | + | ); |
|
| 1098 | + | continue; |
|
| 1099 | + | } |
|
| 1100 | + | }; |
|
| 1101 | + | let word = kind.word(); |
|
| 1068 | 1102 | let Some(name) = ctx.one_string(a) else { continue }; |
|
| 1069 | - | if !ctx.name_ok(a.span(), "artifact", &name) { |
|
| 1103 | + | if !ctx.name_ok(a.span(), word, &name) { |
|
| 1070 | 1104 | continue; |
|
| 1071 | 1105 | } |
|
| 1072 | 1106 | let mut path = None; |
|
| … | 3 unchanged lines |
||
| 1076 | 1110 | match c.name().value() { |
|
| 1077 | 1111 | "path" => match ctx.one_string(c) { |
|
| 1078 | 1112 | Some(p) if output_path_ok(&p) => path = Some(p), |
|
| 1079 | - | Some(p) => ctx.err( |
|
| 1080 | - | c.span(), |
|
| 1081 | - | format!("artifact path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"), |
|
| 1082 | - | ), |
|
| 1113 | + | Some(p) => ctx |
|
| 1114 | + | .err(c.span(), format!("{word} path {p:?} must be an absolute path under /src, without . or .., quotes or spaces")), |
|
| 1083 | 1115 | None => {} |
|
| 1084 | 1116 | }, |
|
| 1085 | - | "format" => { |
|
| 1117 | + | "format" if kind == OutputKind::Artifact => { |
|
| 1086 | 1118 | if ctx.one_string(c).as_deref() != Some("blob") { |
|
| 1087 | 1119 | ctx.err(c.span(), "artifact format is \"blob\""); |
|
| 1088 | 1120 | } |
|
| … | 1 unchanged lines |
||
| 1090 | 1122 | "when" => match ctx.one_string(c).as_deref() { |
|
| 1091 | 1123 | Some("passed") => always = false, |
|
| 1092 | 1124 | Some("always") => always = true, |
|
| 1093 | - | Some(_) => ctx.err(c.span(), "artifact `when` is \"passed\" (the default) or \"always\""), |
|
| 1125 | + | Some(_) => ctx.err(c.span(), format!("{word} `when` is \"passed\" (the default) or \"always\"")), |
|
| 1094 | 1126 | None => {} |
|
| 1095 | 1127 | }, |
|
| 1096 | - | other => ctx.err(c.span(), format!("unknown artifact setting `{other}`")), |
|
| 1128 | + | other => ctx.err(c.span(), format!("unknown {word} setting `{other}`")), |
|
| 1097 | 1129 | } |
|
| 1098 | 1130 | } |
|
| 1099 | 1131 | let Some(path) = path else { |
|
| 1100 | - | ctx.err(a.span(), format!("artifact {name:?} needs a path")); |
|
| 1132 | + | ctx.err(a.span(), format!("{word} {name:?} needs a path")); |
|
| 1101 | 1133 | continue; |
|
| 1102 | 1134 | }; |
|
| 1103 | 1135 | if job.outputs.iter().any(|o| o.name == name) { |
|
| 1104 | - | ctx.err(a.span(), format!("artifact {name:?} is given twice")); |
|
| 1136 | + | ctx.err(a.span(), format!("output {name:?} is given twice (artifacts and reports share names)")); |
|
| 1105 | 1137 | } else if job.outputs.len() == MAX_OUTPUTS { |
|
| 1106 | - | ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts")); |
|
| 1138 | + | ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports")); |
|
| 1107 | 1139 | } else { |
|
| 1108 | - | job.outputs.push(Output { name, path, always }); |
|
| 1140 | + | job.outputs.push(Output { name, path, always, kind }); |
|
| 1109 | 1141 | } |
|
| 1110 | 1142 | } |
|
| 1111 | 1143 | } |
|
| … | 126 unchanged lines |
modified crates/flow-core/src/tests.rs +15 −0
| … | 474 unchanged lines |
||
| 475 | 475 | assert!(serde_json::to_string(&outs[0]).unwrap().contains("\"always\":true")); |
|
| 476 | 476 | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n when \"failed\"\n }\n }\n") |
|
| 477 | 477 | .contains("\"passed\" (the default) or \"always\"")); |
|
| 478 | + | ||
| 479 | + | // oxygenhub#127: a report is a directory output; it shares names with |
|
| 480 | + | // artifacts, and its kind is part of the job's hash. |
|
| 481 | + | let as_kind = |kind: &str| { |
|
| 482 | + | Pipeline::parse(&src(&format!(" output {{\n {kind} \"perf\" {{\n path \"/src/target/report\"\n when \"always\"\n }}\n }}\n"))).unwrap() |
|
| 483 | + | }; |
|
| 484 | + | let (report, artifact) = (as_kind("report"), as_kind("artifact")); |
|
| 485 | + | let o = &report.jobs["build"].outputs[0]; |
|
| 486 | + | assert!(o.kind == OutputKind::Report && o.always && o.path == "/src/target/report"); |
|
| 487 | + | assert!(serde_json::to_string(o).unwrap().contains("\"kind\":\"report\"")); |
|
| 488 | + | assert!(!serde_json::to_string(&artifact.jobs["build"].outputs[0]).unwrap().contains("kind")); |
|
| 489 | + | assert_ne!(report.jobs["build"].hash(sb), artifact.jobs["build"].hash(sb)); |
|
| 490 | + | assert!(bad(" output {\n artifact \"x\" {\n path \"/src/a\"\n }\n report \"x\" {\n path \"/src/b\"\n }\n }\n") |
|
| 491 | + | .contains("given twice")); |
|
| 492 | + | assert!(bad(" output {\n report \"x\" {\n }\n }\n").contains("needs a path")); |
|
| 478 | 493 | } |
|
| 479 | 494 | ||
| 480 | 495 | /// metalgear feedback 19 (oxygenflow#17): `\033` in a string is not KDL; |
|
| … | 85 unchanged lines |
modified crates/flow/src/outputs.rs +126 −0
| … | 79 unchanged lines |
||
| 80 | 80 | Ok(bytes) |
|
| 81 | 81 | } |
|
| 82 | 82 | ||
| 83 | + | /// Most files a report keeps (oxygenhub#127). |
|
| 84 | + | pub const MAX_REPORT_FILES: usize = 2000; |
|
| 85 | + | /// Most bytes a report's files hold together. |
|
| 86 | + | pub const MAX_REPORT_BYTES: u64 = 512 << 20; |
|
| 87 | + | ||
| 88 | + | /// Report directory `inner` of ext4 image `image`: dumped whole with |
|
| 89 | + | /// debugfs into `scratch`, then read as [`read_tree`] does. |
|
| 90 | + | pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> { |
|
| 91 | + | let stat = debugfs(image, &format!("stat \"{inner}\""))?; |
|
| 92 | + | if !stat.contains("Type: directory") { |
|
| 93 | + | bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml"); |
|
| 94 | + | } |
|
| 95 | + | let dest = scratch.join("report.out"); |
|
| 96 | + | let _ = std::fs::remove_dir_all(&dest); |
|
| 97 | + | std::fs::create_dir_all(&dest)?; |
|
| 98 | + | debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?; |
|
| 99 | + | let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default(); |
|
| 100 | + | let files = read_tree(&dest.join(base)); |
|
| 101 | + | let _ = std::fs::remove_dir_all(&dest); |
|
| 102 | + | files |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// Report directory `inner` under `base` for the syscall tier: every |
|
| 106 | + | /// component of the path must be a directory, never a link, so a job |
|
| 107 | + | /// cannot point the runner outside its own tree. |
|
| 108 | + | pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> { |
|
| 109 | + | let mut dir = base.to_path_buf(); |
|
| 110 | + | for part in inner.split('/').filter(|p| !p.is_empty()) { |
|
| 111 | + | if part == "." || part == ".." { |
|
| 112 | + | bail!("{inner} is not a clean path"); |
|
| 113 | + | } |
|
| 114 | + | dir.push(part); |
|
| 115 | + | let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?; |
|
| 116 | + | if !meta.is_dir() { |
|
| 117 | + | bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml"); |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | read_tree(&dir) |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path |
|
| 124 | + | /// travels in a header and as a URL as it is. |
|
| 125 | + | fn report_name_ok(n: &str) -> bool { |
|
| 126 | + | !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b)) |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | /// The regular files under host directory `root`, by `/`-separated path |
|
| 130 | + | /// relative to it, sorted. Links and anything that is not a file or a |
|
| 131 | + | /// directory are left out, never followed. Within the report limits, and |
|
| 132 | + | /// `report.toml` must be at the top. |
|
| 133 | + | pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> { |
|
| 134 | + | let mut out: Vec<(String, Vec<u8>)> = Vec::new(); |
|
| 135 | + | let mut total = 0u64; |
|
| 136 | + | let mut stack = vec![(root.to_path_buf(), String::new())]; |
|
| 137 | + | while let Some((dir, rel)) = stack.pop() { |
|
| 138 | + | for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { |
|
| 139 | + | let entry = entry?; |
|
| 140 | + | let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?; |
|
| 141 | + | let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") }; |
|
| 142 | + | if !report_name_ok(&name) { |
|
| 143 | + | bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'"); |
|
| 144 | + | } |
|
| 145 | + | let meta = std::fs::symlink_metadata(entry.path())?; |
|
| 146 | + | if meta.is_dir() { |
|
| 147 | + | stack.push((entry.path(), path)); |
|
| 148 | + | } else if meta.is_file() { |
|
| 149 | + | if meta.len() > MAX_ARTIFACT_BYTES { |
|
| 150 | + | bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20); |
|
| 151 | + | } |
|
| 152 | + | total += meta.len(); |
|
| 153 | + | if total > MAX_REPORT_BYTES { |
|
| 154 | + | bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20); |
|
| 155 | + | } |
|
| 156 | + | if out.len() == MAX_REPORT_FILES { |
|
| 157 | + | bail!("the report holds more than {MAX_REPORT_FILES} files"); |
|
| 158 | + | } |
|
| 159 | + | out.push((path, std::fs::read(entry.path())?)); |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | if !out.iter().any(|(p, _)| p == "report.toml") { |
|
| 164 | + | bail!("the report directory has no report.toml at its top"); |
|
| 165 | + | } |
|
| 166 | + | out.sort(); |
|
| 167 | + | Ok(out) |
|
| 168 | + | } |
|
| 169 | + | ||
| 83 | 170 | fn debugfs(image: &Path, request: &str) -> Result<String> { |
|
| 84 | 171 | let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?; |
|
| 85 | 172 | if !out.status.success() { |
|
| … | 85 unchanged lines |
||
| 171 | 258 | } |
|
| 172 | 259 | ||
| 173 | 260 | #[test] |
|
| 261 | + | fn reports_are_read_as_trees_without_following_links() { |
|
| 262 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 263 | + | let tree = dir.path().join("tree"); |
|
| 264 | + | let report = tree.join("out/report"); |
|
| 265 | + | std::fs::create_dir_all(report.join("data")).unwrap(); |
|
| 266 | + | std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap(); |
|
| 267 | + | std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap(); |
|
| 268 | + | std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap(); |
|
| 269 | + | std::fs::write(dir.path().join("secret"), b"runner key").unwrap(); |
|
| 270 | + | std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap(); |
|
| 271 | + | std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap(); |
|
| 272 | + | let files = read_report_dir(&tree, "/out/report").unwrap(); |
|
| 273 | + | let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect(); |
|
| 274 | + | assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out"); |
|
| 275 | + | assert_eq!(files[0].1, b"1,2\n"); |
|
| 276 | + | ||
| 277 | + | std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap(); |
|
| 278 | + | assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory"); |
|
| 279 | + | assert!(read_report_dir(&tree, "/out/../out/report").is_err()); |
|
| 280 | + | assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file"); |
|
| 281 | + | ||
| 282 | + | std::fs::remove_file(report.join("report.toml")).unwrap(); |
|
| 283 | + | assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml")); |
|
| 284 | + | std::fs::write(report.join("report.toml"), b"").unwrap(); |
|
| 285 | + | std::fs::write(report.join("bad name"), b"").unwrap(); |
|
| 286 | + | assert!(read_tree(&report).is_err(), "a name that cannot travel as is"); |
|
| 287 | + | } |
|
| 288 | + | ||
| 289 | + | #[test] |
|
| 174 | 290 | fn files_are_read_back_from_an_ext4_image() { |
|
| 175 | 291 | let dir = tempfile::tempdir().unwrap(); |
|
| 176 | 292 | let tree = dir.path().join("tree"); |
|
| … | 6 unchanged lines |
||
| 183 | 299 | assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body); |
|
| 184 | 300 | assert!(read(&image, "/release/missing", dir.path()).is_err()); |
|
| 185 | 301 | assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file")); |
|
| 302 | + | ||
| 303 | + | std::fs::create_dir_all(tree.join("report/data")).unwrap(); |
|
| 304 | + | std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap(); |
|
| 305 | + | std::fs::write(tree.join("report/data/a.csv"), &body).unwrap(); |
|
| 306 | + | std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap(); |
|
| 307 | + | crate::workspace::image(&tree, &image).unwrap(); |
|
| 308 | + | let files = read_report(&image, "/report", dir.path()).unwrap(); |
|
| 309 | + | assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]); |
|
| 310 | + | assert_eq!(files[0].1, body); |
|
| 311 | + | assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report"); |
|
| 186 | 312 | } |
|
| 187 | 313 | } |
modified crates/flow/src/runner.rs +81 −0
| … | 122 unchanged lines |
||
| 123 | 123 | } |
|
| 124 | 124 | } |
|
| 125 | 125 | ||
| 126 | + | /// Upload file `path` of report `name` of job `job` (oxygenhub#127); |
|
| 127 | + | /// the hub keeps it as a blob and answers with its ObjectRef. The |
|
| 128 | + | /// report's manifest follows through [`Hub::artifact`]. |
|
| 129 | + | pub fn report_file(&self, job: i64, name: &str, path: &str, bytes: &[u8]) -> Result<String> { |
|
| 130 | + | let url = format!("{}/api/v1/flow/jobs/{job}/reports/{name}/files", self.base); |
|
| 131 | + | let mut delays = RETRY_DELAYS.iter(); |
|
| 132 | + | loop { |
|
| 133 | + | let req = self |
|
| 134 | + | .agent |
|
| 135 | + | .post(&url) |
|
| 136 | + | .timeout(Duration::from_secs(600)) |
|
| 137 | + | .set("Authorization", &format!("Bearer {}", self.token)) |
|
| 138 | + | .set("Content-Type", "application/octet-stream") |
|
| 139 | + | .set("X-Flow-Report-Path", path); |
|
| 140 | + | match req.send_bytes(bytes) { |
|
| 141 | + | Ok(r) => { |
|
| 142 | + | let v: serde_json::Value = r.into_json()?; |
|
| 143 | + | return v["blob"].as_str().map(String::from).context("the hub named no blob"); |
|
| 144 | + | } |
|
| 145 | + | Err(ureq::Error::Status(code, r)) => { |
|
| 146 | + | let text = r.into_string().unwrap_or_default(); |
|
| 147 | + | match delays.next() { |
|
| 148 | + | Some(d) if code >= 500 => std::thread::sleep(*d), |
|
| 149 | + | _ => bail!("{url}: HTTP {code}: {text}"), |
|
| 150 | + | } |
|
| 151 | + | } |
|
| 152 | + | Err(e) => bail!("{url}: {e}"), |
|
| 153 | + | } |
|
| 154 | + | } |
|
| 155 | + | } |
|
| 156 | + | ||
| 126 | 157 | /// Report a job's end until the hub has it (oxygenhub#71): while the hub |
|
| 127 | 158 | /// cannot be reached or fails (5xx), try again after `first_wait`, |
|
| 128 | 159 | /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended |
|
| … | 439 unchanged lines |
||
| 568 | 599 | let passed = outcome.exit_code == Some(0); |
|
| 569 | 600 | let wanted = claim.job.outputs.iter().filter(|o| passed || o.always); |
|
| 570 | 601 | for o in wanted { |
|
| 602 | + | if o.kind == flow_core::OutputKind::Report { |
|
| 603 | + | let read = if syscall_tier { |
|
| 604 | + | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
|
| 605 | + | crate::outputs::read_report_dir(&dir, &inner) |
|
| 606 | + | } else { |
|
| 607 | + | let (image, inner) = crate::outputs::locate(&claim.job, &scratch.join("workspace.ext4"), &scratch, &o.path); |
|
| 608 | + | crate::outputs::read_report(&image, &inner, &scratch) |
|
| 609 | + | }; |
|
| 610 | + | let kept = read.and_then(|files| upload_report(hub, claim.job_id, &o.name, &files)); |
|
| 611 | + | match kept { |
|
| 612 | + | Ok((blob, files, bytes)) => { |
|
| 613 | + | let unattested = if passed { "" } else { " (the job failed: not in its receipt)" }; |
|
| 614 | + | let hex = blob.to_hex(); |
|
| 615 | + | note(&mut sink, format!("report {}: {files} files, {bytes} bytes, manifest {}{unattested}", o.name, &hex[..12])); |
|
| 616 | + | if passed { |
|
| 617 | + | outputs.push(blob.0); |
|
| 618 | + | } |
|
| 619 | + | } |
|
| 620 | + | Err(e) if passed => { |
|
| 621 | + | output_error = Some(format!("report {}: {e:#}", o.name)); |
|
| 622 | + | break; |
|
| 623 | + | } |
|
| 624 | + | Err(e) => note(&mut sink, format!("report {}: not kept: {e:#}", o.name)), |
|
| 625 | + | } |
|
| 626 | + | continue; |
|
| 627 | + | } |
|
| 571 | 628 | let read = if syscall_tier { |
|
| 572 | 629 | let (dir, inner) = crate::outputs::locate_dir(&claim.job, &scratch.join("tree"), &scratch, &o.path); |
|
| 573 | 630 | crate::outputs::read_dir_file(&dir, &inner) |
|
| … | 54 unchanged lines |
||
| 628 | 685 | /// Bind `receipt` to the state of `heads` in `repo` and sign it as the |
|
| 629 | 686 | /// runner (Oxygen spec §14.6). The state is computed here, not taken from |
|
| 630 | 687 | /// the claim, so the runner vouches only for what it fetched. |
|
| 688 | + | /// Upload a report's files, then its manifest under the report's name, and |
|
| 689 | + | /// check the hub kept each as the blob it should (oxygenhub#127). Answers |
|
| 690 | + | /// the manifest's blob, the file count and their total size. |
|
| 691 | + | fn upload_report(hub: &Hub, job: i64, name: &str, files: &[(String, Vec<u8>)]) -> Result<(oxy_core::hash::ObjectRef, usize, u64)> { |
|
| 692 | + | let mut listed = Vec::with_capacity(files.len()); |
|
| 693 | + | let mut total = 0u64; |
|
| 694 | + | for (path, bytes) in files { |
|
| 695 | + | let blob = Repo::blob_ref(bytes).to_hex(); |
|
| 696 | + | let named = hub.report_file(job, name, path, bytes).with_context(|| format!("uploading {path}"))?; |
|
| 697 | + | if named != blob { |
|
| 698 | + | bail!("the hub kept {path} as {named}, not {blob}"); |
|
| 699 | + | } |
|
| 700 | + | total += bytes.len() as u64; |
|
| 701 | + | listed.push(serde_json::json!({ "path": path, "blob": blob, "size": bytes.len() })); |
|
| 702 | + | } |
|
| 703 | + | let manifest = serde_json::to_vec(&serde_json::json!({ "version": 1, "files": listed }))?; |
|
| 704 | + | let blob = Repo::blob_ref(&manifest); |
|
| 705 | + | let named = hub.artifact(job, name, &manifest)?; |
|
| 706 | + | if named != blob.to_hex() { |
|
| 707 | + | bail!("the hub kept the manifest as {named}, not {}", blob.to_hex()); |
|
| 708 | + | } |
|
| 709 | + | Ok((blob, files.len(), total)) |
|
| 710 | + | } |
|
| 711 | + | ||
| 631 | 712 | fn sign_receipt( |
|
| 632 | 713 | repo: &Repo, |
|
| 633 | 714 | heads: &[ChangeHash], |
|
| … | 301 unchanged lines |
modified docs/spec.md +20 −0
| … | 248 unchanged lines |
||
| 249 | 249 | } |
|
| 250 | 250 | ``` |
|
| 251 | 251 | ||
| 252 | + | 5. A `report` output (OxygenHub `docs/reports.md`) names a directory |
|
| 253 | + | holding `report.toml` at its top. The runner reads its regular files |
|
| 254 | + | (links and anything else are left out, never followed; file names hold |
|
| 255 | + | letters, digits, `.`, `_`, `-` and `+`; at most 2000 files and 512 MiB), |
|
| 256 | + | uploads each to `POST /api/v1/flow/jobs/{job}/reports/{name}/files` with |
|
| 257 | + | its path in `X-Flow-Report-Path`, then uploads a manifest, |
|
| 258 | + | `{"version":1,"files":[{"path","blob","size"}]}`, as the output under |
|
| 259 | + | the report's name, checking every blob the hub answers. The receipt |
|
| 260 | + | names the manifest. Reports and artifacts share names; a report's kind |
|
| 261 | + | is part of the job's hash (`[name, path, "report"]`). |
|
| 262 | + | ||
| 263 | + | ```kdl |
|
| 264 | + | output { |
|
| 265 | + | report "frame-timing" { |
|
| 266 | + | path "/src/out/report" |
|
| 267 | + | when "always" |
|
| 268 | + | } |
|
| 269 | + | } |
|
| 270 | + | ``` |
|
| 271 | + | ||
| 252 | 272 | ## 6. Commutative Execution & Change Caching |
|
| 253 | 273 | ||
| 254 | 274 | In Oxygen, changes that do not share scopes (referenced atoms, placements, or |
|
| … | 282 unchanged lines |