diff options
Diffstat (limited to 'crates/hule-oci/src/lib.rs')
| -rw-r--r-- | crates/hule-oci/src/lib.rs | 717 | +345 −372 |
1 files changed, 345 insertions, 372 deletions
diff --git a/crates/hule-oci/src/lib.rs b/crates/hule-oci/src/lib.rs index 819e05b..e53615b 100644 --- a/crates/hule-oci/src/lib.rs +++ b/crates/hule-oci/src/lib.rs @@ -1,11 +1,8 @@ -// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-FileCopyrightText: 2026 Nikolay Govorov // SPDX-License-Identifier: Apache-2.0 //! OCI image storage and registry operations for Hule machine images. -mod error; -mod storage; - use hule_hmi::MachineImage; use oci_client::annotations::{ORG_OPENCONTAINERS_IMAGE_REF_NAME, ORG_OPENCONTAINERS_IMAGE_TITLE}; use oci_client::client::{ClientConfig, ClientProtocol, Config, ImageLayer}; @@ -15,14 +12,9 @@ use oci_client::manifest::{ use oci_client::secrets::RegistryAuth; use oci_client::{Client, Reference}; use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; use std::path::{Path, PathBuf}; -use tokio::fs::{self, File}; -use tokio::io::AsyncReadExt; -use tokio::task; - -pub use error::{Error, Result}; -pub use storage::Storage; - +pub type Result<T> = std::result::Result<T, String>; type R<T> = Result<T>; // ---- local OCI-layout store ------------------------------------------- @@ -33,6 +25,82 @@ const HULE_CONFIG_MEDIA_TYPE: &str = "application/vnd.hule.machine.config.v1+jso const ANNOTATION_CHUNK_OFFSET: &str = "io.hule.chunk.offset"; const ANNOTATION_CHUNK_LENGTH: &str = "io.hule.chunk.length"; +fn store_root() -> R<PathBuf> { + let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?; + Ok(PathBuf::from(home).join(".hule").join("store")) +} + +fn blobs_dir() -> R<PathBuf> { + Ok(store_root()?.join("blobs").join("sha256")) +} + +fn materialized_dir(manifest_digest: &str) -> R<PathBuf> { + Ok(store_root()? + .join("materialized") + .join(strip_sha256(manifest_digest))) +} + +fn index_path() -> R<PathBuf> { + Ok(store_root()?.join("index.json")) +} + +fn strip_sha256(digest: &str) -> &str { + digest.strip_prefix("sha256:").unwrap_or(digest) +} + +fn sha256_hex(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(data); + format!("sha256:{:x}", hasher.finalize()) +} + +fn ensure_store() -> R<()> { + std::fs::create_dir_all(blobs_dir()?).map_err(|e| e.to_string())?; + std::fs::create_dir_all(store_root()?.join("materialized")).map_err(|e| e.to_string())?; + let layout = store_root()?.join("oci-layout"); + if !layout.exists() { + std::fs::write(&layout, br#"{"imageLayoutVersion":"1.0.0"}"#).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Writes raw bytes as a content-addressed blob, no-op if already present. +fn write_blob(data: &[u8]) -> R<String> { + let digest = sha256_hex(data); + let path = blobs_dir()?.join(strip_sha256(&digest)); + if !path.exists() { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + } + Ok(digest) +} + +fn read_blob(digest: &str) -> R<Vec<u8>> { + std::fs::read(blobs_dir()?.join(strip_sha256(digest))).map_err(|e| e.to_string()) +} + +fn read_index() -> R<OciImageIndex> { + let path = index_path()?; + if !path.exists() { + return Ok(OciImageIndex { + schema_version: 2, + media_type: None, + manifests: vec![], + artifact_type: None, + annotations: None, + }); + } + let data = std::fs::read(&path).map_err(|e| e.to_string())?; + serde_json::from_slice(&data).map_err(|e| e.to_string()) +} + +fn write_index(index: &OciImageIndex) -> R<()> { + let data = serde_json::to_vec_pretty(index).map_err(|e| e.to_string())?; + std::fs::write(index_path()?, data).map_err(|e| e.to_string()) +} + fn index_lookup(index: &OciImageIndex, reference: &str) -> Option<String> { index .manifests @@ -70,71 +138,79 @@ fn index_set(index: &mut OciImageIndex, reference: &str, digest: &str, size: u64 }); } -/// Manages Hule images backed by a [`Storage`]. -#[derive(Clone, Debug)] -pub struct ImageManager { - storage: Storage, -} - -impl ImageManager { - pub fn new(storage: Storage) -> Self { - Self { storage } +fn hardlink_or_copy(src: &Path, dest: &Path) -> R<()> { + if std::fs::hard_link(src, dest).is_err() { + std::fs::copy(src, dest).map_err(|e| e.to_string())?; } + Ok(()) +} - pub fn storage(&self) -> &Storage { - &self.storage +fn write_at(file: &std::fs::File, offset: u64, data: &[u8]) -> R<()> { + use std::os::unix::fs::FileExt; + let mut written = 0usize; + while written < data.len() { + let n = file + .write_at(&data[written..], offset + written as u64) + .map_err(|e| e.to_string())?; + if n == 0 { + return Err("write_at wrote 0 bytes".to_string()); + } + written += n; } + Ok(()) +} - /// Splits `path` into fixed-size chunks, compresses each independently - /// and writes each as its own blob. - async fn write_file_chunks(&self, path: &Path) -> R<Vec<OciDescriptor>> { - let title = path - .file_name() - .ok_or_else(|| Error::InvalidImagePath(path.to_path_buf()))? - .to_string_lossy() - .to_string(); - let mut file = File::open(path).await?; - let mut descriptors = Vec::new(); - let mut offset: u64 = 0; - loop { - let mut buf = Vec::with_capacity(CHUNK_SIZE); - (&mut file) - .take(CHUNK_SIZE as u64) - .read_to_end(&mut buf) - .await?; - if buf.is_empty() { - break; - } - let length = buf.len() as u64; - let compressed = - task::spawn_blocking(move || zstd::stream::encode_all(&buf[..], 0)).await??; - let compressed_len = compressed.len(); - let (digest, _) = self.storage.write_blob(compressed).await?; - - let mut annotations = BTreeMap::new(); - annotations.insert(ORG_OPENCONTAINERS_IMAGE_TITLE.to_string(), title.clone()); - annotations.insert(ANNOTATION_CHUNK_OFFSET.to_string(), offset.to_string()); - annotations.insert(ANNOTATION_CHUNK_LENGTH.to_string(), length.to_string()); - - descriptors.push(OciDescriptor { - media_type: HULE_CHUNK_MEDIA_TYPE.to_string(), - digest, - size: compressed_len as i64, - urls: None, - annotations: Some(annotations), - artifact_type: None, - }); - - offset += length; - if length < CHUNK_SIZE as u64 { - break; - } +/// Splits `path` into fixed-size chunks, compresses each independently +/// (own zstd frame, no state shared between chunks -- see the design notes +/// in the project plan on why this must not be done the other way around), +/// and writes each as its own blob. Returns one descriptor per chunk, all +/// sharing a title annotation plus a chunk offset/length in terms of the +/// *uncompressed* file so pull can reassemble it. +fn write_file_chunks(path: &Path) -> R<Vec<OciDescriptor>> { + let title = path + .file_name() + .ok_or_else(|| format!("{} has no file name", path.display()))? + .to_string_lossy() + .to_string(); + let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let mut descriptors = Vec::new(); + let mut offset: u64 = 0; + loop { + let mut buf = Vec::with_capacity(CHUNK_SIZE); + (&mut file) + .take(CHUNK_SIZE as u64) + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + if buf.is_empty() { + break; } - if descriptors.is_empty() { - return Err(Error::EmptyImageFile(path.to_path_buf())); + let length = buf.len() as u64; + let compressed = zstd::stream::encode_all(&buf[..], 0).map_err(|e| e.to_string())?; + let digest = write_blob(&compressed)?; + + let mut annotations = BTreeMap::new(); + annotations.insert(ORG_OPENCONTAINERS_IMAGE_TITLE.to_string(), title.clone()); + annotations.insert(ANNOTATION_CHUNK_OFFSET.to_string(), offset.to_string()); + annotations.insert(ANNOTATION_CHUNK_LENGTH.to_string(), length.to_string()); + + descriptors.push(OciDescriptor { + media_type: HULE_CHUNK_MEDIA_TYPE.to_string(), + digest, + size: compressed.len() as i64, + urls: None, + annotations: Some(annotations), + artifact_type: None, + }); + + offset += length; + if length < CHUNK_SIZE as u64 { + break; } - Ok(descriptors) } + if descriptors.is_empty() { + return Err(format!("{} is empty", path.display())); + } + Ok(descriptors) } fn make_client(reference: &Reference) -> Client { @@ -152,335 +228,232 @@ fn make_client(reference: &Reference) -> Client { } fn parse_reference(s: &str) -> R<Reference> { - s.parse().map_err(|source| Error::InvalidReference { - reference: s.to_string(), - source, - }) + s.parse() + .map_err(|e| format!("invalid reference '{s}': {e}")) } -// ---- image operations ------------------------------------------------- - -impl ImageManager { - /// Loads a prepared HMI image directory and optionally tags it. - pub async fn load(&self, path: &Path, reference: Option<&str>) -> R<String> { - let config_path = path.join("config.json"); - match fs::metadata(&config_path).await { - Ok(metadata) if metadata.is_file() => {} - Ok(_) => return Err(Error::MissingConfig(path.to_path_buf())), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Err(Error::MissingConfig(path.to_path_buf())); - } - Err(error) => return Err(error.into()), - } - let config_bytes = fs::read(&config_path).await?; - MachineImage::from_json(&config_bytes)?; - - let mut dir = fs::read_dir(path).await?; - let mut entries = Vec::new(); - while let Some(entry) = dir.next_entry().await? { - entries.push(entry); - } - entries.sort_by_key(|entry| entry.file_name()); - - let mut layers = Vec::new(); - let mut sources: Vec<(String, PathBuf)> = Vec::new(); - for entry in entries { - if !entry.file_type().await?.is_file() { - continue; - } - let name = entry.file_name().to_string_lossy().to_string(); - if name == "config.json" { - continue; - } - let file_path = entry.path(); - layers.extend(self.write_file_chunks(&file_path).await?); - sources.push((name, file_path)); - } +// ---- import / push / pull / run --------------------------------------- - let config_size = config_bytes.len(); - let (config_digest, _) = self.storage.write_blob(config_bytes).await?; - let config = OciDescriptor { - media_type: HULE_CONFIG_MEDIA_TYPE.to_string(), - digest: config_digest, - size: config_size as i64, - urls: None, - annotations: None, - artifact_type: None, - }; - - let manifest = OciImageManifest { - schema_version: 2, - media_type: Some(OCI_IMAGE_MEDIA_TYPE.to_string()), - config, - layers, - subject: None, - artifact_type: None, - annotations: None, - }; - let manifest_bytes = serde_json::to_vec(&manifest)?; - let manifest_size = manifest_bytes.len(); - let (manifest_digest, _) = self.storage.write_blob(manifest_bytes).await?; - - self.storage.ensure_hmi_image(&manifest_digest).await?; - for (title, source) in &sources { - self.storage - .cache_hmi_file(&manifest_digest, title, source) - .await?; - } - - if let Some(reference) = reference { - let mut index = self.storage.read_index().await?; - index_set( - &mut index, - reference, - &manifest_digest, - manifest_size as u64, - ); - self.storage.write_index(&index).await?; - } - Ok(manifest_digest) +/// Imports an image directory into the local OCI layout and optionally tags it. +pub async fn import(path: &Path, reference: Option<&str>) -> R<String> { + ensure_store()?; + let config_path = path.join("config.json"); + if !config_path.is_file() { + return Err(format!("{} does not contain config.json", path.display())); } - - /// Pushes a locally tagged image to its registry reference. - pub async fn push(&self, reference_str: &str) -> R<()> { - let index = self.storage.read_index().await?; - let manifest_digest = index_lookup(&index, reference_str) - .ok_or_else(|| Error::ImageNotFound(reference_str.to_string()))?; - let manifest_bytes = self.storage.read_blob(&manifest_digest).await?; - let manifest: OciImageManifest = serde_json::from_slice(&manifest_bytes)?; - - let reference = parse_reference(reference_str)?; - let client = make_client(&reference); - let auth = RegistryAuth::Anonymous; - - let mut layers = Vec::new(); - for descriptor in &manifest.layers { - let data = self.storage.read_blob(&descriptor.digest).await?; - layers.push(ImageLayer::new( - data, - descriptor.media_type.clone(), - descriptor.annotations.clone(), - )); + let config_bytes = std::fs::read(&config_path).map_err(|e| e.to_string())?; + MachineImage::from_json(&config_bytes).map_err(|e| e.to_string())?; + + let mut entries: Vec<_> = std::fs::read_dir(path) + .map_err(|e| e.to_string())? + .collect::<std::result::Result<Vec<_>, _>>() + .map_err(|e| e.to_string())?; + entries.sort_by_key(|e| e.file_name()); + + let mut layers = Vec::new(); + let mut sources: Vec<(String, PathBuf)> = Vec::new(); + for entry in entries { + if !entry.file_type().map_err(|e| e.to_string())?.is_file() { + continue; } - let config_bytes = self.storage.read_blob(&manifest.config.digest).await?; - let config = Config::new( - config_bytes, - manifest.config.media_type.clone(), - manifest.config.annotations.clone(), - ); - - client - .push(&reference, &layers, config, &auth, Some(manifest)) - .await?; - - Ok(()) + let name = entry.file_name().to_string_lossy().to_string(); + if name == "config.json" { + continue; + } + let file_path = entry.path(); + layers.extend(write_file_chunks(&file_path)?); + sources.push((name, file_path)); } - /// Pulls an image into the OCI layout and materializes it in the HMI store. - pub async fn pull(&self, reference_str: &str) -> R<String> { - let reference = parse_reference(reference_str)?; - let client = make_client(&reference); - let auth = RegistryAuth::Anonymous; - - let mut image_data = client - .pull(&reference, &auth, vec![HULE_CHUNK_MEDIA_TYPE]) - .await?; - let manifest = image_data.manifest.ok_or(Error::MissingManifest)?; - - // `client.pull()` fetches layers via `buffer_unordered`, so `image_data.layers` is - // in completion order, not manifest order. Each layer carries its own annotations. - for layer in &mut image_data.layers { - let data = std::mem::take(&mut layer.data); - let (_, data) = self.storage.write_blob(data).await?; - layer.data = data; - } - let config_data = std::mem::take(&mut image_data.config.data); - let (_, config_data) = self.storage.write_blob(config_data).await?; - image_data.config.data = config_data; - - let manifest_bytes = serde_json::to_vec(&manifest)?; - let manifest_size = manifest_bytes.len(); - let (manifest_digest, _) = self.storage.write_blob(manifest_bytes).await?; - self.storage.ensure_hmi_image(&manifest_digest).await?; - - let mut by_title: BTreeMap<String, Vec<(u64, ImageLayer)>> = BTreeMap::new(); - for layer in image_data.layers { - let annotations = layer - .annotations - .as_ref() - .ok_or(Error::InvalidLayer("missing annotations"))?; - let title = annotations - .get(ORG_OPENCONTAINERS_IMAGE_TITLE) - .ok_or(Error::InvalidLayer("missing title annotation"))?; - let offset_value = annotations - .get(ANNOTATION_CHUNK_OFFSET) - .ok_or(Error::InvalidLayer("missing offset annotation"))?; - let offset: u64 = offset_value.parse().map_err(|_| Error::InvalidAnnotation { - name: "offset", - value: offset_value.clone(), - })?; - by_title - .entry(title.clone()) - .or_default() - .push((offset, layer)); - } + let config_digest = write_blob(&config_bytes)?; + let config = OciDescriptor { + media_type: HULE_CONFIG_MEDIA_TYPE.to_string(), + digest: config_digest, + size: config_bytes.len() as i64, + urls: None, + annotations: None, + artifact_type: None, + }; - for (title, chunks) in by_title { - let mut decompressed_chunks = Vec::with_capacity(chunks.len()); - for (offset, layer) in chunks { - let data = task::spawn_blocking(move || zstd::stream::decode_all(&layer.data[..])) - .await??; - decompressed_chunks.push((offset, data)); - } - self.storage - .write_hmi_file(&manifest_digest, &title, decompressed_chunks) - .await?; + let manifest = OciImageManifest { + schema_version: 2, + media_type: Some(OCI_IMAGE_MEDIA_TYPE.to_string()), + config, + layers, + subject: None, + artifact_type: None, + annotations: None, + }; + let manifest_bytes = serde_json::to_vec(&manifest).map_err(|e| e.to_string())?; + let manifest_digest = write_blob(&manifest_bytes)?; + + let materialized = materialized_dir(&manifest_digest)?; + std::fs::create_dir_all(&materialized).map_err(|e| e.to_string())?; + for (title, source) in &sources { + let dest = materialized.join(title); + if !dest.exists() { + hardlink_or_copy(source, &dest)?; } + } - let mut index = self.storage.read_index().await?; + if let Some(reference) = reference { + let mut index = read_index()?; index_set( &mut index, - reference_str, + reference, &manifest_digest, - manifest_size as u64, + manifest_bytes.len() as u64, ); - self.storage.write_index(&index).await?; + write_index(&index)?; + } + Ok(manifest_digest) +} - Ok(manifest_digest) +/// Pushes a locally tagged image to its registry reference. +pub async fn push(reference_str: &str) -> R<()> { + ensure_store()?; + let index = read_index()?; + let manifest_digest = index_lookup(&index, reference_str) + .ok_or_else(|| format!("no local image tagged '{reference_str}'"))?; + let manifest_bytes = read_blob(&manifest_digest)?; + let manifest: OciImageManifest = + serde_json::from_slice(&manifest_bytes).map_err(|e| e.to_string())?; + + let reference = parse_reference(reference_str)?; + let client = make_client(&reference); + let auth = RegistryAuth::Anonymous; + + let mut layers = Vec::new(); + for descriptor in &manifest.layers { + let data = read_blob(&descriptor.digest)?; + layers.push(ImageLayer::new( + data, + descriptor.media_type.clone(), + descriptor.annotations.clone(), + )); } + let config_bytes = read_blob(&manifest.config.digest)?; + let config = Config::new( + config_bytes, + manifest.config.media_type.clone(), + manifest.config.annotations.clone(), + ); + + client + .push(&reference, &layers, config, &auth, Some(manifest)) + .await + .map_err(|e| e.to_string())?; - /// Returns whether a reference is present in the local OCI layout. - pub async fn contains(&self, reference_str: &str) -> R<bool> { - Ok(index_lookup(&self.storage.read_index().await?, reference_str).is_some()) + Ok(()) +} + +/// Pulls an image into the local OCI layout and materializes its files. +pub async fn pull(reference_str: &str) -> R<String> { + ensure_store()?; + let reference = parse_reference(reference_str)?; + let client = make_client(&reference); + let auth = RegistryAuth::Anonymous; + + let image_data = client + .pull(&reference, &auth, vec![HULE_CHUNK_MEDIA_TYPE]) + .await + .map_err(|e| e.to_string())?; + let manifest = image_data + .manifest + .ok_or("registry returned no image manifest")?; + + // `client.pull()` fetches layers via `buffer_unordered`, so `image_data.layers` is in + // completion order, NOT `manifest.layers` order -- do not zip the two. Each `ImageLayer` + // already carries its own annotations, so it's self-describing without cross-referencing. + for layer in &image_data.layers { + write_blob(&layer.data)?; } + write_blob(&image_data.config.data)?; - /// Materializes a locally tagged image into `destination` and returns config.json. - pub async fn materialize(&self, reference_str: &str, destination: &Path) -> R<Vec<u8>> { - let manifest_digest = index_lookup(&self.storage.read_index().await?, reference_str) - .ok_or_else(|| Error::ImageNotFound(reference_str.to_string()))?; + let manifest_bytes = serde_json::to_vec(&manifest).map_err(|e| e.to_string())?; + let manifest_digest = write_blob(&manifest_bytes)?; - let manifest_bytes = self.storage.read_blob(&manifest_digest).await?; - let manifest: OciImageManifest = serde_json::from_slice(&manifest_bytes)?; - let config_bytes = self.storage.read_blob(&manifest.config.digest).await?; + let materialized = materialized_dir(&manifest_digest)?; + std::fs::create_dir_all(&materialized).map_err(|e| e.to_string())?; - fs::create_dir_all(destination).await?; - fs::write(destination.join("config.json"), &config_bytes).await?; + let mut by_title: BTreeMap<String, Vec<(u64, &[u8])>> = BTreeMap::new(); + for layer in &image_data.layers { + let annotations = layer + .annotations + .as_ref() + .ok_or("chunk layer missing annotations")?; + let title = annotations + .get(ORG_OPENCONTAINERS_IMAGE_TITLE) + .ok_or("chunk layer missing title annotation")?; + let offset: u64 = annotations + .get(ANNOTATION_CHUNK_OFFSET) + .ok_or("chunk layer missing offset annotation")? + .parse() + .map_err(|_| "invalid chunk offset annotation".to_string())?; + by_title + .entry(title.clone()) + .or_default() + .push((offset, &layer.data[..])); + } - let mut titles: BTreeSet<String> = BTreeSet::new(); - for descriptor in &manifest.layers { - if let Some(title) = descriptor - .annotations - .as_ref() - .and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_TITLE)) - { - titles.insert(title.clone()); - } - } - for title in titles { - self.storage - .export_hmi_file(&manifest_digest, &title, &destination.join(&title)) - .await?; + for (title, mut chunks) in by_title { + chunks.sort_by_key(|(offset, _)| *offset); + let file = std::fs::File::create(materialized.join(&title)).map_err(|e| e.to_string())?; + for (offset, compressed) in chunks { + let decompressed = zstd::stream::decode_all(compressed).map_err(|e| e.to_string())?; + write_at(&file, offset, &decompressed)?; } - - Ok(config_bytes) } + + let mut index = read_index()?; + index_set( + &mut index, + reference_str, + &manifest_digest, + manifest_bytes.len() as u64, + ); + write_index(&index)?; + + Ok(manifest_digest) } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; - - const TEST_CONFIG: &[u8] = br#"{ - "schemaVersion": 1, - "kind": "MachineImage", - "system": { - "os": "linux", - "name": "test", - "version": "1", - "architecture": "amd64" - }, - "machine": { - "cpu": { "minimum": 1, "default": 1 }, - "ram": { "minimum": 268435456, "default": 268435456 }, - "boot": [{ "protocol": "firmware-disk/bios", "disk": "root" }], - "access": [{ - "type": "ssh", - "port": 22, - "user": "root", - "auth": "empty-password" - }], - "network": { "mode": "dhcp" } - }, - "disks": [{ - "id": "root", - "format": "qcow2", - "path": "root.hmi", - "digest": "sha256:372409142c91c51316a7ab2b055596e145de56d99aa0f5eb110068876cef0be4", - "virtSize": 14, - "diskSize": 14 - }] - }"#; - - fn temporary_directory() -> PathBuf { - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - std::env::temp_dir().join(format!( - "hule-oci-test-{}-{}", - std::process::id(), - NEXT_ID.fetch_add(1, Ordering::Relaxed) - )) - } +/// Returns whether a reference is present in the local OCI layout. +pub fn contains(reference_str: &str) -> R<bool> { + ensure_store()?; + Ok(index_lookup(&read_index()?, reference_str).is_some()) +} - #[test] - fn manager_uses_separate_oci_and_hmi_roots() { - tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(async { - let root = temporary_directory(); - let source = root.join("source"); - let oci_root = root.join("oci"); - let hmi_root = root.join("hmi"); - let destination = root.join("destination"); - fs::create_dir_all(&source).await.unwrap(); - fs::write(source.join("config.json"), TEST_CONFIG) - .await - .unwrap(); - fs::write(source.join("root.hmi"), b"test hmi image") - .await - .unwrap(); - - let storage = Storage::open(&oci_root, &hmi_root).await.unwrap(); - let images = ImageManager::new(storage); - let reference = "example.test/hule:tokio"; - let digest = images.load(&source, Some(reference)).await.unwrap(); - - assert!(images.contains(reference).await.unwrap()); - assert!(fs::try_exists(oci_root.join("index.json")).await.unwrap()); - assert!( - fs::try_exists( - hmi_root - .join(digest.strip_prefix("sha256:").unwrap_or(&digest)) - .join("root.hmi") - ) - .await - .unwrap() - ); - - let config = images.materialize(reference, &destination).await.unwrap(); - assert!(!config.is_empty()); - assert_eq!( - fs::read(destination.join("root.hmi")).await.unwrap(), - b"test hmi image" - ); - - assert!(matches!(images.contains("missing").await, Ok(false))); - assert!(matches!( - images.materialize("missing", &destination).await, - Err(Error::ImageNotFound(reference)) if reference == "missing" - )); - - fs::remove_dir_all(root).await.unwrap(); - }); +/// Materializes a locally tagged image into `destination` and returns config.json. +pub fn materialize(reference_str: &str, destination: &Path) -> R<Vec<u8>> { + ensure_store()?; + let manifest_digest = index_lookup(&read_index()?, reference_str) + .ok_or_else(|| format!("no local image tagged '{reference_str}'"))?; + + let manifest_bytes = read_blob(&manifest_digest)?; + let manifest: OciImageManifest = + serde_json::from_slice(&manifest_bytes).map_err(|e| e.to_string())?; + let config_bytes = read_blob(&manifest.config.digest)?; + + std::fs::create_dir_all(destination).map_err(|e| e.to_string())?; + std::fs::write(destination.join("config.json"), &config_bytes).map_err(|e| e.to_string())?; + + let materialized = materialized_dir(&manifest_digest)?; + let mut titles: BTreeSet<String> = BTreeSet::new(); + for descriptor in &manifest.layers { + if let Some(title) = descriptor + .annotations + .as_ref() + .and_then(|a| a.get(ORG_OPENCONTAINERS_IMAGE_TITLE)) + { + titles.insert(title.clone()); + } } + for title in titles { + let dest = destination.join(&title); + if !dest.exists() { + hardlink_or_copy(&materialized.join(&title), &dest)?; + } + } + + Ok(config_bytes) } |
