OXYGENHUB

Coverage / crates/hub/src/images.rs 92.8%

270 of 291 lines run, 48 of 103 functions · run #503 on main · run missed

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 {
2131×
    pub fn new(data: &Path) -> Store {
2231×
        Store { root: data.join("images") }
2331×
    }
24
2560039×
    fn chunk_path(&self, hash: &str) -> PathBuf {
2660039×
        self.root.join("chunks").join(&hash[..2]).join(hash)
2760039×
    }
28
2921×
    fn manifest_path(&self, hash: &str) -> PathBuf {
3021×
        self.root.join("manifests").join(format!("{hash}.json"))
3121×
    }
32
3360006×
    pub fn has_chunk(&self, hash: &str) -> bool {
3460006×
        is_hash(hash) && self.chunk_path(hash).exists()
3560006×
    }
36
37
    /// Keep chunk `hash`, whose bytes the caller has checked.
3810×
    pub fn put_chunk(&self, hash: &str, bytes: &[u8]) -> std::io::Result<()> {
3910×
        let path = self.chunk_path(hash);
4010×
        if path.exists() {
410×
            return Ok(());
4210×
        }
4310×
        std::fs::create_dir_all(path.parent().expect("a chunk's directory"))?;
4410×
        let tmp = path.with_extension(format!("tmp{}", std::process::id()));
4510×
        let mut f = std::fs::File::create(&tmp)?;
4610×
        f.write_all(&zstd::encode_all(bytes, 3)?)?;
4710×
        f.sync_all()?;
4810×
        std::fs::rename(&tmp, &path)
4910×
    }
50
5123×
    pub fn chunk(&self, hash: &str) -> std::io::Result<Vec<u8>> {
5223×
        if !is_hash(hash) {
530×
            return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "not a chunk name"));
5423×
        }
5523×
        zstd::decode_all(std::fs::File::open(self.chunk_path(hash))?)
5623×
    }
57
5814×
    pub fn manifest(&self, hash: &str) -> Option<Manifest> {
5914×
        if !is_hash(hash) {
600×
            return None;
6114×
        }
6214×
        serde_json::from_slice(&std::fs::read(self.manifest_path(hash)).ok()?).ok()
6314×
    }
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.
675×
    pub fn verify_and_keep(&self, m: &Manifest) -> Result<(), String> {
685×
        m.check().map_err(|e| e.to_string())?;
695×
        let mut whole = blake3::Hasher::new();
7055×
        for c in &m.chunks {
7155×
            if c.is_zeros() {
7243×
                whole.update(&vec![0u8; c.len()]);
7343×
                continue;
7412×
            }
7512×
            let bytes = self.chunk(c.hash()).map_err(|_| format!("chunk {} was not sent; send it first", &c.hash()[..12]))?;
7611×
            if bytes.len() != c.len() {
770×
                return Err(format!("chunk {} is {} bytes, not {}", &c.hash()[..12], bytes.len(), c.len()));
7811×
            }
7911×
            whole.update(&bytes);
80
        }
814×
        let got = whole.finalize().to_hex().to_string();
824×
        if got != m.hash {
831×
            return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12]));
843×
        }
853×
        self.keep_manifest(m)
865×
    }
87
88
    /// Keep manifest `m` of an image only runners hold (oxygenflow#94): well
89
    /// formed, its chunks not here, so not checked against its hash; a
90
    /// fetcher checks the image it assembles against the pinned hash.
91
    /// Never over a manifest already kept.
921×
    pub fn keep_peer_manifest(&self, m: &Manifest) -> Result<(), String> {
931×
        m.check().map_err(|e| e.to_string())?;
941×
        if self.manifest_path(&m.hash).exists() {
950×
            return Ok(());
961×
        }
971×
        self.keep_manifest(m)
981×
    }
99
100
    /// Write manifest `m`, whose image is here.
1016×
    fn keep_manifest(&self, m: &Manifest) -> Result<(), String> {
1026×
        let path = self.manifest_path(&m.hash);
1036×
        std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?;
1046×
        let tmp = path.with_extension("json.tmp");
1056×
        std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
1066×
        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
1076×
    }
108
109
    /// What image `m` is for, read from its own files (OxygenFlow spec
110
    /// §11.2, oxygenhub#173): its shell's ELF header, found through its ext4
