//! Images by hash (oxygenflow#39, OxygenFlow docs/jetstream.md ยง8): rootfs //! images kept site-wide for runners to fetch, so nobody copies an image //! between machines. An image arrives as chunks (Oxygen's FastCDC, sent //! only when this hub lacks them) and then its manifest, which is accepted //! only when every chunk is here and the image they make has the hash it //! is named by. Chunks are kept zstd-compressed under //! `/images/chunks`, shared by every image that has them. use crate::db::{now_ms, Pool}; use jet::images::{is_hash, Manifest}; use std::io::Write; use std::path::{Path, PathBuf}; /// Where this hub keeps images. #[derive(Clone, Debug)] pub struct Store { root: PathBuf, } impl Store { pub fn new(data: &Path) -> Store { Store { root: data.join("images") } } fn chunk_path(&self, hash: &str) -> PathBuf { self.root.join("chunks").join(&hash[..2]).join(hash) } fn manifest_path(&self, hash: &str) -> PathBuf { self.root.join("manifests").join(format!("{hash}.json")) } pub fn has_chunk(&self, hash: &str) -> bool { is_hash(hash) && self.chunk_path(hash).exists() } /// Keep chunk `hash`, whose bytes the caller has checked. pub fn put_chunk(&self, hash: &str, bytes: &[u8]) -> std::io::Result<()> { let path = self.chunk_path(hash); if path.exists() { return Ok(()); } std::fs::create_dir_all(path.parent().expect("a chunk's directory"))?; let tmp = path.with_extension(format!("tmp{}", std::process::id())); let mut f = std::fs::File::create(&tmp)?; f.write_all(&zstd::encode_all(bytes, 3)?)?; f.sync_all()?; std::fs::rename(&tmp, &path) } pub fn chunk(&self, hash: &str) -> std::io::Result> { if !is_hash(hash) { return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "not a chunk name")); } zstd::decode_all(std::fs::File::open(self.chunk_path(hash))?) } pub fn manifest(&self, hash: &str) -> Option { if !is_hash(hash) { return None; } serde_json::from_slice(&std::fs::read(self.manifest_path(hash)).ok()?).ok() } /// Accept manifest `m`: well formed, every chunk here, and the image /// they make hashing to its name. Reads the whole image once. pub fn verify_and_keep(&self, m: &Manifest) -> Result<(), String> { m.check().map_err(|e| e.to_string())?; let mut whole = blake3::Hasher::new(); for c in &m.chunks { if c.is_zeros() { whole.update(&vec![0u8; c.len()]); continue; } let bytes = self.chunk(c.hash()).map_err(|_| format!("chunk {} was not sent; send it first", &c.hash()[..12]))?; if bytes.len() != c.len() { return Err(format!("chunk {} is {} bytes, not {}", &c.hash()[..12], bytes.len(), c.len())); } whole.update(&bytes); } let got = whole.finalize().to_hex().to_string(); if got != m.hash { return Err(format!("the chunks make an image named {}, not {}", &got[..12], &m.hash[..12])); } let path = self.manifest_path(&m.hash); std::fs::create_dir_all(path.parent().expect("the manifests directory")).map_err(|e| e.to_string())?; let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, serde_json::to_vec(m).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?; std::fs::rename(&tmp, &path).map_err(|e| e.to_string()) } } /// Record an image this hub now keeps. pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> { sqlx::query( "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES (?, ?, ?, ?, ?, ?) \ ON CONFLICT (hash) DO NOTHING", ) .bind(&m.hash) .bind(m.size as i64) .bind(m.arch.clone().unwrap_or_default()) .bind(m.chunks.len() as i64) .bind(uploader) .bind(now_ms()) .execute(pool) .await?; if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) { crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?; } Ok(()) } /// Every image this hub keeps, by hash. pub async fn held(pool: &Pool) -> sqlx::Result> { Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect()) } /// An image this hub keeps (`jet_images`). #[derive(sqlx::FromRow)] pub struct Kept { pub hash: String, pub size: i64, pub arch: String, pub chunks: i64, pub uploader: String, pub created_ms: i64, } pub async fn kept(pool: &Pool) -> sqlx::Result> { sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms FROM jet_images").fetch_all(pool).await } /// One repository's use of one image: its jobs since `since_ms`, and when /// it last ran one. #[derive(sqlx::FromRow)] pub struct Use { pub image: String, pub owner: String, pub name: String, pub jobs: i64, pub last_ms: i64, } /// What each repository's jobs ran on since `since_ms`, newest first. pub async fn uses(pool: &Pool, since_ms: i64) -> sqlx::Result> { sqlx::query_as( "SELECT lower(json_extract(j.sandbox, '$.rootfs')) AS image, p.owner, p.name, COUNT(*) AS jobs, MAX(COALESCE(j.started_ms, r.created_ms)) AS last_ms FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories p ON p.id = r.repo_id WHERE r.created_ms >= ? AND COALESCE(json_extract(j.sandbox, '$.rootfs'), '') != '' GROUP BY image, p.id ORDER BY last_ms DESC", ) .bind(since_ms) .fetch_all(pool) .await } /// The architecture runners reported for each image (`flow_images`). pub async fn reported_archs(pool: &Pool) -> sqlx::Result> { Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect()) }