aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to 'crates/hule-oci/src/lib.rs')
-rw-r--r--crates/hule-oci/src/lib.rs717+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)
}