Skip to main content

max / makenotwork

11.6 KB · 302 lines History Blame Raw
1 //! Emitting the artifact record: what this build produced, from what source,
2 //! and what it proved.
3 //!
4 //! Design + rationale: maintainer wiki.
5 //! <!-- wiki: sando-bento-boundary -->
6 //!
7 //! Bento already knew all of this and kept none of it together. The per-file
8 //! sha256 is computed at `collect`, the commit is resolved by the release
9 //! preflight, the step outcomes are rows in `step_runs`, and the three met
10 //! nowhere. Writing them as one document beside the artifacts is what makes the
11 //! handover to Sando possible later; today nothing reads it.
12 //!
13 //! Non-fatal. A build that produced signed, notarized bytes has succeeded
14 //! whether or not its paperwork could be written, so every failure in here is
15 //! logged and swallowed. [`crate::handoff`] reads the record: it returns the path
16 //! it wrote, and a `None` means the target has nothing to hand to Sando. The
17 //! swallowing stays because the reason to write a record is broader than the
18 //! handoff: the archive keeps one for every target, including the ones no Sando
19 //! deploys.
20
21 use crate::domain::{AppId, Target, Version};
22 use crate::engine::RecipeCtx;
23 use crate::state::AppState;
24 use chrono::{DateTime, Utc};
25 use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict};
26 use ops_exec::{Action, DiscardSink, Step as OpStep};
27 use std::path::PathBuf;
28
29 /// Where a target's record lands: beside the artifacts it describes, in that
30 /// target's own collect directory (`dist_root/<app>/<version>/<target>/`).
31 ///
32 /// Per target because the manifest is — it names the bytes THIS host produced —
33 /// and a plain `record.json` because the directory already says which target.
34 pub fn record_path(
35 dist_root: &std::path::Path,
36 app: &AppId,
37 version: &Version,
38 target: Target,
39 ) -> std::path::PathBuf {
40 crate::archive::target_dir(dist_root, app, version, target).join(RECORD_FILE)
41 }
42
43 /// The record's file name inside a target's directory.
44 pub const RECORD_FILE: &str = "record.json";
45
46 /// Build the record for a finished target run and write it beside the
47 /// artifacts. Never fails a build: logs and returns `None`.
48 ///
49 /// The returned path is what the handoff sends to Sando. `None` therefore means
50 /// two different things that want the same treatment: nothing was collected, or
51 /// the paperwork could not be written. Either way there is no artifact this
52 /// daemon can honestly hand over.
53 pub async fn emit(state: &AppState, ctx: &RecipeCtx, pinned_sha: &str) -> Option<PathBuf> {
54 if pinned_sha.is_empty() {
55 // Pinning is off (`pin_release_sha = false`, which is how the tests run
56 // against repos that are not git checkouts). There is no commit to name,
57 // and a record whose provenance is blank would describe bytes without
58 // saying where they came from, which is the thing this exists to stop.
59 tracing::debug!(app = %ctx.app, target = %ctx.target, "release pinning off, no record written");
60 return None;
61 }
62
63 let hashes = ctx.artifact_hashes();
64 if hashes.is_empty() {
65 // Nothing was collected, so there is no bundle to describe. A build that
66 // failed at prebuild is the ordinary case here, and inventing an empty
67 // manifest for it would mint an identity for no bytes.
68 tracing::debug!(app = %ctx.app, target = %ctx.target, "no artifacts collected, no record written");
69 return None;
70 }
71
72 let manifest = match Manifest::new(hashes) {
73 Ok(m) => m,
74 Err(e) => {
75 tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not build artifact manifest");
76 return None;
77 }
78 };
79
80 let provenance = Provenance {
81 app: ctx.app.to_string(),
82 version: ctx.version.to_string(),
83 tag: ctx.tag.clone(),
84 git_sha: pinned_sha.to_string(),
85 target: ctx.target.to_string(),
86 build_host: ctx.build_host.clone(),
87 toolchain: toolchain_of(state, &ctx.build_host).await,
88 built_at: Utc::now(),
89 };
90
91 let gates = gates_for(state, ctx.target_run_id).await;
92
93 let record = match ArtifactRecord::new("bento", manifest, provenance, gates) {
94 Ok(r) => r,
95 Err(e) => {
96 // Reaching here means the daemon assembled a document it would
97 // itself refuse. Loud, because it is a bug in this file, not a
98 // build problem.
99 tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "assembled an invalid artifact record");
100 return None;
101 }
102 };
103
104 let path = record_path(&state.cfg.dist_root, &ctx.app, &ctx.version, ctx.target);
105 if let Err(e) = tokio::fs::write(&path, record.to_json()).await {
106 tracing::error!(path = %path.display(), error = %e, "could not write artifact record");
107 return None;
108 }
109 tracing::info!(
110 app = %ctx.app, target = %ctx.target, digest = %record.digest.short(),
111 "wrote artifact record"
112 );
113
114 // Re-deposit so the archive holds the paperwork next to the bytes. The
115 // artifacts themselves went over at `collect`; the record is written after
116 // the recipe finishes, so it needs this second pass. Non-fatal, like the
117 // rest of this file: the build is over, and a record that reached the local
118 // tree but not the archive is worth a log, not a retroactive failure.
119 if let Some(dir) = path.parent()
120 && let Err(e) =
121 crate::archive::deposit(&state.cfg, dir, &ctx.app, &ctx.version, ctx.target).await
122 {
123 tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not archive the artifact record");
124 }
125
126 Some(path)
127 }
128
129 /// `rustc --version` on the build host.
130 ///
131 /// Asked rather than assumed: the daemon's own toolchain is not the one that
132 /// compiled a macOS or aarch64 artifact, and a record that reported fw13's
133 /// rustc for every target would be confidently wrong three times out of four.
134 /// Unreadable is recorded as `unknown` rather than left blank, since an empty
135 /// provenance field is refused and a missing toolchain should not cost a build
136 /// its paperwork.
137 async fn toolchain_of(state: &AppState, host: &str) -> String {
138 const UNKNOWN: &str = "unknown";
139 let Some(exec) = state.executors.get(host) else {
140 return UNKNOWN.to_string();
141 };
142 // A login shell: `rustc` lives in ~/.cargo/bin, which a non-login ssh shell
143 // does not have on PATH.
144 let step = OpStep::shell(
145 Action::Build,
146 "bash -lc 'rustc --version' 2>/dev/null".to_string(),
147 );
148 let mut sink = DiscardSink;
149 match exec.run_streaming(&step, &mut sink).await {
150 Ok(out) if out.status.success() => {
151 let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
152 if v.is_empty() { UNKNOWN.to_string() } else { v }
153 }
154 _ => UNKNOWN.to_string(),
155 }
156 }
157
158 /// This run's steps, as artifact-scoped gate records.
159 ///
160 /// A recipe step IS Bento's gate: `prebuild` is clippy plus the test suite,
161 /// `verify` is the Gatekeeper check, `sign` either produced a valid signature
162 /// or failed. Reporting the steps verbatim rather than inventing a separate
163 /// gate vocabulary keeps the record honest about what was actually observed.
164 async fn gates_for(state: &AppState, target_run_id: i64) -> Vec<GateRecord> {
165 let rows: Vec<(String, String, Option<String>, String, Option<String>)> = sqlx::query_as(
166 "SELECT step, status, log_ref, started_at, finished_at
167 FROM step_runs WHERE target_run_id = ? ORDER BY id",
168 )
169 .bind(target_run_id)
170 .fetch_all(&state.pool)
171 .await
172 .unwrap_or_default();
173
174 rows.into_iter()
175 .map(|(step, status, log_ref, started_at, finished_at)| {
176 let ran_at = parse_ts(&started_at);
177 let (verdict, summary) =
178 verdict_of(&step, &status, &started_at, finished_at.as_deref());
179 let mut g = GateRecord::new(step, Scope::Artifact, verdict, summary, ran_at);
180 if let Some(r) = log_ref {
181 g = g.with_log_ref(r);
182 }
183 g
184 })
185 .collect()
186 }
187
188 /// Map a `step_runs.status` to a verdict and a one-line summary.
189 ///
190 /// Anything that is neither `ok` nor `failed` is a step that never reached a
191 /// verdict, which happens when a newer build supersedes this one mid-run. That
192 /// is `Blocked`, not `Failed`: nothing was observed to be wrong with the
193 /// artifact, the run just stopped being the current one.
194 fn verdict_of(
195 step: &str,
196 status: &str,
197 started_at: &str,
198 finished_at: Option<&str>,
199 ) -> (Verdict, String) {
200 let secs = duration_secs(started_at, finished_at);
201 match status {
202 "ok" => (
203 Verdict::Passed,
204 match secs {
205 Some(s) => format!("{step} passed in {s}s"),
206 None => format!("{step} passed"),
207 },
208 ),
209 "failed" => (
210 Verdict::Failed,
211 match secs {
212 Some(s) => format!("{step} failed after {s}s"),
213 None => format!("{step} failed"),
214 },
215 ),
216 other => (
217 Verdict::Blocked,
218 format!("{step} never finished (left `{other}`); the run was superseded or aborted"),
219 ),
220 }
221 }
222
223 fn parse_ts(s: &str) -> DateTime<Utc> {
224 DateTime::parse_from_rfc3339(s).map_or_else(|_| Utc::now(), |t| t.with_timezone(&Utc))
225 }
226
227 fn duration_secs(started_at: &str, finished_at: Option<&str>) -> Option<i64> {
228 let start = DateTime::parse_from_rfc3339(started_at).ok()?;
229 let end = DateTime::parse_from_rfc3339(finished_at?).ok()?;
230 Some((end - start).num_seconds().max(0))
231 }
232
233 #[cfg(test)]
234 mod tests {
235 use super::*;
236
237 #[test]
238 fn the_record_sits_in_its_own_targets_directory() {
239 // Each target collects into its own directory, so the file name is the
240 // same everywhere and the path is what distinguishes two targets'
241 // paperwork. A shared directory is what used to make that not true.
242 let root = std::path::Path::new("/dist");
243 let linux = record_path(
244 root,
245 &AppId::new("goingson"),
246 &"0.4.1".parse().unwrap(),
247 "linux/x86_64".parse().unwrap(),
248 );
249 let macos = record_path(
250 root,
251 &AppId::new("goingson"),
252 &"0.4.1".parse().unwrap(),
253 "macos/aarch64".parse().unwrap(),
254 );
255 assert_ne!(linux, macos);
256 assert_eq!(
257 linux,
258 std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64/record.json")
259 );
260 }
261
262 #[test]
263 fn a_passing_step_reports_its_duration() {
264 let (v, s) = verdict_of(
265 "prebuild",
266 "ok",
267 "2026-08-06T12:00:00Z",
268 Some("2026-08-06T12:02:30Z"),
269 );
270 assert_eq!(v, Verdict::Passed);
271 assert_eq!(s, "prebuild passed in 150s");
272 }
273
274 #[test]
275 fn a_failed_step_is_a_failed_gate() {
276 let (v, s) = verdict_of(
277 "sign",
278 "failed",
279 "2026-08-06T12:00:00Z",
280 Some("2026-08-06T12:00:04Z"),
281 );
282 assert_eq!(v, Verdict::Failed);
283 assert!(s.contains("failed after 4s"), "{s}");
284 }
285
286 #[test]
287 fn a_superseded_step_is_blocked_rather_than_failed() {
288 // Nothing was observed to be wrong with the artifact. Calling it a
289 // failure would put a red mark on a build that was merely overtaken.
290 let (v, s) = verdict_of("build", "running", "2026-08-06T12:00:00Z", None);
291 assert_eq!(v, Verdict::Blocked);
292 assert!(s.contains("never finished"), "{s}");
293 }
294
295 #[test]
296 fn an_unparseable_timestamp_costs_the_duration_and_nothing_else() {
297 let (v, s) = verdict_of("build", "ok", "not-a-timestamp", Some("also-not"));
298 assert_eq!(v, Verdict::Passed);
299 assert_eq!(s, "build passed");
300 }
301 }
302