1
//! Publishing an artifact to object storage, and reading one back.
2
//!
3
//! `s3cmd` stays an external program — it is the credential-handling tool the
4
//! platform already provides — but *what* gets published, under which key, and
5
//! with which provenance is decided by [`crate::artifact`], in one place.
6

            
7
use std::path::{Path, PathBuf};
8
use std::process::Command;
9

            
10
use anyhow::{Context, Result, bail};
11
use sha2::{Digest, Sha256};
12

            
13
use crate::artifact::{Identity, Kind, Meta};
14

            
15
/// Credentials and endpoint for the artifacts bucket.
16
pub struct Store {
17
    access_key: String,
18
    secret_key: String,
19
    bucket: String,
20
    endpoint: String,
21
}
22

            
23
impl Store {
24
    /// Reads the bucket configuration, failing closed.
25
    ///
26
    /// Only ever called on a branch where the workflow decided this run may
27
    /// hold the write key; a PR run builds and verifies everything and never
28
    /// reaches here.
29
    pub fn from_env() -> Result<Self> {
30
        let get = |key: &str| -> Result<String> {
31
            let value = std::env::var(key).unwrap_or_default();
32
            if value.is_empty() {
33
                bail!("{key} is required to publish artifacts");
34
            }
35
            Ok(value)
36
        };
37
        Ok(Self {
38
            access_key: get("ARTIFACTS_ACCESS_KEY_ID")?,
39
            secret_key: get("ARTIFACTS_SECRET_ACCESS_KEY")?,
40
            bucket: get("ARTIFACTS_BUCKET")?,
41
            endpoint: get("ARTIFACTS_ENDPOINT")?,
42
        })
43
    }
44

            
45
    fn s3(&self, args: &[&str]) -> Result<()> {
46
        let status = Command::new("s3cmd")
47
            .arg(format!("--access_key={}", self.access_key))
48
            .arg(format!("--secret_key={}", self.secret_key))
49
            .arg(format!("--host={}", self.endpoint))
50
            .arg(format!("--host-bucket=%(bucket)s.{}", self.endpoint))
51
            .args(args)
52
            .status()
53
            .context("failed to run s3cmd")?;
54
        if !status.success() {
55
            bail!("s3cmd {} exited with {status}", args.join(" "));
56
        }
57
        Ok(())
58
    }
59

            
60
    /// Uploads a payload under its content-addressed key, then the meta that
61
    /// points at it. Order matters: a meta must never name an object that is
62
    /// not there yet, since a reader that sees the meta will go fetch it.
63
    pub fn publish(&self, kind: Kind, tarball: &Path, identity: &Identity) -> Result<Meta> {
64
        let meta = Meta {
65
            kind,
66
            sha256: sha256_of(tarball)?,
67
            bytes: std::fs::metadata(tarball)
68
                .with_context(|| format!("cannot stat {}", tarball.display()))?
69
                .len(),
70
            identity: identity.clone(),
71
        };
72

            
73
        let prefix = identity.prefix(&self.bucket);
74
        let object = format!("{prefix}/{}", meta.object_key());
75
        println!(">> publishing {} -> {object}", tarball.display());
76
        self.s3(&["put", &tarball.to_string_lossy(), &object])?;
77

            
78
        let meta_path = tarball.with_extension("meta");
79
        std::fs::write(&meta_path, meta.to_text())
80
            .with_context(|| format!("cannot write {}", meta_path.display()))?;
81
        self.s3(&[
82
            "put",
83
            &meta_path.to_string_lossy(),
84
            &format!("{prefix}/{}", Meta::meta_key(kind)),
85
        ])?;
86

            
87
        Ok(meta)
88
    }
89
}
90

            
91
/// The hash that becomes part of the object key.
92
///
93
/// Hex-encoded lowercase, so it is the same string `sha256sum` prints — the
94
/// deploy container verifies with that, and a mismatch in encoding would be a
95
/// confusing way to discover it.
96
pub fn sha256_of(path: &Path) -> Result<String> {
97
    use std::io::Read;
98

            
99
    let mut file =
100
        std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
101
    let mut hasher = Sha256::new();
102
    let mut buffer = vec![0u8; 64 * 1024];
103
    loop {
104
        let read = file
105
            .read(&mut buffer)
106
            .with_context(|| format!("cannot read {}", path.display()))?;
107
        if read == 0 {
108
            break;
109
        }
110
        hasher.update(&buffer[..read]);
111
    }
112
    Ok(hasher
113
        .finalize()
114
        .iter()
115
        .fold(String::with_capacity(64), |mut hex, byte| {
116
            use std::fmt::Write;
117
            let _ = write!(hex, "{byte:02x}");
118
            hex
119
        }))
120
}
121

            
122
impl Store {
123
    fn s3_get(&self, remote: &str, local: &Path) -> Result<()> {
124
        self.s3(&["--force", "get", remote, &local.to_string_lossy()])
125
    }
126

            
127
    /// Reads one kind's meta out of a run's prefix.
128
    pub fn fetch_meta(&self, kind: Kind, identity: &Identity, into: &Path) -> Result<Meta> {
129
        let remote = format!("{}/{}", identity.prefix(&self.bucket), Meta::meta_key(kind));
130
        let local = into.join(Meta::meta_key(kind));
131
        self.s3_get(&remote, &local)
132
            .with_context(|| format!("no {kind} meta at {remote} — did that job publish?"))?;
133
        let text = std::fs::read_to_string(&local)
134
            .with_context(|| format!("cannot read {}", local.display()))?;
135
        Meta::from_text(&text).with_context(|| format!("malformed {kind} meta"))
136
    }
137

            
138
    /// Downloads a payload and checks it against what its meta claims.
139
    ///
140
    /// The key is content-addressed, so bytes cannot change under a name — but
141
    /// verifying on read is what turns that from an argument into a check.
142
    pub fn verify_object(&self, meta: &Meta, identity: &Identity, into: &Path) -> Result<PathBuf> {
143
        let remote = format!("{}/{}", identity.prefix(&self.bucket), meta.object_key());
144
        let local = into.join(meta.object_key());
145
        self.s3_get(&remote, &local)?;
146

            
147
        let bytes = std::fs::metadata(&local)
148
            .with_context(|| format!("cannot stat {}", local.display()))?
149
            .len();
150
        if bytes != meta.bytes {
151
            bail!(
152
                "{} is {bytes} bytes, its meta says {}",
153
                meta.object_key(),
154
                meta.bytes
155
            );
156
        }
157
        let sha256 = sha256_of(&local)?;
158
        if sha256 != meta.sha256 {
159
            bail!(
160
                "{} hashes to {sha256}, its meta says {}",
161
                meta.object_key(),
162
                meta.sha256
163
            );
164
        }
165
        Ok(local)
166
    }
167

            
168
    /// Writes the manifest. Only finalize calls this, and only after both kinds
169
    /// have been fetched and verified — the manifest existing is the signal that
170
    /// the pair is complete and consistent.
171
    pub fn write_manifest(&self, metas: &[Meta], identity: &Identity, from: &Path) -> Result<()> {
172
        let entries: Vec<serde_json::Value> = metas
173
            .iter()
174
            .map(|meta| {
175
                serde_json::json!({
176
                    "kind": meta.kind.as_str(),
177
                    "object": meta.object_key(),
178
                    "sha256": meta.sha256,
179
                    "bytes": meta.bytes,
180
                    "run_attempt": meta.identity.run_attempt,
181
                })
182
            })
183
            .collect();
184
        let manifest = serde_json::json!({
185
            "commit": identity.commit,
186
            "run_id": identity.run_id,
187
            "builder": identity.builder,
188
            "artifacts": entries,
189
        });
190

            
191
        let local = from.join(crate::artifact::MANIFEST_NAME);
192
        std::fs::write(&local, serde_json::to_vec_pretty(&manifest)?)
193
            .with_context(|| format!("cannot write {}", local.display()))?;
194
        self.s3(&[
195
            "put",
196
            &local.to_string_lossy(),
197
            &format!(
198
                "{}/{}",
199
                identity.prefix(&self.bucket),
200
                crate::artifact::MANIFEST_NAME
201
            ),
202
        ])
203
    }
204
}