Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/cfsctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ rhel9 = ['composefs/rhel9']
anyhow = { version = "1.0.87", default-features = false }
fn-error-context = "0.2"
clap = { version = "4.5.0", default-features = false, features = ["std", "help", "usage", "derive", "wrap_help"] }
comfy-table = { version = "7.1", default-features = false }
composefs = { workspace = true }
composefs-boot = { workspace = true }
composefs-oci = { workspace = true, optional = true }
composefs-http = { workspace = true, optional = true }
env_logger = { version = "0.11.0", default-features = false }
hex = { version = "0.4.0", default-features = false }
rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
tokio = { version = "1.24.2", default-features = false }

[lints]
Expand Down
145 changes: 86 additions & 59 deletions crates/cfsctl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::{

use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use comfy_table::{presets::UTF8_FULL, Table};

use rustix::fs::CWD;

Expand Down Expand Up @@ -103,12 +104,25 @@ enum OciCommand {
},
/// List all tagged OCI images in the repository
#[clap(name = "images")]
ListImages,
ListImages {
/// Output as JSON array
#[clap(long)]
json: bool,
},
/// Show information about an OCI image
///
/// By default, outputs JSON with manifest, config, and referrers.
/// Use --manifest or --config to output just that raw JSON.
#[clap(name = "inspect")]
Inspect {
/// Image reference (tag name or manifest digest)
image: String,
/// Output only the raw manifest JSON (as originally stored)
#[clap(long, conflicts_with = "config")]
manifest: bool,
/// Output only the raw config JSON (as originally stored)
#[clap(long, conflicts_with = "manifest")]
config: bool,
},
/// Tag an image with a new name
Tag {
Expand All @@ -122,6 +136,21 @@ enum OciCommand {
/// Tag name to remove
name: String,
},
/// Inspect a stored layer
///
/// By default, outputs the raw tar stream to stdout.
/// Use --dumpfile for composefs dumpfile format, or --json for metadata.
#[clap(name = "layer")]
LayerInspect {
/// Layer diff_id (sha256:...)
layer: String,
/// Output as composefs dumpfile format (one entry per line)
#[clap(long, conflicts_with = "json")]
dumpfile: bool,
/// Output layer metadata as JSON
#[clap(long, conflicts_with = "dumpfile")]
json: bool,
},
/// Compute the composefs image object id of the rootfs of a stored OCI image
ComputeId {
#[clap(flatten)]
Expand Down Expand Up @@ -390,16 +419,18 @@ where
println!("verity {}", result.manifest_verity.to_hex());
println!("tagged {tag_name}");
}
OciCommand::ListImages => {
OciCommand::ListImages { json } => {
let images = composefs_oci::oci_image::list_images(&repo)?;

if images.is_empty() {
if json {
println!("{}", serde_json::to_string_pretty(&images)?);
} else if images.is_empty() {
println!("No images found");
} else {
println!(
"{:<30} {:<12} {:<10} {:<8} {:<6}",
"NAME", "DIGEST", "ARCH", "SEALED", "LAYERS"
);
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(["NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"]);

for img in images {
let digest_short = img
.manifest_digest
Expand All @@ -410,68 +441,49 @@ where
} else {
digest_short
};
println!(
"{:<30} {:<12} {:<10} {:<8} {:<6}",
img.name,
let arch = if img.architecture.is_empty() {
"artifact"
} else {
&img.architecture
};
let sealed = if img.sealed { "yes" } else { "no" };
table.add_row([
img.name.as_str(),
digest_display,
if img.architecture.is_empty() {
"artifact"
} else {
&img.architecture
},
if img.sealed { "yes" } else { "no" },
img.layer_count
);
arch,
sealed,
&img.layer_count.to_string(),
&img.referrer_count.to_string(),
]);
}
println!("{table}");
}
}
OciCommand::Inspect { ref image } => {
OciCommand::Inspect {
ref image,
manifest,
config,
} => {
let img = if image.starts_with("sha256:") {
composefs_oci::oci_image::OciImage::open(&repo, image, None)?
} else {
composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
};

println!("Manifest: {}", img.manifest_digest());
println!("Config: {}", img.config_digest());
println!(
"Type: {}",
if img.is_container_image() {
"container"
} else {
"artifact"
}
);

if img.is_container_image() {
println!("Architecture: {}", img.architecture());
println!("OS: {}", img.os());
}

if let Some(created) = img.created() {
println!("Created: {created}");
}

println!(
"Sealed: {}",
if img.is_sealed() { "yes" } else { "no" }
);
if let Some(seal) = img.seal_digest() {
println!("Seal digest: {seal}");
}

println!("Layers: {}", img.layer_descriptors().len());
for (i, layer) in img.layer_descriptors().iter().enumerate() {
println!(" [{i}] {} ({} bytes)", layer.digest(), layer.size());
}

if let Some(labels) = img.labels() {
if !labels.is_empty() {
println!("Labels:");
for (k, v) in labels {
println!(" {k}: {v}");
}
}
if manifest {
// Output raw manifest JSON exactly as stored
let manifest_json = img.read_manifest_json(&repo)?;
std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
println!();
} else if config {
// Output raw config JSON exactly as stored
let config_json = img.read_config_json(&repo)?;
std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
println!();
} else {
// Default: output combined JSON with manifest, config, and referrers
let output = img.inspect_json(&repo)?;
println!("{}", serde_json::to_string_pretty(&output)?);
}
}
OciCommand::Tag {
Expand All @@ -485,6 +497,21 @@ where
composefs_oci::oci_image::untag_image(&repo, name)?;
println!("Removed tag {name}");
}
OciCommand::LayerInspect {
ref layer,
dumpfile,
json,
} => {
if json {
let info = composefs_oci::layer_info(&repo, layer)?;
println!("{}", serde_json::to_string_pretty(&info)?);
} else if dumpfile {
composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
} else {
// Default: output raw tar
composefs_oci::layer_tar(&repo, layer, &mut std::io::stdout())?;
}
}
OciCommand::Seal {
config_opts:
OCIConfigOptions {
Expand Down
7 changes: 7 additions & 0 deletions crates/composefs-oci/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ hex = { version = "0.4.0", default-features = false }
indicatif = { version = "0.17.0", default-features = false, features = ["tokio"] }
oci-spec = { version = "0.8.0", default-features = false }
rustix = { version = "1.0.0", features = ["fs"] }
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
sha2 = { version = "0.10.1", default-features = false }
tar = { version = "0.4.38", default-features = false }
tokio = { version = "1.24.2", features = ["rt-multi-thread"] }
tokio-util = { version = "0.7", default-features = false, features = ["io"] }
tracing = "0.1"
cap-std-ext = "4.0"
flate2 = { version = "1.0", default-features = false, features = ["rust_backend"] }
ocidir = "0.6"
zstd = { version = "0.13.0", default-features = false }

[dev-dependencies]
similar-asserts = "1.7.0"
Expand Down
2 changes: 1 addition & 1 deletion crates/composefs-oci/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ mod test {
fsverity::Sha256HashValue,
tree::{LeafContent, RegularFile, Stat},
};
use std::{cell::RefCell, collections::BTreeMap, io::BufRead, io::Read, path::PathBuf};
use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf};

use super::*;

Expand Down
94 changes: 94 additions & 0 deletions crates/composefs-oci/src/layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//! Shared layer import logic for OCI container images.
//!
//! This module provides common functionality for importing OCI image layers
//! into a composefs repository, shared between the skopeo proxy path and
//! direct OCI layout import.

use std::sync::Arc;

use anyhow::{bail, Result};
use async_compression::tokio::bufread::{GzipDecoder, ZstdDecoder};
use oci_spec::image::MediaType;
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWriteExt};

use composefs::fsverity::FsVerityHashValue;
use composefs::repository::Repository;

use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
use crate::tar::split_async;

/// Check if a media type represents a tar-based layer.
pub fn is_tar_media_type(media_type: &MediaType) -> bool {
matches!(
media_type,
MediaType::ImageLayer
| MediaType::ImageLayerGzip
| MediaType::ImageLayerZstd
| MediaType::ImageLayerNonDistributable
| MediaType::ImageLayerNonDistributableGzip
| MediaType::ImageLayerNonDistributableZstd
)
}

/// Wrap an async reader with the appropriate decompressor for the media type.
///
/// Returns a boxed reader that decompresses the stream if needed.
pub fn decompress_async<'a, R>(
reader: R,
media_type: &MediaType,
) -> Result<Box<dyn AsyncBufRead + Unpin + Send + 'a>>
where
R: AsyncRead + Unpin + Send + 'a,
{
let buf = tokio::io::BufReader::new(reader);
let reader: Box<dyn AsyncBufRead + Unpin + Send> = match media_type {
MediaType::ImageLayer | MediaType::ImageLayerNonDistributable => Box::new(buf),
MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => {
Box::new(tokio::io::BufReader::new(GzipDecoder::new(buf)))
}
MediaType::ImageLayerZstd | MediaType::ImageLayerNonDistributableZstd => {
Box::new(tokio::io::BufReader::new(ZstdDecoder::new(buf)))
}
_ => bail!("Unsupported layer media type for decompression: {media_type}"),
};
Ok(reader)
}

/// Import a tar layer from an async reader into the repository.
///
/// The reader should already be decompressed (use `decompress_async` first).
/// Returns the fs-verity object ID of the imported splitstream.
pub async fn import_tar_async<ObjectID, R>(
repo: Arc<Repository<ObjectID>>,
reader: R,
) -> Result<ObjectID>
where
ObjectID: FsVerityHashValue,
R: AsyncBufRead + Unpin + Send,
{
split_async(reader, repo, TAR_LAYER_CONTENT_TYPE).await
}

/// Store raw bytes from an async reader as a repository object.
///
/// Streams the raw bytes into a repository object without creating a splitstream.
/// Use this for non-tar blobs (OCI artifacts) where the caller will create
/// the splitstream wrapper.
///
/// Returns (object_id, size) of the stored object.
pub async fn store_blob_async<ObjectID, R>(
repo: &Repository<ObjectID>,
mut reader: R,
) -> Result<(ObjectID, u64)>
where
ObjectID: FsVerityHashValue,
R: AsyncRead + Unpin,
{
let tmpfile = repo.create_object_tmpfile()?;
let mut writer = tokio::fs::File::from(std::fs::File::from(tmpfile));
let size = tokio::io::copy(&mut reader, &mut writer).await?;
writer.flush().await?;
let tmpfile = writer.into_std().await;
let object_id = repo.finalize_object_tmpfile(tmpfile, size)?;
Ok((object_id, size))
}
8 changes: 5 additions & 3 deletions crates/composefs-oci/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
#![forbid(unsafe_code)]

pub mod image;
pub mod layer;
pub mod oci_image;
pub mod oci_layout;
pub mod skopeo;
pub mod tar;

Expand All @@ -31,9 +33,9 @@ use crate::tar::get_entry;

// Re-export key types for convenience
pub use oci_image::{
add_referrer, list_images, list_referrers, list_refs, remove_referrer,
remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo, OciImage,
OCI_REF_PREFIX,
add_referrer, layer_dumpfile, layer_info, layer_tar, list_images, list_referrers, list_refs,
remove_referrer, remove_referrers_for_subject, resolve_ref, tag_image, untag_image, ImageInfo,
LayerInfo, OciImage, SplitstreamInfo, OCI_REF_PREFIX,
};
pub use skopeo::{pull_image, PullResult};

Expand Down
Loading
Loading