OXYGENHUB
◆ v0.9.2 oxygenhub / crates / hub / src / images.rs
11.0 KiB · Rust · last changed Repository bar: an Artifacts menu and a More menu 17 days ago source blame history raw
1
//! Images by hash (oxygenflow#39, OxygenFlow docs/jetstream.md §8): rootfs
2
//! images kept site-wide for runners to fetch, so nobody copies an image
3
//! between machines. An image arrives as chunks (Oxygen's FastCDC, sent
4
//! only when this hub lacks them) and then its manifest, which is accepted
5
//! only when every chunk is here and the image they make has the hash it
6
//! is named by. Chunks are kept zstd-compressed under
7
//! `<data>/images/chunks`, shared by every image that has them.
8
9
use crate::db::{now_ms, Pool};
10
use jet::images::{is_hash, Manifest};
11
use std::io::Write;
12
use std::path::{Path, PathBuf};
13
14
/// Where this hub keeps images.
15
#[derive(Clone, Debug)]
16
pub struct Store {
17
    root: PathBuf,
18
}
19
20
impl Store {
21
    pub fn new(data: &Path) -> Store {
22
        Store { root: data.join("images") }
23
    }
24
25
    fn chunk_path(&self, hash: &str) -> PathBuf {
26
        self.root.join("chunks").join(&hash[..2]).join(hash)
27
    }
28
29
    fn manifest_path(&self, hash: &str) -> PathBuf {
30
        self.root.join("manifests").join(format!("{hash}.json"))
31
    }
32
33
    pub fn has_chunk(&self, hash: &str) -> bool {
34
        is_hash(hash) && self.chunk_path(hash).exists()
35
    }
36
37
    /// Keep chunk `hash`, whose bytes the caller has checked.
38
    pub fn put_chunk(&self, hash: &str, bytes: &[u8]) -> std::io::Result<()> {
39
        let path = self.chunk_path(hash);
40
        if path.exists() {
41
            return Ok(());
42
        }
43
        std::fs::create_dir_all(path.parent().expect("a chunk's directory"))?;
44
        let tmp = path.with_extension(format!("tmp{}", std::process::id()));
45
        let mut f = std::fs::File::create(&tmp)?;
46
        f.write_all(&zstd::encode_all(bytes, 3)?)?;
47
        f.sync_all()?;
48
        std::fs::rename(&tmp, &path)
49
    }
50
51
    pub fn chunk(&self, hash: &str) -> std::io::Result<Vec<u8>> {
52
        if !is_hash(hash) {
53
            return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "not a chunk name"));
54
        }
55
        zstd::decode_all(std::fs::File::open(self.chunk_path(hash))?)
56
    }
57
58
    pub fn manifest(&self, hash: &str) -> Option<Manifest> {
59
        if !is_hash(hash) {
60
            return None;
61
        }
62
        serde_json::from_slice(&std::fs::read(self.manifest_path(hash)).ok()?).ok()
63
    }
64
65
    /// Accept manifest `m`: well formed, every chunk here, and the image
66
    /// they make hashing to its name. Reads the whole image once.
67
    pub fn verify_and_keep(&self, m: &Manifest) -> Result<(), String> {
68
        m.check().map_err(|e| e.to_string())?;
69
        let mut whole = blake3::Hasher::new();
70
        for c in &m.chunks {
71
            if c.is_zeros() {
72
                whole.update(&vec![0u8; c.len()]);
73
                continue;
74
            }
75
            let bytes = self.chunk(c.hash()).map_err(|_| format!("chunk {} was not sent; send it first", &c.hash()[..12]))?;
76
            if bytes.len() != c.len() {
77
                return Err(format!("chunk {} is {} bytes, not {}", &c.hash()[..12], bytes.len(), c.len()));
78
            }
79
            whole.update(&bytes);
80
        }
81
        let got = whole.finalize().to_hex().to_string();
82
        if got != m.hash {
83
            return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12]));
84
        }
85
        self.keep_manifest(m)
86
    }
87
88
    /// Write manifest `m`, whose image is here.
89
    fn keep_manifest(&self, m: &Manifest) -> Result<(), String> {
90
        let path = self.manifest_path(&m.hash);
91
        std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?;
92
        let tmp = path.with_extension("json.tmp");
93
        std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
94
        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
95
    }
96
97
    /// What image `m` is for, read from its own files (OxygenFlow spec
98
    /// §11.2, oxygenhub#173): its shell's ELF header, found through its ext4
99
    /// out of the kept chunks, a few of them, not the whole image. `None`
100
    /// when it is not ext4 or has no shell to read.
101
    pub fn read_arch(&self, m: &Manifest) -> Option<&'static str> {
102
        let mut starts = Vec::with_capacity(m.chunks.len());
103
        let mut end = 0u64;
104
        for c in &m.chunks {
105
            starts.push(end);
106
            end += c.len() as u64;
107
        }
108
        let mut cache: std::collections::HashMap<usize, Vec<u8>> = std::collections::HashMap::new();
109
        let short = || std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "past the image's end");
110
        jet::rootfs::arch(&mut |at: u64, buf: &mut [u8]| {
111
            let mut done = 0usize;
112
            while done < buf.len() {
113
                let pos = at + done as u64;
114
                let i = starts.partition_point(|&s| s <= pos).checked_sub(1).ok_or_else(short)?;
115
                let c = &m.chunks[i];
116
                let within = (pos - starts[i]) as usize;
117
                if within >= c.len() {
118
                    return Err(short());
119
                }
120
                let n = (c.len() - within).min(buf.len() - done);
121
                if c.is_zeros() {
122
                    buf[done..done + n].fill(0);
123
                } else {
124
                    if !cache.contains_key(&i) {
125
                        if cache.len() >= 32 {
126
                            cache.clear();
127
                        }
128
                        cache.insert(i, self.chunk(c.hash())?);
129
                    }
130
                    let bytes = &cache[&i];
131
                    if bytes.len() != c.len() {
132
                        return Err(short());
133
                    }
134
                    buf[done..done + n].copy_from_slice(&bytes[within..within + n]);
135
                }
136
                done += n;
137
            }
138
            Ok(())
139
        })
