|
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 |
+ |
}
|