111
    /// out of the kept chunks, a few of them, not the whole image. `None`
112
    /// when it is not ext4 or has no shell to read.
1135×
    pub fn read_arch(&self, m: &Manifest) -> Option<&'static str> {
1145×
        let mut starts = Vec::with_capacity(m.chunks.len());
1155×
        let mut end = 0u64;
11678×
        for c in &m.chunks {
11778×
            starts.push(end);
11878×
            end += c.len() as u64;
11978×
        }
1205×
        let mut cache: std::collections::HashMap<usize, Vec<u8>> = std::collections::HashMap::new();
1215×
        let short = || std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "past the image's end");
12231×
        jet::rootfs::arch(&mut |at: u64, buf: &mut [u8]| {
12331×
            let mut done = 0usize;
12462×
            while done < buf.len() {
12531×
                let pos = at + done as u64;
126177×
                let i = starts.partition_point(|&s| s <= pos).checked_sub(1).ok_or_else(short)?;
12731×
                let c = &m.chunks[i];
12831×
                let within = (pos - starts[i]) as usize;
12931×
                if within >= c.len() {
1300×
                    return Err(short());
13131×
                }
13231×
                let n = (c.len() - within).min(buf.len() - done);
13331×
                if c.is_zeros() {
1340×
                    buf[done..done + n].fill(0);
1350×
                } else {
13631×
                    if !cache.contains_key(&i) {
1377×
                        if cache.len() >= 32 {
1380×
                            cache.clear();
1397×
                        }
1407×
                        cache.insert(i, self.chunk(c.hash())?);
14124×
                    }
14231×
                    let bytes = &cache[&i];
14331×
                    if bytes.len() != c.len() {
1440×
                        return Err(short());
14531×
                    }
14631×
                    buf[done..done + n].copy_from_slice(&bytes[within..within + n]);
147
                }
14831×
                done += n;
149
            }
15031×
            Ok(())
15131×
        })
1525×
    }
153
154
    /// Keep image `m`, checked as [`Store::verify_and_keep`], with the
155
    /// architecture its own files name: the manifest's word is taken only
156
    /// when the image cannot be read. The manifest as kept.
1575×
    pub fn keep_reading_arch(&self, m: &Manifest) -> Result<Manifest, String> {
1585×
        self.verify_and_keep(m)?;
1593×
        match self.read_arch(m) {
1601×
            Some(a) if m.arch.as_deref() != Some(a) => {
1611×
                let mut kept = m.clone();
1621×
                kept.arch = Some(a.to_string());
1631×
                self.keep_manifest(&kept)?;
1641×
                Ok(kept)
165
            }
1662×
            _ => Ok(m.clone()),
167
        }
1685×
    }
169
170
    /// Record `arch` in kept image `hash`'s manifest.
1711×
    fn set_arch(&self, hash: &str, arch: &str) -> Result<(), String> {
1721×
        let mut m = self.manifest(hash).ok_or("no such image")?;
1731×
        m.arch = Some(arch.to_string());
1741×
        self.keep_manifest(&m)
1751×
    }
176
}
177
178
/// Record an image this hub now keeps, its chunks and all: one only
179
/// runners held before becomes the hub's.
1805×
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
1815×
    sqlx::query(
1825×
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, $2, $3, $4, $5, $6) \
1835×
         ON CONFLICT (hash) DO UPDATE SET source = 'hub', holder = '', \
1845×
         arch = CASE WHEN excluded.arch != '' THEN excluded.arch ELSE jet_images.arch END",
1855×
    )
1865×
    .bind(&m.hash)
1875×
    .bind(m.size as i64)
1885×
    .bind(m.arch.clone().unwrap_or_default())
1895×
    .bind(m.chunks.len() as i64)
1905×
    .bind(uploader)
1915×
    .bind(now_ms())
1925×
    .execute(pool)
1935×
    .await?;
1945×
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
1954×
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
1961×
    }
1975×
    Ok(())
