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

            
30
use std::fmt;
31
use std::str::FromStr;
32

            
33
use anyhow::{Context, Result, bail};
34

            
35
pub 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)]
39
pub enum Kind {
40
    Coverage,
41
    Release,
42
}
43

            
44
impl Kind {
45
    pub const ALL: [Kind; 2] = [Kind::Coverage, Kind::Release];
46

            
47
4
    pub fn as_str(self) -> &'static str {
48
4
        match self {
49
2
            Kind::Coverage => "coverage",
50
2
            Kind::Release => "release",
51
        }
52
4
    }
53
}
54

            
55
impl fmt::Display for Kind {
56
4
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57
4
        f.write_str(self.as_str())
58
4
    }
59
}
60

            
61
impl FromStr for Kind {
62
    type Err = anyhow::Error;
63

            
64
3
    fn from_str(s: &str) -> Result<Self> {
65
3
        match s {
66
3
            "coverage" => Ok(Kind::Coverage),
67
1
            "release" => Ok(Kind::Release),
68
            other => bail!("unknown artifact kind: {other}"),
69
        }
70
3
    }
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)]
82
pub struct Identity {
83
    pub commit: String,
84
    pub run_id: String,
85
    pub run_attempt: String,
86
    pub builder: String,
87
}
88

            
89
impl 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
5
    pub fn from_source(lookup: impl Fn(&str) -> Option<String>) -> Result<Self> {
101
13
        let required = |key: &str| -> Result<String> {
102
13
            match lookup(key) {
103
12
                Some(value) if !value.is_empty() => Ok(value),
104
2
                _ => bail!("{key} is required for artifact paths and was empty or unset"),
105
            }
106
13
        };
107

            
108
5
        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
5
        let run_id = required("GITHUB_RUN_ID")?;
114
3
        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
3
        if !builder.contains("@sha256:") {
120
1
            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
2
        }
125

            
126
        Ok(Self {
127
2
            commit,
128
2
            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
2
            run_attempt: lookup("GITHUB_RUN_ATTEMPT")
132
2
                .filter(|value| !value.is_empty())
133
2
                .unwrap_or_else(|| "unknown".into()),
134
2
            builder,
135
        })
136
5
    }
137

            
138
    /// `s3://<bucket>/<commit>/<run_id>` — no trailing slash.
139
1
    pub fn prefix(&self, bucket: &str) -> String {
140
1
        format!("s3://{bucket}/{}/{}", self.commit, self.run_id)
141
1
    }
142
}
143

            
144
/// A published object: what it is, what it hashes to, and what produced it.
145
#[derive(Debug, Clone, PartialEq, Eq)]
146
pub struct Meta {
147
    pub kind: Kind,
148
    pub sha256: String,
149
    pub bytes: u64,
150
    pub identity: Identity,
151
}
152

            
153
impl Meta {
154
    /// Content-addressed key. The hash is in the name, so republishing
155
    /// different bytes cannot land on the same object.
156
3
    pub fn object_key(&self) -> String {
157
3
        format!("{}-{}.tar", self.kind, self.sha256)
158
3
    }
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
1
    pub fn to_text(&self) -> String {
167
1
        format!(
168
            "kind={}\nobject={}\nsha256={}\nbytes={}\ncommit={}\nrun_id={}\nrun_attempt={}\nbuilder={}\n",
169
            self.kind,
170
1
            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
1
    }
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
3
    pub fn from_text(text: &str) -> Result<Self> {
186
18
        let get = |key: &str| -> Result<String> {
187
18
            text.lines()
188
77
                .find_map(|line| line.strip_prefix(&format!("{key}=")))
189
18
                .map(str::to_owned)
190
18
                .with_context(|| format!("meta is missing {key}"))
191
18
        };
192

            
193
        Ok(Self {
194
3
            kind: get("kind")?.parse()?,
195
3
            sha256: get("sha256")?,
196
3
            bytes: get("bytes")?
197
3
                .parse()
198
3
                .context("meta bytes is not a number")?,
199
            identity: Identity {
200
3
                commit: get("commit")?,
201
2
                run_id: get("run_id")?,
202
2
                run_attempt: get("run_attempt")?,
203
2
                builder: get("builder")?,
204
            },
205
        })
206
3
    }
207
}
208

            
209
/// Why a pair of artifacts may not be assembled into one image.
210
#[derive(Debug, PartialEq, Eq)]
211
pub enum PairError {
212
    Commit { coverage: String, release: String },
213
    Run { coverage: String, release: String },
214
    Builder { coverage: String, release: String },
215
}
216

            
217
impl 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.
239
4
pub fn check_pair(coverage: &Meta, release: &Meta) -> Result<(), PairError> {
240
4
    let (c, r) = (&coverage.identity, &release.identity);
241
4
    if c.commit != r.commit {
242
1
        return Err(PairError::Commit {
243
1
            coverage: c.commit.clone(),
244
1
            release: r.commit.clone(),
245
1
        });
246
3
    }
247
3
    if c.run_id != r.run_id {
248
1
        return Err(PairError::Run {
249
1
            coverage: c.run_id.clone(),
250
1
            release: r.run_id.clone(),
251
1
        });
252
2
    }
253
2
    if c.builder != r.builder {
254
1
        return Err(PairError::Builder {
255
1
            coverage: c.builder.clone(),
256
1
            release: r.builder.clone(),
257
1
        });
258
1
    }
259
1
    Ok(())
260
4
}
261

            
262
#[cfg(test)]
263
mod tests;