Skip to main content

max / makenotwork

14.6 KB · 367 lines History Blame Raw
1 //! Accepting an artifact Sando did not build.
2 //!
3 //! Design: wiki [[sando-bento-boundary]], [[release-artifact-identity]].
4 //!
5 //! Everything downstream of `build.rs` assumes Sando compiled the thing it is
6 //! about to ship. That assumption is what the boundary removes: Bento builds,
7 //! Sando decides whether the result advances. Deciding requires being handed
8 //! bytes, and being handed bytes requires a reason to believe they are the ones
9 //! the evidence is about.
10 //!
11 //! That belief is not taken on trust here. An accepted artifact has been checked
12 //! three ways, each catching something the others cannot:
13 //!
14 //! 1. The record is internally consistent — its claimed digest is the digest of
15 //! the manifest it carries. Catches a hand-edited or truncated document.
16 //! 2. The bytes on disk hash to that manifest, file for file. Catches a bundle
17 //! that drifted in transit, or one swapped for another, and names the file.
18 //! 3. The digest of what arrived equals the digest the record claims. Implied by
19 //! (2), and checked anyway because it is the value everything downstream
20 //! keys on, and a mismatch here means the two computations disagree.
21 //!
22 //! Intake is about identity, not policy. A record whose builder gates failed is
23 //! accepted and says so: whether that artifact may advance is the gating layer's
24 //! call, and it cannot make that call about an artifact it was never handed.
25
26 use crate::bundle;
27 use ops_artifact::ArtifactRecord;
28 use std::path::{Path, PathBuf};
29
30 /// An artifact that arrived with credible paperwork, published into the local
31 /// release root.
32 #[derive(Debug)]
33 pub struct AcceptedArtifact {
34 /// The builder's document, verified against the bytes.
35 pub record: ArtifactRecord,
36 /// The content-addressed directory the bundle now lives at.
37 pub released: PathBuf,
38 }
39
40 impl AcceptedArtifact {
41 /// True iff every gate the builder ran passed. Not a decision to advance:
42 /// Sando's own gates have not run yet, and they are the ones about this
43 /// artifact in an environment.
44 pub fn builder_gates_passed(&self) -> bool {
45 self.record.all_gates_passed()
46 }
47 }
48
49 /// Why an artifact was turned away.
50 ///
51 /// Typed rather than a string because the operator response differs per case
52 /// and only one of these is a transfer problem. A record that does not parse is
53 /// a producer bug; a manifest mismatch is corruption or substitution; a failure
54 /// to publish is a local disk problem on this machine.
55 #[derive(Debug)]
56 pub enum IntakeError {
57 /// The record did not parse, or failed its own validation.
58 BadRecord(ops_artifact::ContractError),
59 /// The bundle could not be hashed (unreadable, or not a directory).
60 Unreadable(String),
61 /// The bytes are not the ones the record describes. `first_difference` names
62 /// a file the two disagree about, when one can be identified.
63 ManifestMismatch {
64 first_difference: Option<String>,
65 claimed_files: usize,
66 found_files: usize,
67 },
68 /// The recomputed digest is not the one claimed.
69 DigestMismatch { claimed: String, computed: String },
70 /// Publishing the verified bundle into the release root failed.
71 Publish(String),
72 }
73
74 impl std::fmt::Display for IntakeError {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 IntakeError::BadRecord(e) => write!(f, "artifact record is not usable: {e}"),
78 IntakeError::Unreadable(d) => write!(f, "could not read the incoming bundle: {d}"),
79 IntakeError::ManifestMismatch {
80 first_difference: Some(p),
81 ..
82 } => write!(
83 f,
84 "the bundle is not what its record describes; `{p}` differs. \
85 Refusing an artifact whose evidence is about other bytes"
86 ),
87 IntakeError::ManifestMismatch {
88 first_difference: None,
89 claimed_files,
90 found_files,
91 } => write!(
92 f,
93 "the bundle is not what its record describes: the record lists {claimed_files} \
94 file(s), the bundle holds {found_files}"
95 ),
96 IntakeError::DigestMismatch { claimed, computed } => write!(
97 f,
98 "bundle digest {computed} does not match the claimed {claimed}"
99 ),
100 IntakeError::Publish(d) => write!(f, "publishing the accepted bundle failed: {d}"),
101 }
102 }
103 }
104
105 impl std::error::Error for IntakeError {}
106
107 /// Verify an incoming bundle against its record and publish it
108 /// content-addressed into `release_root`.
109 ///
110 /// `staged` must already be a directory under `release_root/staging/`, because
111 /// publishing is an atomic same-filesystem rename and a cross-device staging dir
112 /// would silently become a copy. The caller owns getting the bytes there; this
113 /// owns deciding whether they may stay.
114 pub async fn accept(
115 release_root: &Path,
116 staged: &Path,
117 record_json: &str,
118 ) -> Result<AcceptedArtifact, IntakeError> {
119 let record = ArtifactRecord::parse(record_json).map_err(IntakeError::BadRecord)?;
120
121 // Hash what actually arrived. Sando's own bundle walker, deliberately: the
122 // artifact will later be verified on the node by the same code, so intake
123 // proving the bundle with a different implementation than the deploy uses
124 // would leave a gap exactly where the two disagree.
125 let computed = bundle::digest_dir(staged)
126 .await
127 .map_err(|e| IntakeError::Unreadable(format!("{e:#}")))?;
128
129 let claimed_manifest = record.manifest.to_text();
130 if computed.manifest != claimed_manifest {
131 return Err(manifest_mismatch(&claimed_manifest, &computed.manifest));
132 }
133 if computed.full != record.digest.as_str() {
134 return Err(IntakeError::DigestMismatch {
135 claimed: record.digest.to_string(),
136 computed: computed.full,
137 });
138 }
139
140 // Write the MANIFEST into the bundle so the node can verify per file after
141 // the transfer. Excluded from its own hash, so this does not change what was
142 // just proved.
143 tokio::fs::write(
144 staged.join(bundle::MANIFEST_NAME),
145 computed.manifest.as_bytes(),
146 )
147 .await
148 .map_err(|e| IntakeError::Publish(format!("writing MANIFEST: {e}")))?;
149
150 let released = crate::deploy::finalize_local_release(release_root, staged, computed.short())
151 .await
152 .map_err(|e| IntakeError::Publish(format!("{e:#}")))?;
153
154 tracing::info!(
155 app = %record.provenance.app,
156 version = %record.provenance.version,
157 target = %record.provenance.target,
158 producer = %record.producer,
159 digest = %record.digest.short(),
160 "accepted an artifact built elsewhere"
161 );
162 Ok(AcceptedArtifact { record, released })
163 }
164
165 /// Name a file the two manifests disagree about, for the error.
166 ///
167 /// A digest mismatch alone says only "not the same"; the useful half is which
168 /// file drifted, which is the whole reason the manifest is per-file rather than
169 /// one hash over a tarball.
170 fn manifest_mismatch(claimed: &str, computed: &str) -> IntakeError {
171 let paths = |text: &str| -> Vec<(String, String)> {
172 text.lines()
173 .filter_map(|l| l.split_once(" "))
174 .map(|(sha, path)| (path.to_string(), sha.to_string()))
175 .collect()
176 };
177 let (a, b) = (paths(claimed), paths(computed));
178 let first_difference = a
179 .iter()
180 .find(|(path, sha)| !b.iter().any(|(p, s)| p == path && s == sha))
181 .or_else(|| b.iter().find(|(path, _)| !a.iter().any(|(p, _)| p == path)))
182 .map(|(path, _)| path.clone());
183 IntakeError::ManifestMismatch {
184 first_difference,
185 claimed_files: a.len(),
186 found_files: b.len(),
187 }
188 }
189
190 #[cfg(test)]
191 mod tests {
192 use super::*;
193 use chrono::{DateTime, Utc};
194 use ops_artifact::{GateRecord, Manifest, Provenance, Scope, Verdict};
195
196 fn at() -> DateTime<Utc> {
197 DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap()
198 }
199
200 async fn write(root: &Path, rel: &str, bytes: &[u8]) {
201 let p = root.join(rel);
202 tokio::fs::create_dir_all(p.parent().unwrap())
203 .await
204 .unwrap();
205 tokio::fs::write(&p, bytes).await.unwrap();
206 }
207
208 /// A staging dir under `release_root`, holding a small bundle.
209 async fn staged_bundle(release_root: &Path) -> PathBuf {
210 let staged = release_root.join("staging").join("1");
211 write(&staged, "pom", b"binary bytes").await;
212 write(&staged, "static/app.css", b"body{}").await;
213 staged
214 }
215
216 fn provenance() -> Provenance {
217 Provenance {
218 app: "pom".into(),
219 version: "0.4.1".into(),
220 tag: "pom-v0.4.1".into(),
221 git_sha: "a".repeat(40),
222 target: "linux/aarch64".into(),
223 build_host: "astra".into(),
224 toolchain: "rustc 1.97.0".into(),
225 built_at: at(),
226 }
227 }
228
229 /// The record Bento would have written for `staged`.
230 async fn record_for(staged: &Path, gates: Vec<GateRecord>) -> String {
231 let computed = bundle::digest_dir(staged).await.unwrap();
232 let manifest = Manifest::parse(&computed.manifest).unwrap();
233 ArtifactRecord::new("bento", manifest, provenance(), gates)
234 .unwrap()
235 .to_json()
236 }
237
238 fn passing_gate() -> GateRecord {
239 GateRecord::new(
240 "prebuild",
241 Scope::Artifact,
242 Verdict::Passed,
243 "prebuild passed in 90s",
244 at(),
245 )
246 }
247
248 /// The load-bearing agreement: Sando's bundle walker and the contract's
249 /// manifest must produce the same text, byte for byte. They are separate
250 /// implementations on either side of the boundary, and a divergence would
251 /// make every honest artifact look corrupt.
252 #[tokio::test]
253 async fn sandos_walker_and_the_contract_manifest_agree_exactly() {
254 let dir = tempfile::tempdir().unwrap();
255 let staged = staged_bundle(dir.path()).await;
256 let computed = bundle::digest_dir(&staged).await.unwrap();
257 let parsed = Manifest::parse(&computed.manifest).unwrap();
258 assert_eq!(parsed.to_text(), computed.manifest);
259 assert_eq!(parsed.digest().as_str(), computed.full);
260 }
261
262 #[tokio::test]
263 async fn a_matching_bundle_is_accepted_and_published_content_addressed() {
264 let dir = tempfile::tempdir().unwrap();
265 let staged = staged_bundle(dir.path()).await;
266 let json = record_for(&staged, vec![passing_gate()]).await;
267
268 let accepted = accept(dir.path(), &staged, &json).await.unwrap();
269 assert!(accepted.builder_gates_passed());
270 assert!(
271 accepted.released.ends_with(accepted.record.digest.short()),
272 "published at {}, expected a dir named for the digest",
273 accepted.released.display()
274 );
275 // The MANIFEST rides along for node-side verification.
276 assert!(accepted.released.join(bundle::MANIFEST_NAME).exists());
277 // Staging is gone: the bundle was renamed, not copied.
278 assert!(!staged.exists());
279 }
280
281 #[tokio::test]
282 async fn a_bundle_that_drifted_is_refused_and_names_the_file() {
283 // The failure this whole boundary exists to make impossible: evidence
284 // that vouches for one set of bytes arriving with another.
285 let dir = tempfile::tempdir().unwrap();
286 let staged = staged_bundle(dir.path()).await;
287 let json = record_for(&staged, vec![passing_gate()]).await;
288 write(&staged, "static/app.css", b"tampered").await;
289
290 let err = accept(dir.path(), &staged, &json).await.unwrap_err();
291 match err {
292 IntakeError::ManifestMismatch {
293 first_difference: Some(p),
294 ..
295 } => assert_eq!(p, "static/app.css"),
296 other => panic!("expected a manifest mismatch naming the file, got {other}"),
297 }
298 // Nothing was published.
299 assert!(!dir.path().join("releases").exists());
300 }
301
302 #[tokio::test]
303 async fn an_extra_file_in_the_bundle_is_refused() {
304 // Same bytes for everything the record lists, plus something it does not.
305 // The digest would differ, but the useful error names the intruder.
306 let dir = tempfile::tempdir().unwrap();
307 let staged = staged_bundle(dir.path()).await;
308 let json = record_for(&staged, vec![passing_gate()]).await;
309 write(&staged, "companions/unexpected", b"who put this here").await;
310
311 let err = accept(dir.path(), &staged, &json).await.unwrap_err();
312 match err {
313 IntakeError::ManifestMismatch {
314 first_difference: Some(p),
315 ..
316 } => assert_eq!(p, "companions/unexpected"),
317 other => panic!("expected the extra file to be named, got {other}"),
318 }
319 }
320
321 #[tokio::test]
322 async fn a_record_that_does_not_parse_is_refused_before_anything_is_hashed() {
323 let dir = tempfile::tempdir().unwrap();
324 let staged = staged_bundle(dir.path()).await;
325 let err = accept(dir.path(), &staged, "{\"not\": \"a record\"}")
326 .await
327 .unwrap_err();
328 assert!(matches!(err, IntakeError::BadRecord(_)), "{err}");
329 assert!(staged.exists(), "a refused intake leaves the bytes alone");
330 }
331
332 #[tokio::test]
333 async fn a_record_whose_digest_was_edited_is_refused() {
334 // Editing the digest to match tampered bytes does not help: the record
335 // validates its own digest against its own manifest first.
336 let dir = tempfile::tempdir().unwrap();
337 let staged = staged_bundle(dir.path()).await;
338 let json = record_for(&staged, vec![passing_gate()]).await;
339 let forged = json.replace(&record_digest(&json), &"f".repeat(64));
340 let err = accept(dir.path(), &staged, &forged).await.unwrap_err();
341 assert!(matches!(err, IntakeError::BadRecord(_)), "{err}");
342 }
343
344 #[tokio::test]
345 async fn a_failed_builder_gate_is_accepted_and_reported_not_hidden() {
346 // Intake decides identity, not whether the thing may ship. Refusing here
347 // would leave the evidence of a failed build nowhere Sando can see it.
348 let dir = tempfile::tempdir().unwrap();
349 let staged = staged_bundle(dir.path()).await;
350 let failed = GateRecord::new(
351 "prebuild",
352 Scope::Artifact,
353 Verdict::Failed,
354 "12 test(s) failed",
355 at(),
356 );
357 let json = record_for(&staged, vec![failed]).await;
358 let accepted = accept(dir.path(), &staged, &json).await.unwrap();
359 assert!(!accepted.builder_gates_passed());
360 }
361
362 fn record_digest(json: &str) -> String {
363 let v: serde_json::Value = serde_json::from_str(json).unwrap();
364 v["digest"].as_str().unwrap().to_string()
365 }
366 }
367