1985×
}
199
200
/// Learn what every image is for (oxygenhub#173), once at start: read
201
/// each kept image that has no architecture from its own files, then give
202
/// any image still unknown the architecture its jobs passed on. A runner's
203
/// own report, read from the image it holds, wins over what was learned.
204
/// How many images were read.
205193×
pub async fn learn_archs(app: &crate::Shared) -> sqlx::Result<usize> {
206193×
    let mut read = 0;
207193×
    for k in kept(&app.db).await?.into_iter().filter(|k| k.arch.is_empty()) {
2082×
        let store = app.images();
2092×
        let hash = k.hash.clone();
2102×
        let arch = tokio::task::spawn_blocking(move || {
2112×
            let a = store.read_arch(&store.manifest(&hash)?)?;
2121×
            store.set_arch(&hash, a).ok()?;
2131×
            Some(a)
2142×
        })
2152×
        .await
2162×
        .ok()
2172×
        .flatten();
2182×
        if let Some(a) = arch {
2191×
            sqlx::query("UPDATE jet_images SET arch = $1 WHERE hash = $2").bind(a).bind(&k.hash).execute(&app.db).await?;
2201×
            crate::db::flow::record_image_archs(&app.db, &[(k.hash.clone(), a.to_string())].into_iter().collect()).await?;
2211×
            read += 1;
2221×
        }
223
    }
224193×
    crate::db::flow::learn_archs_from_runs(&app.db).await?;
225192×
    Ok(read)
226192×
}
227
228
/// Record an image only runners hold, whose manifest `holder` sent
229
/// (oxygenflow#94). Never over one the hub keeps.
2302×
pub async fn record_peer(pool: &Pool, m: &Manifest, holder: &str) -> sqlx::Result<()> {
2312×
    sqlx::query(
2322×
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms, source, holder) VALUES ($1, $2, $3, $4, $5, $6, 'peer', $7) \
2332×
         ON CONFLICT (hash) DO NOTHING",
2342×
    )
2352×
    .bind(&m.hash)
2362×
    .bind(m.size as i64)
2372×
    .bind(m.arch.clone().unwrap_or_default())
2382×
    .bind(m.chunks.len() as i64)
2392×
    .bind(holder)
2402×
    .bind(now_ms())
2412×
    .bind(holder)
2422×
    .execute(pool)
2432×
    .await?;
2442×
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
2452×
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
2460×
    }
2472×
    Ok(())
2482×
}
249
250
/// Every image this hub keeps a manifest of, by hash: its own and those
251
/// only runners hold.
252136×
pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> {
253136×
    Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect())
