Skip to content

Commit fc1d4e2

Browse files
cgwaltersjeckersb
authored andcommitted
oci: Add OCI-native manifest/config storage and image management
Switch how we store OCI images to also include and identify images by having explicit `oci/` tags which point to a manifest, which points to a config. We continue to generate splitstream for manifest and config, but we now always store the original context as "external" objects. A big rationale for this is it will align with a future proposal for composefs OCI sealing where we include fsverity signatures for the manifest/config as a detached object. With this for example, bootc can stop storing the manifest on its own. Other big features: - Full general support for OCI artifacts as well - Support for OCI referrers as well (to store sigstore and future composefs signatures) Finally we have an initial sketch for storing multi-arch images. Assisted-by: OpenCode (Opus 4.6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 014777a commit fc1d4e2

9 files changed

Lines changed: 3350 additions & 70 deletions

File tree

crates/cfsctl/src/main.rs

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ enum OciCommand {
101101
/// optional reference name for the manifest, use as 'ref/<name>' elsewhere
102102
name: Option<String>,
103103
},
104+
/// List all tagged OCI images in the repository
105+
#[clap(name = "images")]
106+
ListImages,
107+
/// Show information about an OCI image
108+
#[clap(name = "inspect")]
109+
Inspect {
110+
/// Image reference (tag name or manifest digest)
111+
image: String,
112+
},
113+
/// Tag an image with a new name
114+
Tag {
115+
/// Manifest digest (sha256:...)
116+
manifest_digest: String,
117+
/// Tag name to assign
118+
name: String,
119+
},
120+
/// Remove a tag from an image
121+
Untag {
122+
/// Tag name to remove
123+
name: String,
124+
},
104125
/// Compute the composefs image object id of the rootfs of a stored OCI image
105126
ComputeId {
106127
#[clap(flatten)]
@@ -359,11 +380,110 @@ where
359380
println!("{}", image_id.to_id());
360381
}
361382
OciCommand::Pull { ref image, name } => {
362-
let (digest, verity) =
363-
composefs_oci::pull(&Arc::new(repo), image, name.as_deref(), None).await?;
383+
// If no explicit name provided, use the image reference as the tag
384+
let tag_name = name.as_deref().unwrap_or(image);
385+
let result =
386+
composefs_oci::pull_image(&Arc::new(repo), image, Some(tag_name), None).await?;
364387

365-
println!("config {digest}");
366-
println!("verity {}", verity.to_hex());
388+
println!("manifest {}", result.manifest_digest);
389+
println!("config {}", result.config_digest);
390+
println!("verity {}", result.manifest_verity.to_hex());
391+
println!("tagged {tag_name}");
392+
}
393+
OciCommand::ListImages => {
394+
let images = composefs_oci::oci_image::list_images(&repo)?;
395+
396+
if images.is_empty() {
397+
println!("No images found");
398+
} else {
399+
println!(
400+
"{:<30} {:<12} {:<10} {:<8} {:<6}",
401+
"NAME", "DIGEST", "ARCH", "SEALED", "LAYERS"
402+
);
403+
for img in images {
404+
let digest_short = img
405+
.manifest_digest
406+
.strip_prefix("sha256:")
407+
.unwrap_or(&img.manifest_digest);
408+
let digest_display = if digest_short.len() > 12 {
409+
&digest_short[..12]
410+
} else {
411+
digest_short
412+
};
413+
println!(
414+
"{:<30} {:<12} {:<10} {:<8} {:<6}",
415+
img.name,
416+
digest_display,
417+
if img.architecture.is_empty() {
418+
"artifact"
419+
} else {
420+
&img.architecture
421+
},
422+
if img.sealed { "yes" } else { "no" },
423+
img.layer_count
424+
);
425+
}
426+
}
427+
}
428+
OciCommand::Inspect { ref image } => {
429+
let img = if image.starts_with("sha256:") {
430+
composefs_oci::oci_image::OciImage::open(&repo, image, None)?
431+
} else {
432+
composefs_oci::oci_image::OciImage::open_ref(&repo, image)?
433+
};
434+
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+
}
475+
}
476+
}
477+
OciCommand::Tag {
478+
ref manifest_digest,
479+
ref name,
480+
} => {
481+
composefs_oci::oci_image::tag_image(&repo, manifest_digest, name)?;
482+
println!("Tagged {manifest_digest} as {name}");
483+
}
484+
OciCommand::Untag { ref name } => {
485+
composefs_oci::oci_image::untag_image(&repo, name)?;
486+
println!("Removed tag {name}");
367487
}
368488
OciCommand::Seal {
369489
config_opts:

crates/composefs-oci/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ tokio-util = { version = "0.7", default-features = false, features = ["io"] }
3030
similar-asserts = "1.7.0"
3131
composefs = { workspace = true, features = ["test"] }
3232
once_cell = "1.21.3"
33+
proptest = "1"
3334
tempfile = "3.8.0"
3435

3536
[lints]

0 commit comments

Comments
 (0)