xtask/artifact.rs
1//! The artifact layout: object keys, per-kind provenance, and the manifest.
2//!
3//! One definition, used by the producers, by finalize and by deploy. Two places
4//! that each "know" the layout is how a producer and a consumer drift apart
5//! without anyone noticing, which is why this is a module with types rather
6//! than a string built in three scripts.
7//!
8//! Layout:
9//!
10//! ```text
11//! s3://<bucket>/<commit>/<run_id>/
12//! <kind>-<sha256>.tar the payload, content-addressed
13//! <kind>.meta pointer + provenance for that kind
14//! nomisync-manifest.json written by finalize, and only by finalize
15//! ```
16//!
17//! **Content-addressed** because the key contains the hash of the bytes: an
18//! object can never be overwritten with different content, since different
19//! bytes are a different key. That structurally removes the window where a
20//! manifest is valid while the bytes behind a mutable name changed underneath.
21//!
22//! **Run-scoped, not attempt-scoped.** Attempts may differ between kinds and
23//! that is correct: if coverage passes and only release is re-run, they land in
24//! attempts 1 and 2 of the same run, built from the same commit. Refusing that
25//! pair would force a full coverage rebuild because an unrelated job flaked.
26//! The invariant that matters is *same commit, same run, same builder image* —
27//! and mixing across runs or commits is unreachable, because the prefix
28//! contains both.
29
30use std::fmt;
31use std::str::FromStr;
32
33use anyhow::{Context, Result, bail};
34
35pub const MANIFEST_NAME: &str = "nomisync-manifest.json";
36
37/// Which half of the artifact pair an object is.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Kind {
40 Coverage,
41 Release,
42}
43
44impl Kind {
45 pub const ALL: [Kind; 2] = [Kind::Coverage, Kind::Release];
46
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Kind::Coverage => "coverage",
50 Kind::Release => "release",
51 }
52 }
53}
54
55impl fmt::Display for Kind {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str(self.as_str())
58 }
59}
60
61impl FromStr for Kind {
62 type Err = anyhow::Error;
63
64 fn from_str(s: &str) -> Result<Self> {
65 match s {
66 "coverage" => Ok(Kind::Coverage),
67 "release" => Ok(Kind::Release),
68 other => bail!("unknown artifact kind: {other}"),
69 }
70 }
71}
72
73/// Who built an artifact, and in which run.
74///
75/// Read from the environment rather than defaulted. `ci-build` derives Job
76/// *names* from `GITHUB_RUN_NUMBER` with a `:-0` fallback, which is right for a
77/// name and catastrophic for a path — it would collapse every run onto one
78/// prefix and reopen exactly the mixing this layout prevents. A missing value
79/// is an error here, and this reads the run id rather than reusing that
80/// defaulted number.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Identity {
83 pub commit: String,
84 pub run_id: String,
85 pub run_attempt: String,
86 pub builder: String,
87}
88
89impl Identity {
90 /// Reads identity from the process environment, failing closed.
91 pub fn from_env() -> Result<Self> {
92 Self::from_source(|key| std::env::var(key).ok())
93 }
94
95 /// Reads identity from an arbitrary lookup.
96 ///
97 /// Taking the source as a parameter keeps the tests off the process
98 /// environment: `set_var` is `unsafe` and racy across threads, and cargo
99 /// runs a crate's tests in one process.
100 pub fn from_source(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
101 let required = |key: &str| -> Result<String> {
102 match lookup(key) {
103 Some(value) if !value.is_empty() => Ok(value),
104 _ => bail!("{key} is required for artifact paths and was empty or unset"),
105 }
106 };
107
108 let commit = required("SHA")?;
109 // GITHUB_RUN_ID, verified populated in a real build pod (362 while the
110 // run number was 12), and globally unique where the number is only
111 // sequential per repository. A re-run keeps its id and increments the
112 // attempt, which is exactly the prefix behaviour this layout wants.
113 let run_id = required("GITHUB_RUN_ID")?;
114 let builder = required("RUST_BUILDER_IMAGE_REF")?;
115
116 // The digest requirement lives with the producer, not in the shared
117 // ci-build orchestrator: other projects on that orchestrator pin tags
118 // and must keep working.
119 if !builder.contains("@sha256:") {
120 bail!(
121 "RUST_BUILDER_IMAGE_REF is not digest-pinned: {builder}\n\
122 a tag can be moved, so it cannot prove two producers used one image"
123 );
124 }
125
126 Ok(Self {
127 commit,
128 run_id,
129 // Best-effort: unlike the others this is provenance detail, not a
130 // path component, so an platform that does not supply it still works.
131 run_attempt: lookup("GITHUB_RUN_ATTEMPT")
132 .filter(|value| !value.is_empty())
133 .unwrap_or_else(|| "unknown".into()),
134 builder,
135 })
136 }
137
138 /// `s3://<bucket>/<commit>/<run_id>` — no trailing slash.
139 pub fn prefix(&self, bucket: &str) -> String {
140 format!("s3://{bucket}/{}/{}", self.commit, self.run_id)
141 }
142}
143
144/// A published object: what it is, what it hashes to, and what produced it.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Meta {
147 pub kind: Kind,
148 pub sha256: String,
149 pub bytes: u64,
150 pub identity: Identity,
151}
152
153impl Meta {
154 /// Content-addressed key. The hash is in the name, so republishing
155 /// different bytes cannot land on the same object.
156 pub fn object_key(&self) -> String {
157 format!("{}-{}.tar", self.kind, self.sha256)
158 }
159
160 pub fn meta_key(kind: Kind) -> String {
161 format!("{kind}.meta")
162 }
163
164 /// Flat `key=value`, because a meta is read by the deploy container too and
165 /// a JSON parser there is a cost with no benefit at this size.
166 pub fn to_text(&self) -> String {
167 format!(
168 "kind={}\nobject={}\nsha256={}\nbytes={}\ncommit={}\nrun_id={}\nrun_attempt={}\nbuilder={}\n",
169 self.kind,
170 self.object_key(),
171 self.sha256,
172 self.bytes,
173 self.identity.commit,
174 self.identity.run_id,
175 self.identity.run_attempt,
176 self.identity.builder,
177 )
178 }
179
180 /// Parses a meta fetched from object storage.
181 ///
182 /// Parsed, never evaluated: the previous shell version had to be careful not
183 /// to `source` a file that came off the network. Here that class of mistake
184 /// is not expressible.
185 pub fn from_text(text: &str) -> Result<Self> {
186 let get = |key: &str| -> Result<String> {
187 text.lines()
188 .find_map(|line| line.strip_prefix(&format!("{key}=")))
189 .map(str::to_owned)
190 .with_context(|| format!("meta is missing {key}"))
191 };
192
193 Ok(Self {
194 kind: get("kind")?.parse()?,
195 sha256: get("sha256")?,
196 bytes: get("bytes")?
197 .parse()
198 .context("meta bytes is not a number")?,
199 identity: Identity {
200 commit: get("commit")?,
201 run_id: get("run_id")?,
202 run_attempt: get("run_attempt")?,
203 builder: get("builder")?,
204 },
205 })
206 }
207}
208
209/// Why a pair of artifacts may not be assembled into one image.
210#[derive(Debug, PartialEq, Eq)]
211pub enum PairError {
212 Commit { coverage: String, release: String },
213 Run { coverage: String, release: String },
214 Builder { coverage: String, release: String },
215}
216
217impl fmt::Display for PairError {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 match self {
220 PairError::Commit { coverage, release } => write!(
221 f,
222 "artifacts are from different commits: coverage {coverage}, release {release}"
223 ),
224 PairError::Run { coverage, release } => write!(
225 f,
226 "artifacts are from different runs: coverage {coverage}, release {release}"
227 ),
228 PairError::Builder { coverage, release } => write!(
229 f,
230 "artifacts were built by different images: coverage {coverage}, release {release}"
231 ),
232 }
233 }
234}
235
236/// The invariant deploy relies on: same commit, same run, same builder image.
237///
238/// Attempts deliberately need not match — see the module docs.
239pub fn check_pair(coverage: &Meta, release: &Meta) -> Result<(), PairError> {
240 let (c, r) = (&coverage.identity, &release.identity);
241 if c.commit != r.commit {
242 return Err(PairError::Commit {
243 coverage: c.commit.clone(),
244 release: r.commit.clone(),
245 });
246 }
247 if c.run_id != r.run_id {
248 return Err(PairError::Run {
249 coverage: c.run_id.clone(),
250 release: r.run_id.clone(),
251 });
252 }
253 if c.builder != r.builder {
254 return Err(PairError::Builder {
255 coverage: c.builder.clone(),
256 release: r.builder.clone(),
257 });
258 }
259 Ok(())
260}
261
262#[cfg(test)]
263mod tests;