254136×
}
255
256
/// Where the images jobs need can come from (oxygenflow#94): the hub's
257
/// own, pushed and verified, and those only runners hold, each with the
258
/// swarm sites of the online runners holding it now.
259
#[derive(Debug, Default)]
260
pub struct Reach {
261
    pub hub: std::collections::HashSet<String>,
262
    pub peers: std::collections::HashMap<String, Vec<String>>,
263
    /// Each runner in a swarm now, and its site.
264
    pub sites: std::collections::HashMap<i64, String>,
265
}
266
267
impl Reach {
268
    /// Whether a runner that fetches images can get `image`: from the hub,
269
    /// or from a runner of its own site.
27011×
    pub fn fetchable(&self, runner: i64, image: &str) -> bool {
27111×
        self.hub.contains(image) || self.sites.get(&runner).is_some_and(|s| self.peers.get(image).is_some_and(|v| v.contains(s)))
27211×
    }
273
274
    /// Every image `runner` can fetch.
27516×
    pub fn for_runner(&self, runner: i64) -> std::collections::HashSet<String> {
27616×
        let mut out = self.hub.clone();
27716×
        out.extend(self.peers.keys().filter(|i| self.fetchable(runner, i)).cloned());
27816×
        out
27916×
    }
280
281
    /// Whether only runners hold `image`.
2824×
    pub fn peer_only(&self, image: &str) -> bool {
2834×
        !self.hub.contains(image) && self.peers.contains_key(image)
2844×
    }
285
}
286
287
/// [`Reach`] now.
288245×
pub async fn reach(app: &crate::Shared) -> sqlx::Result<Reach> {
289245×
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, source FROM jet_images").fetch_all(&app.db).await?;
290245×
    let mut reach = Reach::default();
291245×
    for (hash, source) in rows {
29226×
        if source == "peer" {
2931×
            reach.peers.insert(hash, Vec::new());
29425×
        } else {
29525×
            reach.hub.insert(hash);
29625×
        }
297
    }
298305×
    for r in crate::db::runners::list(&app.db).await? {
299305×
        if r.state == "disabled" || !r.online() {
3003×
            continue;
301302×
        }
302302×
        let Some(addr) = app.swarm.addr(r.id) else { continue };
30313×
        for image in r.image_list().unwrap_or_default() {
3045×
            if let Some(sites) = reach.peers.get_mut(&image.to_ascii_lowercase()) {
3050×
                if !sites.contains(&addr.site) {
3060×
                    sites.push(addr.site.clone());
3070×
                }
3085×
            }
309
        }
31013×
        reach.sites.insert(r.id, addr.site);
311
    }
312245×
    Ok(reach)
313245×
}
314
315
/// An image this hub keeps (`jet_images`).
316
#[derive(sqlx::FromRow)]
317
pub struct Kept {
318
    pub hash: String,
319
    pub size: i64,
320
    pub arch: String,
321
    pub chunks: i64,
322
    pub uploader: String,
323
    pub created_ms: i64,
324
    /// 'hub', or 'peer' for one only runners hold (oxygenflow#94).
325
    pub source: String,
326
    pub holder: String,
327
}
328
329198×
pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> {
330198×
    sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms, source, holder FROM jet_images").fetch_all(pool).await
331198×
}
332
333
/// One repository's use of one image: its jobs since `since_ms`, and when
334
/// it last ran one.
335
#[derive(sqlx::FromRow)]
336
pub struct Use {
337
    pub image: String,
338
    pub owner: String,
339
    pub name: String,
340
    pub jobs: i64,
341
    pub last_ms: i64,
342
}
343
344
/// What each repository's jobs ran on since `since_ms`, newest first.
3452×
pub async fn uses(pool: &Pool, since_ms: i64) -> sqlx::Result<Vec<Use>> {
3462×
    let rootfs = crate::db::json_text("j.sandbox", "rootfs");
3472×
    sqlx::query_as(&format!(
3482×
        "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs,
3492×
                MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms
3502×
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
3512×
         WHERE r.created_ms >= $1 AND COALESCE({rootfs}, '') != ''
3522×
         GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC"
3532×
    ))
3542×
    .bind(since_ms)
3552×
    .fetch_all(pool)
3562×
    .await
3572×
}
358
359
/// What one repository's jobs ran on since `since_ms`, newest first
360
/// (its Images page, oxygenhub#254).
3611×
pub async fn uses_of(pool: &Pool, repo_id: i64, since_ms: i64) -> sqlx::Result<Vec<Use>> {
3621×
    let rootfs = crate::db::json_text("j.sandbox", "rootfs");
3631×
    sqlx::query_as(&format!(
3641×
        "SELECT lower({rootfs}) AS image, p.owner, p.name, COUNT(*) AS jobs,
3651×
                MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms
3661×
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id
3671×
         WHERE r.created_ms >= $1 AND p.id = $2 AND COALESCE({rootfs}, '') != ''
3681×
         GROUP BY lower({rootfs}), p.id, p.owner, p.name ORDER BY last_ms DESC"
3691×
    ))
3701×
    .bind(since_ms)
3711×
    .bind(repo_id)
3721×
    .fetch_all(pool)
3731×
    .await
3741×
}
375
376
/// The architecture runners reported for each image (`flow_images`).
3772×
pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> {
3782×
    Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect())
3792×
}
380
381
#[cfg(test)]
382
mod reach_tests {
383
    use super::Reach;
384
385
    #[test]
3861×
    fn an_image_only_runners_hold_is_fetchable_in_their_site_alone() {
3871×
        let mut r = Reach::default();
3881×
        r.hub.insert("hub-image".into());
3891×
        r.peers.insert("peer-image".into(), vec!["home".into()]);
3901×
        r.sites.insert(1, "home".into());
3911×
        r.sites.insert(2, "cloud".into());
3921×
        assert!(r.fetchable(1, "hub-image") && r.fetchable(2, "hub-image") && r.fetchable(3, "hub-image"));
3931×
        assert!(r.fetchable(1, "peer-image"), "a runner of the holder's site");
3941×
        assert!(!r.fetchable(2, "peer-image"), "another site");
3951×
        assert!(!r.fetchable(3, "peer-image"), "a runner in no swarm");
3961×
        assert!(r.peer_only("peer-image") && !r.peer_only("hub-image"));
3971×
        assert_eq!(r.for_runner(1).len(), 2);
3981×
        assert_eq!(r.for_runner(2).len(), 1);
3991×
    }
400
}