140
    }
141
142
    /// Keep image `m`, checked as [`Store::verify_and_keep`], with the
143
    /// architecture its own files name: the manifest's word is taken only
144
    /// when the image cannot be read. The manifest as kept.
145
    pub fn keep_reading_arch(&self, m: &Manifest) -> Result<Manifest, String> {
146
        self.verify_and_keep(m)?;
147
        match self.read_arch(m) {
148
            Some(a) if m.arch.as_deref() != Some(a) => {
149
                let mut kept = m.clone();
150
                kept.arch = Some(a.to_string());
151
                self.keep_manifest(&kept)?;
152
                Ok(kept)
153
            }
154
            _ => Ok(m.clone()),
155
        }
156
    }
157
158
    /// Record `arch` in kept image `hash`'s manifest.
159
    fn set_arch(&self, hash: &str, arch: &str) -> Result<(), String> {
160
        let mut m = self.manifest(hash).ok_or("no such image")?;
161
        m.arch = Some(arch.to_string());
162
        self.keep_manifest(&m)
163
    }
164
}
165
166
/// Record an image this hub now keeps.
167
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
168
    sqlx::query(
169
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, $2, $3, $4, $5, $6) \
170
         ON CONFLICT (hash) DO UPDATE SET arch = excluded.arch WHERE excluded.arch != ''",
171
    )
172
    .bind(&m.hash)
173
    .bind(m.size as i64)
174
    .bind(m.arch.clone().unwrap_or_default())
175
    .bind(m.chunks.len() as i64)
176
    .bind(uploader)
177
    .bind(now_ms())
178
    .execute(pool)
179
    .await?;
180
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
181
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
182
    }
183
    Ok(())
184
}
185
186
/// Learn what every image is for (oxygenhub#173), once at start: read
187
/// each kept image that has no architecture from its own files, then give
188
/// any image still unknown the architecture its jobs passed on. A runner's
189
/// own report, read from the image it holds, wins over what was learned.
190
/// How many images were read.
191
pub async fn learn_archs(app: &crate::Shared) -> sqlx::Result<usize> {
192
    let mut read = 0;
193
    for k in kept(&app.db).await?.into_iter().filter(|k| k.arch.is_empty()) {
194
        let store = app.images();
195
        let hash = k.hash.clone();
196
        let arch = tokio::task::spawn_blocking(move || {
197
            let a = store.read_arch(&store.manifest(&hash)?)?;
198
            store.set_arch(&hash, a).ok()?;
199
            Some(a)
200
        })
201
        .await
202
        .ok()
203
        .flatten();
204
        if let Some(a) = arch {
205
            sqlx::query("UPDATE jet_images SET arch = $1 WHERE hash = $2").bind(a).bind(&k.hash).execute(&app.db).await?;
206
            crate::db::flow::record_image_archs(&app.db, &[(k.hash.clone(), a.to_string())].into_iter().collect()).await?;
207
            read += 1;
208
        }
209
    }
210
    crate::db::flow::learn_archs_from_runs(&app.db).await?;
211
    Ok(read)
212
}
213
214
/// Every image this hub keeps, by hash.
215
pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> {
216
    Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect())
217
}
218
219
/// An image this hub keeps (`jet_images`).
220
#[derive(sqlx::FromRow)]
221
pub struct Kept {
222
    pub hash: String,
223
    pub size: i64,
224
    pub arch: String,
225
    pub chunks: i64,
226
    pub uploader: String,
227
    pub created_ms: i64,
228
}
229
230
pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> {
231
    sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms FROM jet_images").fetch_all(pool).await
232
}
233
234
/// One repository's use of one image: its jobs since `since_ms`, and when
235
/// it last ran one.
236
#[derive(sqlx::FromRow)]
237
pub struct Use {
238
    pub image: String,
239
    pub owner: String,
240
    pub name: String,
241
    pub jobs: i64,
242
    pub last_ms: i64,
243
}
244
245
/// What each repository's jobs ran on since `since_ms`, newest first.
246
pub async fn uses(pool: &Pool, since_ms: i64) -> sqlx::Result<Vec<Use>> {
247
    let rootfs = crate::db::json_text("j.sandbox", "rootfs");
248
    sqlx::query_as(&format!(
249
        "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs,
250
                MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms
251
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
252
         WHERE r.created_ms >= $1 AND COALESCE({rootfs}, '') != ''
253
         GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC"
254
    ))
255
    .bind(since_ms)
256
    .fetch_all(pool)
257
    .await
258
}
259
260
/// What one repository's jobs ran on since `since_ms`, newest first
261
/// (its Images page, oxygenhub#254).
262
pub async fn uses_of(pool: &Pool, repo_id: i64, since_ms: i64) -> sqlx::Result<Vec<Use>> {
263
    let rootfs = crate::db::json_text("j.sandbox", "rootfs");
264
    sqlx::query_as(&format!(
265
        "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs,
266
                MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms
267
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
268
         WHERE r.created_ms >= $1 AND p.id = $2 AND COALESCE({rootfs}, '') != ''
269
         GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC"
270
    ))
271
    .bind(since_ms)
272
    .bind(repo_id)
273
    .fetch_all(pool)
274
    .await
275
}
276
277
/// The architecture runners reported for each image (`flow_images`).
278
pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
279
    Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect())
280
}