Skip to main content

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;