OXYGENHUB

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

270 of 291 lines run, 48 of 103 functions · run #524 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 {
2142×
    pub fn new(data: &Path) -> Store {
2242×
        Store { root: data.join("images") }
2342×
    }
24
2560055×
    fn chunk_path(&self, hash: &str) -> PathBuf {
2660055×
        self.root.join("chunks").join(&hash[..2]).join(hash)
2760055×
    }
28
2928×
    fn manifest_path(&self, hash: &str) -> PathBuf {
3028×
        self.root.join("manifests").join(format!("{hash}.json"))
3128×
    }
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.
3815×
    pub fn put_chunk(&self, hash: &str, bytes: &[u8]) -> std::io::Result<()> {
3915×
        let path = self.chunk_path(hash);
4015×
        if path.exists() {
410×
            return Ok(());
4215×
        }
4315×
        std::fs::create_dir_all(path.parent().expect("a chunk's directory"))?;
4415×
        let tmp = path.with_extension(format!("tmp{}", std::process::id()));
4515×
        let mut f = std::fs::File::create(&tmp)?;
4615×
        f.write_all(&zstd::encode_all(bytes, 3)?)?;
4715×
        f.sync_all()?;
4815×
        std::fs::rename(&tmp, &path)
4915×
    }
50
5134×
    pub fn chunk(&self, hash: &str) -> std::io::Result<Vec<u8>> {
5234×
        if !is_hash(hash) {
530×
            return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "not a chunk name"));
5434×
        }
5534×
        zstd::decode_all(std::fs::File::open(self.chunk_path(hash))?)
5634×
    }
57
5820×
    pub fn manifest(&self, hash: &str) -> Option<Manifest> {
5920×
        if !is_hash(hash) {
600×
            return None;
6120×
        }
6220×
        serde_json::from_slice(&std::fs::read(self.manifest_path(hash)).ok()?).ok()
6320×
    }
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.
676×
    pub fn verify_and_keep(&self, m: &Manifest) -> Result<(), String> {
686×
        m.check().map_err(|e| e.to_string())?;
696×
        let mut whole = blake3::Hasher::new();
7060×
        for c in &m.chunks {
7160×
            if c.is_zeros() {
7243×
                whole.update(&vec![0u8; c.len()]);
7343×
                continue;
7417×
            }
7517×
            let bytes = self.chunk(c.hash()).map_err(|_| format!("chunk {} was not sent; send it first", &c.hash()[..12]))?;
7616×
            if bytes.len() != c.len() {
770×
                return Err(format!("chunk {} is {} bytes, not {}", &c.hash()[..12], bytes.len(), c.len()));
7816×
            }
7916×
            whole.update(&bytes);
80
        }
815×
        let got = whole.finalize().to_hex().to_string();
825×
        if got != m.hash {
831×
            return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12]));
844×
        }
854×
        self.keep_manifest(m)
866×
    }
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.
1017×
    fn keep_manifest(&self, m: &Manifest) -> Result<(), String> {
1027×
        let path = self.manifest_path(&m.hash);
1037×
        std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?;
1047×
        let tmp = path.with_extension("json.tmp");
1057×
        std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
1067×
        std::fs::rename(&tmp, &path).map_err(|e| e.to_string())
1077×
    }
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;
11680×
        for c in &m.chunks {
11780×
            starts.push(end);
11880×
            end += c.len() as u64;
11980×
        }
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;
126205×
                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) {
1379×
                        if cache.len() >= 32 {
1380×
                            cache.clear();
1399×
                        }
1409×
                        cache.insert(i, self.chunk(c.hash())?);
14122×
                    }
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.
1806×
pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> {
1816×
    sqlx::query(
1826×
        "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, $2, $3, $4, $5, $6) \
1836×
         ON CONFLICT (hash) DO UPDATE SET source = 'hub', holder = '', \
1846×
         arch = CASE WHEN excluded.arch != '' THEN excluded.arch ELSE jet_images.arch END",
1856×
    )
1866×
    .bind(&m.hash)
1876×
    .bind(m.size as i64)
1886×
    .bind(m.arch.clone().unwrap_or_default())
1896×
    .bind(m.chunks.len() as i64)
1906×
    .bind(uploader)
1916×
    .bind(now_ms())
1926×
    .execute(pool)
1936×
    .await?;
1946×
    if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) {
1955×
        crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?;
1961×
    }
1976×
    Ok(())
1986×
}
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.
205200×
pub async fn learn_archs(app: &crate::Shared) -> sqlx::Result<usize> {
206200×
    let mut read = 0;
207200×
    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
    }
224200×
    crate::db::flow::learn_archs_from_runs(&app.db).await?;
225199×
    Ok(read)
226199×
}
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.
252141×
pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> {
253141×
    Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect())
254141×
}
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.
288250×
pub async fn reach(app: &crate::Shared) -> sqlx::Result<Reach> {
289250×
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, source FROM jet_images").fetch_all(&app.db).await?;
290250×
    let mut reach = Reach::default();
291250×
    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
    }
298310×
    for r in crate::db::runners::list(&app.db).await? {
299310×
        if r.state == "disabled" || !r.online() {
3003×
            continue;
301307×
        }
302307×
        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
    }
312250×
    Ok(reach)
313250×
}
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
329206×
pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> {
330206×
    sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms, source, holder FROM jet_images").fetch_all(pool).await
331206×
}
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
}