Skip to content

Commit 6f4176a

Browse files
committed
oci: Revamp cfsctl oci
- `cfsctl oci inspect` now outputs JSON with full metadata, and supports `--manifest/--config`. - `cfsctl oci images` now includes referrer count and also supports `--json` - `cfsctl oci layer` is new and can output tar or dumpfile or json metadata Assisted-by: OpenCode (Claude claude-opus-4-5-20250514) Signed-off-by: Colin Walters <walters@verbum.org> Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com> Signed-off-by: Colin Walters <walters@verbum.org>
1 parent b157191 commit 6f4176a

8 files changed

Lines changed: 590 additions & 56 deletions

File tree

crates/cfsctl/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ composefs-http = { workspace = true, optional = true }
2828
env_logger = { version = "0.11.0", default-features = false }
2929
hex = { version = "0.4.0", default-features = false }
3030
rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] }
31+
serde_json = { version = "1.0", default-features = false, features = ["std"] }
3132
tokio = { version = "1.24.2", default-features = false }
3233

3334
[lints]

crates/cfsctl/src/main.rs

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
use std::{
88
fs::create_dir_all,
9+
io::IsTerminal,
910
path::{Path, PathBuf},
1011
sync::Arc,
1112
};
@@ -103,12 +104,25 @@ enum OciCommand {
103104
},
104105
/// List all tagged OCI images in the repository
105106
#[clap(name = "images")]
106-
ListImages,
107+
ListImages {
108+
/// Output as JSON array
109+
#[clap(long)]
110+
json: bool,
111+
},
107112
/// Show information about an OCI image
113+
///
114+
/// By default, outputs JSON with manifest, config, and referrers.
115+
/// Use --manifest or --config to output just that raw JSON.
108116
#[clap(name = "inspect")]
109117
Inspect {
110118
/// Image reference (tag name or manifest digest)
111119
image: String,
120+
/// Output only the raw manifest JSON (as originally stored)
121+
#[clap(long, conflicts_with = "config")]
122+
manifest: bool,
123+
/// Output only the raw config JSON (as originally stored)
124+
#[clap(long, conflicts_with = "manifest")]
125+
config: bool,
112126
},
113127
/// Tag an image with a new name
114128
Tag {
@@ -122,6 +136,21 @@ enum OciCommand {
122136
/// Tag name to remove
123137
name: String,
124138
},
139+
/// Inspect a stored layer
140+
///
141+
/// By default, outputs the raw tar stream to stdout.
142+
/// Use --dumpfile for composefs dumpfile format, or --json for metadata.
143+
#[clap(name = "layer")]
144+
LayerInspect {
145+
/// Layer diff_id (sha256:...)
146+
layer: String,
147+
/// Output as composefs dumpfile format (one entry per line)
148+
#[clap(long, conflicts_with = "json")]
149+
dumpfile: bool,
150+
/// Output layer metadata as JSON
151+
#[clap(long, conflicts_with = "dumpfile")]
152+
json: bool,
153+
},
125154
/// Compute the composefs image object id of the rootfs of a stored OCI image
126155
ComputeId {
127156
#[clap(flatten)]
@@ -390,15 +419,17 @@ where
390419
println!("verity {}", result.manifest_verity.to_hex());
391420
println!("tagged {tag_name}");
392421
}
393-
OciCommand::ListImages => {
422+
OciCommand::ListImages { json } => {
394423
let images = composefs_oci::oci_image::list_images(&repo)?;
395424

396-
if images.is_empty() {
425+
if json {
426+
println!("{}", serde_json::to_string_pretty(&images)?);
427+
} else if images.is_empty() {
397428
println!("No images found");
398429
} else {
399430
println!(
400-
"{:<30} {:<12} {:<10} {:<8} {:<6}",
401-
"NAME", "DIGEST", "ARCH", "SEALED", "LAYERS"
431+
"{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}",
432+
"NAME", "DIGEST", "ARCH", "SEALED", "LAYERS", "REFS"
402433
);
403434
for img in images {
404435
let digest_short = img
@@ -411,7 +442,7 @@ where
411442
digest_short
412443
};
413444
println!(
414-
"{:<30} {:<12} {:<10} {:<8} {:<6}",
445+
"{:<30} {:<12} {:<10} {:<8} {:<6} {:<5}",
415446
img.name,
416447
digest_display,
417448
if img.architecture.is_empty() {
@@ -420,58 +451,37 @@ where
420451
&img.architecture
421452
},
422453
if img.sealed { "yes" } else { "no" },
423-
img.layer_count
454+
img.layer_count,
455+
img.referrer_count
424456
);
425457
}
426458
}
427459
}
428-
OciCommand::Inspect { ref image } => {
460+
OciCommand::Inspect {
461+
ref image,
462+
manifest,
463+
config,
464+
} => {
429465
let img = if image.starts_with("sha256:") {
430466
composefs_oci::oci_image::OciImage::open(&repo, image, None)?
431467
} else {
432468
composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
433469
};
434470

435-
println!("Manifest: {}", img.manifest_digest());
436-
println!("Config: {}", img.config_digest());
437-
println!(
438-
"Type: {}",
439-
if img.is_container_image() {
440-
"container"
441-
} else {
442-
"artifact"
443-
}
444-
);
445-
446-
if img.is_container_image() {
447-
println!("Architecture: {}", img.architecture());
448-
println!("OS: {}", img.os());
449-
}
450-
451-
if let Some(created) = img.created() {
452-
println!("Created: {created}");
453-
}
454-
455-
println!(
456-
"Sealed: {}",
457-
if img.is_sealed() { "yes" } else { "no" }
458-
);
459-
if let Some(seal) = img.seal_digest() {
460-
println!("Seal digest: {seal}");
461-
}
462-
463-
println!("Layers: {}", img.layer_descriptors().len());
464-
for (i, layer) in img.layer_descriptors().iter().enumerate() {
465-
println!(" [{i}] {} ({} bytes)", layer.digest(), layer.size());
466-
}
467-
468-
if let Some(labels) = img.labels() {
469-
if !labels.is_empty() {
470-
println!("Labels:");
471-
for (k, v) in labels {
472-
println!(" {k}: {v}");
473-
}
474-
}
471+
if manifest {
472+
// Output raw manifest JSON exactly as stored
473+
let manifest_json = img.read_manifest_json(&repo)?;
474+
std::io::Write::write_all(&mut std::io::stdout(), &manifest_json)?;
475+
println!();
476+
} else if config {
477+
// Output raw config JSON exactly as stored
478+
let config_json = img.read_config_json(&repo)?;
479+
std::io::Write::write_all(&mut std::io::stdout(), &config_json)?;
480+
println!();
481+
} else {
482+
// Default: output combined JSON with manifest, config, and referrers
483+
let output = img.inspect_json(&repo)?;
484+
println!("{}", serde_json::to_string_pretty(&output)?);
475485
}
476486
}
477487
OciCommand::Tag {
@@ -485,6 +495,28 @@ where
485495
composefs_oci::oci_image::untag_image(&repo, name)?;
486496
println!("Removed tag {name}");
487497
}
498+
OciCommand::LayerInspect {
499+
ref layer,
500+
dumpfile,
501+
json,
502+
} => {
503+
if json {
504+
let info = composefs_oci::layer_info(&repo, layer)?;
505+
println!("{}", serde_json::to_string_pretty(&info)?);
506+
} else if dumpfile {
507+
composefs_oci::layer_dumpfile(&repo, layer, &mut std::io::stdout())?;
508+
} else {
509+
// Default: output raw tar, but not to a tty
510+
let mut out = std::io::stdout().lock();
511+
if out.is_terminal() {
512+
anyhow::bail!(
513+
"Refusing to write tar data to terminal. \
514+
Redirect to a file, pipe to tar, or use --json for metadata."
515+
);
516+
}
517+
composefs_oci::layer_tar(&repo, layer, &mut out)?;
518+
}
519+
}
488520
OciCommand::Seal {
489521
config_opts:
490522
OCIConfigOptions {

crates/composefs-oci/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ hex = { version = "0.4.0", default-features = false }
2121
indicatif = { version = "0.17.0", default-features = false, features = ["tokio"] }
2222
oci-spec = { version = "0.8.0", default-features = false }
2323
rustix = { version = "1.0.0", features = ["fs"] }
24+
serde = { version = "1.0", default-features = false, features = ["derive"] }
25+
serde_json = { version = "1.0", default-features = false, features = ["std"] }
2426
sha2 = { version = "0.10.1", default-features = false }
2527
tar = { version = "0.4.38", default-features = false }
2628
tokio = { version = "1.24.2", features = ["rt-multi-thread"] }

crates/composefs-oci/src/image.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ mod test {
144144
fsverity::Sha256HashValue,
145145
tree::{LeafContent, RegularFile, Stat},
146146
};
147-
use std::{cell::RefCell, collections::BTreeMap, io::BufRead, io::Read, path::PathBuf};
147+
use std::{cell::RefCell, collections::BTreeMap, io::BufRead, path::PathBuf};
148148

149149
use super::*;
150150

crates/composefs-oci/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ use crate::tar::get_entry;
3131

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

0 commit comments

Comments
 (0)