Artifacts between jobs: input { artifact "x" from "job"; path "/src/x" } 46fe2c815cc3
flow-core parses inputs (from a job named in depends-on that declares
that artifact; clean paths under /src; at most 8; hashed as job field 10)
and the claim carries each with the blob the producer's receipt names. The
runner fetches each from /api/v1/flow/jobs/{job}/inputs/{index}, refuses
the job when its blob reference differs, writes it at its path (outside the
scope hash) and lists the blobs as the receipt's inputs. Spec 5.3.1. Tests:
parsing and every refusal (flow-core), placing and the mismatch (runner).
Refs #58
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 03:24 UTC
- Signature
- ✔ signed by Claude Code
- Hash
46fe2c815cc3fe5418562f0a5128d9e685148c29807881f49a587a5a6ea799ba- Depends on
- 1abadd4a2fa6 6d53fb7d00ec 720eb8191059 89171fca079c a083cc802892 a67b97e9a717 bb643c252515 bf7bb1b80fa3 c4285535b03a d60aac6c3f8a e0918b887850 e2c125610f43 e576e08617dc ea56c56eaf90 ea81796bb757 fc10d9e80383
- Depended on by
- 0d663e91c598 f201e1bc5bcd 1b160d372d9e 2be1fe4ad345 f4e51e63582d e20897216221
- In bookmarks
- main
- 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
- 24 atoms, 0 tree operations, 8 files
Browse the tree at this change
modified crates/flow-core/src/api.rs +17 −0
| … | 254 unchanged lines |
||
| 255 | 255 | /// them for the repository (oxygenhub#107). |
|
| 256 | 256 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 257 | 257 | pub secrets: Vec<SecretInput>, |
|
| 258 | + | /// The job's inputs (oxygenflow#58), in the job's order, each with the |
|
| 259 | + | /// blob the producing job's receipt names: fetched from |
|
| 260 | + | /// `/api/v1/flow/jobs/{job_id}/inputs/{index}` and checked against it. |
|
| 261 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 262 | + | pub inputs: Vec<ArtifactInput>, |
|
| 263 | + | } |
|
| 264 | + | ||
| 265 | + | /// An artifact a job takes (oxygenflow#58), as the hub hands it over. |
|
| 266 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 267 | + | pub struct ArtifactInput { |
|
| 268 | + | pub artifact: String, |
|
| 269 | + | /// The producing job. |
|
| 270 | + | pub from: String, |
|
| 271 | + | /// Where it goes, under `/src`. |
|
| 272 | + | pub path: String, |
|
| 273 | + | /// The Oxygen blob reference its receipt names, hex. |
|
| 274 | + | pub blob: String, |
|
| 258 | 275 | } |
|
| 259 | 276 | ||
| 260 | 277 | impl Claim { |
|
| … | 78 unchanged lines |
modified crates/flow-core/src/canonical.rs +7 −0
| … | 105 unchanged lines |
||
| 106 | 106 | if !j.identities.is_empty() { |
|
| 107 | 107 | fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect()))); |
|
| 108 | 108 | } |
|
| 109 | + | // Inputs as [artifact, from, path] sorted by path; absent when none. |
|
| 110 | + | if !j.inputs.is_empty() { |
|
| 111 | + | fields.push(( |
|
| 112 | + | 10, |
|
| 113 | + | Value::Array(j.inputs.iter().map(|i| Value::Array(vec![text(&i.artifact), text(&i.from), text(&i.path)])).collect()), |
|
| 114 | + | )); |
|
| 115 | + | } |
|
| 109 | 116 | Value::map(fields) |
|
| 110 | 117 | } |
|
| 111 | 118 | ||
| … | 41 unchanged lines |
modified crates/flow-core/src/lib.rs +2 −2
| … | 11 unchanged lines |
||
| 12 | 12 | ||
| 13 | 13 | pub use canonical::{object_hash, scope_tree_hash, Hash}; |
|
| 14 | 14 | pub use pipeline::{ |
|
| 15 | - | arch_ok, egress_allows, egress_pattern_ok, Identity, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, Problems, |
|
| 16 | - | Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH, |
|
| 15 | + | arch_ok, egress_allows, egress_pattern_ok, Identity, Input, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, |
|
| 16 | + | Problems, Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH, |
|
| 17 | 17 | }; |
|
| 18 | 18 | pub use receipt::{Environment, Receipt}; |
|
| 19 | 19 | ||
| … | 2 unchanged lines |
modified crates/flow-core/src/pipeline.rs +87 −0
| … | 171 unchanged lines |
||
| 172 | 172 | /// Files the job produces, sorted by name (spec §5.2 `output`). |
|
| 173 | 173 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 174 | 174 | pub outputs: Vec<Output>, |
|
| 175 | + | /// Artifacts of jobs this one depends on, put in its workspace before it |
|
| 176 | + | /// starts (oxygenflow#58), sorted by path. The runner checks each against |
|
| 177 | + | /// the producing job's receipt; this job's receipt lists them as inputs. |
|
| 178 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 179 | + | pub inputs: Vec<Input>, |
|
| 175 | 180 | /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub |
|
| 176 | 181 | /// supplies each by name, the runner mounts it as a file. |
|
| 177 | 182 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| … | 94 unchanged lines |
||
| 272 | 277 | /// `report.toml`, kept file by file with a manifest (oxygenhub#127). |
|
| 273 | 278 | #[serde(default, skip_serializing_if = "OutputKind::is_artifact")] |
|
| 274 | 279 | pub kind: OutputKind, |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | /// An artifact a job takes from a job it depends on (oxygenflow#58): |
|
| 283 | + | /// `input { artifact "tfplan" from "plan"; path "/src/tfplan" }`. |
|
| 284 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 285 | + | pub struct Input { |
|
| 286 | + | /// The producing job's artifact. |
|
| 287 | + | pub artifact: String, |
|
| 288 | + | /// The producing job: one this job depends on. |
|
| 289 | + | pub from: String, |
|
| 290 | + | /// Where it is put: a clean absolute path under `/src`. |
|
| 291 | + | pub path: String, |
|
| 275 | 292 | } |
|
| 293 | + | ||
| 294 | + | /// The most inputs a job takes. |
|
| 295 | + | const MAX_INPUTS: usize = 8; |
|
| 276 | 296 | ||
| 277 | 297 | /// What an [`Output`] is. |
|
| 278 | 298 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
|
| … | 300 unchanged lines |
||
| 579 | 599 | ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d)); |
|
| 580 | 600 | } else if d == &job.name { |
|
| 581 | 601 | ctx.err(*span, format!("job {:?} depends on itself", job.name)); |
|
| 602 | + | } |
|
| 603 | + | } |
|
| 604 | + | // Inputs come from a job this one depends on, which declares |
|
| 605 | + | // that artifact (oxygenflow#58). |
|
| 606 | + | for i in &job.inputs { |
|
| 607 | + | let Some((producer, _)) = jobs.get(&i.from) else { |
|
| 608 | + | ctx.err(*span, format!("job {:?} takes an input from {:?}, which is not declared", job.name, i.from)); |
|
| 609 | + | continue; |
|
| 610 | + | }; |
|
| 611 | + | if !job.depends_on.contains(&i.from) { |
|
| 612 | + | ctx.err(*span, format!("job {:?} takes {:?} from {:?}: add depends-on \"{}\"", job.name, i.artifact, i.from, i.from)); |
|
| 582 | 613 | } |
|
| 614 | + | match producer.outputs.iter().find(|o| o.name == i.artifact) { |
|
| 615 | + | Some(o) if o.kind == OutputKind::Artifact => {} |
|
| 616 | + | Some(_) => ctx.err(*span, format!("{:?} of job {:?} is a report; inputs are artifacts", i.artifact, i.from)), |
|
| 617 | + | None => ctx.err(*span, format!("job {:?} declares no artifact {:?}", i.from, i.artifact)), |
|
| 618 | + | } |
|
| 583 | 619 | } |
|
| 584 | 620 | } |
|
| 585 | 621 | let graph: BTreeMap<&str, Vec<&str>> = |
|
| … | 680 unchanged lines |
||
| 1266 | 1302 | ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports")); |
|
| 1267 | 1303 | } else { |
|
| 1268 | 1304 | job.outputs.push(Output { name, path, always, kind }); |
|
| 1305 | + | } |
|
| 1306 | + | } |
|
| 1307 | + | } |
|
| 1308 | + | ||
| 1309 | + | /// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`. |
|
| 1310 | + | fn parse_input(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) { |
|
| 1311 | + | if !node.entries().is_empty() { |
|
| 1312 | + | ctx.err(node.span(), "`input` takes a block: input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\" }"); |
|
| 1313 | + | } |
|
| 1314 | + | let (mut artifact, mut from, mut path) = (None, None, None); |
|
| 1315 | + | for c in block(ctx, node) { |
|
| 1316 | + | ctx.no_children(c); |
|
| 1317 | + | match c.name().value() { |
|
| 1318 | + | "artifact" => match ctx.strings(c).as_slice() { |
|
| 1319 | + | [name, word, producer] if word == "from" => { |
|
| 1320 | + | if ctx.name_ok(c.span(), "artifact", name) && ctx.name_ok(c.span(), "job", producer) { |
|
| 1321 | + | artifact = Some(name.clone()); |
|
| 1322 | + | from = Some(producer.clone()); |
|
| 1323 | + | } |
|
| 1324 | + | } |
|
| 1325 | + | _ => ctx.err(c.span(), "`artifact` names the artifact and its job: artifact \"tfplan\" from \"plan\""), |
|
| 1326 | + | }, |
|
| 1327 | + | "path" => match ctx.one_string(c) { |
|
| 1328 | + | Some(p) if output_path_ok(&p) => path = Some(p), |
|
| 1329 | + | Some(p) => { |
|
| 1330 | + | ctx.err(c.span(), format!("input path {p:?} must be an absolute path under /src, without . or .., quotes or spaces")) |
|
| 1331 | + | } |
|
| 1332 | + | None => {} |
|
| 1333 | + | }, |
|
| 1334 | + | other => ctx.err(c.span(), format!("unknown input setting `{other}`; an input has `artifact` and `path`")), |
|
| 1335 | + | } |
|
| 1336 | + | } |
|
| 1337 | + | match (artifact, from, path) { |
|
| 1338 | + | (Some(artifact), Some(from), Some(path)) => { |
|
| 1339 | + | if job.inputs.iter().any(|i| i.path == path) { |
|
| 1340 | + | ctx.err(node.span(), format!("two inputs are put at {path:?}")); |
|
| 1341 | + | } else if job.inputs.len() == MAX_INPUTS { |
|
| 1342 | + | ctx.err(node.span(), format!("a job takes at most {MAX_INPUTS} inputs")); |
|
| 1343 | + | } else { |
|
| 1344 | + | job.inputs.push(Input { artifact, from, path }); |
|
| 1345 | + | } |
|
| 1346 | + | } |
|
| 1347 | + | (a, _, p) => { |
|
| 1348 | + | if a.is_some() && p.is_none() { |
|
| 1349 | + | ctx.err(node.span(), "an input needs a `path`"); |
|
| 1350 | + | } else if a.is_none() && p.is_some() { |
|
| 1351 | + | ctx.err(node.span(), "an input needs `artifact \"NAME\" from \"JOB\"`"); |
|
| 1352 | + | } |
|
| 1269 | 1353 | } |
|
| 1270 | 1354 | } |
|
| 1271 | 1355 | } |
|
| … | 12 unchanged lines |
||
| 1284 | 1368 | secrets: vec![], |
|
| 1285 | 1369 | identities: vec![], |
|
| 1286 | 1370 | outputs: vec![], |
|
| 1371 | + | inputs: vec![], |
|
| 1287 | 1372 | runs_on: vec![], |
|
| 1288 | 1373 | timeout_s: None, |
|
| 1289 | 1374 | on: None, |
|
| … | 71 unchanged lines |
||
| 1361 | 1446 | outputs_at = Some(c.span()); |
|
| 1362 | 1447 | parse_output(ctx, c, &mut job); |
|
| 1363 | 1448 | } |
|
| 1449 | + | "input" => parse_input(ctx, c, &mut job), |
|
| 1364 | 1450 | "secret" => parse_secret(ctx, c, &mut job), |
|
| 1365 | 1451 | "identity" => parse_identity(ctx, c, &mut job), |
|
| 1366 | 1452 | n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")), |
|
| … | 2 unchanged lines |
||
| 1369 | 1455 | } |
|
| 1370 | 1456 | job.mounts.sort_by(|a, b| a.target.cmp(&b.target)); |
|
| 1371 | 1457 | job.outputs.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 1458 | + | job.inputs.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| 1372 | 1459 | job.secrets.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 1373 | 1460 | job.identities.sort_by(|a, b| a.audience.cmp(&b.audience)); |
|
| 1374 | 1461 | // A tmpfs is gone when the job ends; nothing on it can be kept. |
|
| … | 23 unchanged lines |
modified crates/flow-core/src/tests.rs +31 −0
| … | 442 unchanged lines |
||
| 443 | 443 | run_number: 3, |
|
| 444 | 444 | env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(), |
|
| 445 | 445 | secrets: vec![], |
|
| 446 | + | inputs: vec![], |
|
| 446 | 447 | }; |
|
| 447 | 448 | let env = job.with_flow_env(&claim.flow_env()).env; |
|
| 448 | 449 | assert_eq!(env["CC"], "clang", "the hub's variables reach the job"); |
|
| … | 189 unchanged lines |
||
| 638 | 639 | let e = Pipeline::parse(&t).unwrap_err().to_string(); |
|
| 639 | 640 | assert!(e.contains(why), "{bad:?}: {e}"); |
|
| 640 | 641 | } |
|
| 642 | + | } |
|
| 643 | + | ||
| 644 | + | #[test] |
|
| 645 | + | fn a_job_takes_an_artifact_of_a_job_it_depends_on() { |
|
| 646 | + | let jobs = |apply: &str| { |
|
| 647 | + | doc(&format!( |
|
| 648 | + | "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" |
|
| 649 | + | )) |
|
| 650 | + | }; |
|
| 651 | + | let ok = jobs(" depends-on \"plan\"\n input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n"); |
|
| 652 | + | let p = Pipeline::parse(&ok).unwrap(); |
|
| 653 | + | assert_eq!(p.jobs["apply"].inputs, [crate::Input { artifact: "tfplan".into(), from: "plan".into(), path: "/src/tfplan".into() }]); |
|
| 654 | + | let without = Pipeline::parse(&jobs(" depends-on \"plan\"\n")).unwrap(); |
|
| 655 | + | assert_ne!(p.hash(), without.hash(), "inputs are part of the job"); |
|
| 656 | + | ||
| 657 | + | let has = |apply: &str, want: &str| { |
|
| 658 | + | let ps = problems(&jobs(apply)); |
|
| 659 | + | assert!(ps.iter().any(|(_, m)| m.contains(want)), "{apply}: {ps:?}"); |
|
| 660 | + | }; |
|
| 661 | + | has(" input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n", "add depends-on \"plan\""); |
|
| 662 | + | has(" depends-on \"plan\"\n input { artifact \"nope\" from \"plan\"; path \"/src/x\"; }\n", "declares no artifact \"nope\""); |
|
| 663 | + | has(" depends-on \"plan\"\n input { artifact \"r\" from \"plan\"; path \"/src/x\"; }\n", "is a report"); |
|
| 664 | + | has(" input { artifact \"tfplan\" from \"ghost\"; path \"/src/x\"; }\n", "which is not declared"); |
|
| 665 | + | has(" depends-on \"plan\"\n input { artifact \"tfplan\" from \"plan\"; path \"/etc/x\"; }\n", "absolute path under /src"); |
|
| 666 | + | has(" depends-on \"plan\"\n input { artifact \"tfplan\" \"plan\"; path \"/src/x\"; }\n", "artifact \"tfplan\" from \"plan\""); |
|
| 667 | + | has(" depends-on \"plan\"\n input { artifact \"tfplan\" from \"plan\"; }\n", "needs a `path`"); |
|
| 668 | + | has( |
|
| 669 | + | " depends-on \"plan\"\n input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n", |
|
| 670 | + | "two inputs are put at", |
|
| 671 | + | ); |
|
| 641 | 672 | } |
modified crates/flow/src/outputs.rs +1 −0
| … | 223 unchanged lines |
||
| 224 | 224 | Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] }, |
|
| 225 | 225 | ], |
|
| 226 | 226 | outputs: vec![], |
|
| 227 | + | inputs: vec![], |
|
| 227 | 228 | runs_on: vec![], |
|
| 228 | 229 | timeout_s: None, |
|
| 229 | 230 | on: None, |
|
| … | 84 unchanged lines |
modified crates/flow/src/runner.rs +80 −2
| … | 9 unchanged lines |
||
| 10 | 10 | use flow_core::{scope_tree_hash, Receipt}; |
|
| 11 | 11 | use oxy_core::hash::ChangeHash; |
|
| 12 | 12 | use oxy_core::repo::Repo; |
|
| 13 | - | use std::path::PathBuf; |
|
| 13 | + | use std::path::{Path, PathBuf}; |
|
| 14 | 14 | use std::time::{Duration, Instant}; |
|
| 15 | 15 | ||
| 16 | 16 | /// A request the hub refused (4xx). On a job's endpoints, 409 means the |
|
| … | 107 unchanged lines |
||
| 124 | 124 | v["blob"].as_str().map(String::from).context("the hub named no blob") |
|
| 125 | 125 | } |
|
| 126 | 126 | ||
| 127 | + | /// Input `index` of job `job` (oxygenflow#58): the bytes of the |
|
| 128 | + | /// producing job's artifact, which the caller checks against the blob the |
|
| 129 | + | /// claim names. |
|
| 130 | + | pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> { |
|
| 131 | + | let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base); |
|
| 132 | + | let r = retrying(&url, &PATIENT_DELAYS, false, || { |
|
| 133 | + | self.agent |
|
| 134 | + | .get(&url) |
|
| 135 | + | .timeout(Duration::from_secs(600)) |
|
| 136 | + | .set("Authorization", &format!("Bearer {}", self.token)) |
|
| 137 | + | .call() |
|
| 138 | + | .map_err(Box::new) |
|
| 139 | + | })?; |
|
| 140 | + | let mut bytes = Vec::new(); |
|
| 141 | + | let mut reader = r.into_reader(); |
|
| 142 | + | std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?; |
|
| 143 | + | if bytes.len() > MAX_INPUT_BYTES { |
|
| 144 | + | bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20); |
|
| 145 | + | } |
|
| 146 | + | Ok(bytes) |
|
| 147 | + | } |
|
| 148 | + | ||
| 127 | 149 | /// Upload file `path` of report `name` of job `job` (oxygenhub#127); |
|
| 128 | 150 | /// the hub keeps it as a blob and answers with its ObjectRef. The |
|
| 129 | 151 | /// report's manifest follows through [`Hub::artifact`]. |
|
| … | 130 unchanged lines |
||
| 260 | 282 | ||
| 261 | 283 | /// Waits for what a job leaves behind (oxygenflow#49): three minutes in |
|
| 262 | 284 | /// all, longer than a hub takes to restart. |
|
| 285 | + | /// Check an input's bytes against the blob the producing job's receipt |
|
| 286 | + | /// names, then write them at its path under `tree` (the workspace's `/src`). |
|
| 287 | + | fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> { |
|
| 288 | + | let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy")) |
|
| 289 | + | else { |
|
| 290 | + | bail!("path {:?} is not a clean path under /src", i.path); |
|
| 291 | + | }; |
|
| 292 | + | let blob = Repo::blob_ref(bytes); |
|
| 293 | + | if blob.to_hex() != i.blob { |
|
| 294 | + | bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]); |
|
| 295 | + | } |
|
| 296 | + | let dest = tree.join(rel); |
|
| 297 | + | if let Some(dir) = dest.parent() { |
|
| 298 | + | std::fs::create_dir_all(dir)?; |
|
| 299 | + | } |
|
| 300 | + | std::fs::write(&dest, bytes)?; |
|
| 301 | + | Ok(blob.0) |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | /// The largest input a job takes: the hub's artifact limit. |
|
| 305 | + | pub const MAX_INPUT_BYTES: usize = 512 << 20; |
|
| 306 | + | ||
| 263 | 307 | const PATIENT_DELAYS: [Duration; 15] = { |
|
| 264 | 308 | let mut d = [Duration::from_secs(15); 15]; |
|
| 265 | 309 | d[0] = Duration::from_secs(1); |
|
| … | 536 unchanged lines |
||
| 802 | 846 | files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h))); |
|
| 803 | 847 | } |
|
| 804 | 848 | let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h))); |
|
| 849 | + | // Inputs (oxygenflow#58): each producing job's artifact, checked |
|
| 850 | + | // against the blob its receipt names, then put at its path. Not part |
|
| 851 | + | // of the scope: they are the run's, not the repository's. |
|
| 852 | + | let mut input_blobs = Vec::new(); |
|
| 853 | + | for (index, i) in claim.inputs.iter().enumerate() { |
|
| 854 | + | let what = format!("input {} from {}", i.artifact, i.from); |
|
| 855 | + | let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?; |
|
| 856 | + | let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?; |
|
| 857 | + | note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12])); |
|
| 858 | + | input_blobs.push(blob); |
|
| 859 | + | } |
|
| 805 | 860 | // The syscall tier runs on the tree itself; a microVM gets an image. |
|
| 806 | 861 | let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall; |
|
| 807 | 862 | if !syscall_tier { |
|
| … | 118 unchanged lines |
||
| 926 | 981 | job_name: claim.job.name.clone(), |
|
| 927 | 982 | pipeline_hash: hex32(&claim.pipeline_hash), |
|
| 928 | 983 | scope_tree_hash: scope_hash, |
|
| 929 | - | inputs: vec![], |
|
| 984 | + | inputs: input_blobs.clone(), |
|
| 930 | 985 | environment: outcome.environment.hash(), |
|
| 931 | 986 | outputs, |
|
| 932 | 987 | exit_code: code as u32, |
|
| … | 171 unchanged lines |
||
| 1104 | 1159 | ||
| 1105 | 1160 | #[cfg(test)] |
|
| 1106 | 1161 | mod tests { |
|
| 1162 | + | ||
| 1163 | + | #[test] |
|
| 1164 | + | fn an_input_is_placed_only_when_it_is_what_the_receipt_names() { |
|
| 1165 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1166 | + | let bytes = b"the reviewed plan"; |
|
| 1167 | + | let blob = Repo::blob_ref(bytes).to_hex(); |
|
| 1168 | + | let input = |path: &str, blob: &str| flow_core::api::ArtifactInput { |
|
| 1169 | + | artifact: "tfplan".into(), |
|
| 1170 | + | from: "plan".into(), |
|
| 1171 | + | path: path.into(), |
|
| 1172 | + | blob: blob.into(), |
|
| 1173 | + | }; |
|
| 1174 | + | let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap(); |
|
| 1175 | + | assert_eq!(hex::encode(got), blob); |
|
| 1176 | + | assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes); |
|
| 1177 | + | let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err(); |
|
| 1178 | + | assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}"); |
|
| 1179 | + | assert!(!dir.path().join("other").exists(), "nothing written when it does not match"); |
|
| 1180 | + | for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] { |
|
| 1181 | + | assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}"); |
|
| 1182 | + | } |
|
| 1183 | + | } |
|
| 1184 | + | ||
| 1107 | 1185 | use super::*; |
|
| 1108 | 1186 | ||
| 1109 | 1187 | #[test] |
|
| … | 291 unchanged lines |
modified docs/spec.md +27 −0
| … | 297 unchanged lines |
||
| 298 | 298 | } |
|
| 299 | 299 | ``` |
|
| 300 | 300 | ||
| 301 | + | ### 5.3.1 Artifacts between jobs (oxygenflow#58) |
|
| 302 | + | ||
| 303 | + | A job takes an artifact of a job it depends on: |
|
| 304 | + | ||
| 305 | + | ```kdl |
|
| 306 | + | job "apply" { |
|
| 307 | + | depends-on "plan" |
|
| 308 | + | input { |
|
| 309 | + | artifact "tfplan" from "plan" |
|
| 310 | + | path "/src/tfplan" |
|
| 311 | + | } |
|
| 312 | + | } |
|
| 313 | + | ``` |
|
| 314 | + | ||
| 315 | + | The producer must be named in `depends-on` and declare that name as an |
|
| 316 | + | artifact (not a report); paths are clean absolute paths under `/src`, at |
|
| 317 | + | most 8 inputs, and they are part of the job's hash (`[artifact, from, |
|
| 318 | + | path]`, field 10). Since the producer has passed when the job is handed |
|
| 319 | + | out, the hub puts each input in the claim with the blob the producer's |
|
| 320 | + | receipt names (a passing job's receipt names exactly the artifacts it |
|
| 321 | + | uploaded). Before the job starts, the runner fetches each from |
|
| 322 | + | `GET /api/v1/flow/jobs/{job}/inputs/{index}`, checks its Oxygen blob |
|
| 323 | + | reference against that blob, refuses the job if they differ, and writes it |
|
| 324 | + | at its path in the workspace (not part of the scope hash: it belongs to the |
|
| 325 | + | run, not the repository). The job's receipt lists the blobs as its |
|
| 326 | + | `inputs`, so an apply job's receipt proves which plan it applied. |
|
| 327 | + | ||
| 301 | 328 | ### 5.4 The Build Cache (oxygenflow#28) |
|
| 302 | 329 | ||
| 303 | 330 | An `ephemeral://disk` mount may name a `cache`, and the files that key it: |
|
| … | 351 unchanged lines |