use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use colored::{ColoredString, Colorize};
use oci_spec::image::{Digest, ImageManifest};
use ommx::artifact::{
fetch_remote_manifest, get_local_registry_root,
local_registry::{
AnonymousRefOptions, ArchiveInspectView, ArtifactListOptions, ArtifactRefRecord, GcBlob,
GcDeleteReport, GcOptions, GcReport, LocalRegistry, OciDirRef, RefUpdate,
RegistryListReport,
},
ImageRef, LocalArtifact,
};
use std::{
path::{Path, PathBuf},
time::Duration,
};
mod built_info {
include!(concat!(env!("OUT_DIR"), "/built.rs"));
}
#[derive(Parser)]
#[command(version, about, long_about = None)]
enum Command {
Version,
Inspect {
image_name_or_path: String,
},
Push {
image_name_or_path: String,
},
Pull {
image_name: String,
},
List,
Size {
#[clap(required = true)]
image_names: Vec<String>,
},
Import {
path: PathBuf,
},
Export {
image_name: String,
output: PathBuf,
},
Rm {
image_name: String,
#[clap(long)]
root: Option<PathBuf>,
},
RestoreRef {
image_name: String,
manifest_digest: Digest,
#[clap(long)]
root: Option<PathBuf>,
},
ImportLegacy {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
replace: bool,
},
PruneAnonymous {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
dry_run: bool,
#[clap(long)]
delete: bool,
#[clap(long)]
experiments: bool,
#[clap(long, value_parser = GcOptions::parse_grace_period)]
older_than: Option<Duration>,
#[clap(long)]
show_digests: bool,
},
Gc {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
dry_run: bool,
#[clap(long)]
delete: bool,
#[clap(long, default_value = "24h", value_parser = GcOptions::parse_grace_period)]
grace_period: Duration,
#[clap(long)]
show_digests: bool,
},
#[command(hide = true)]
Load {
path: PathBuf,
},
#[command(hide = true)]
Save {
image_name: String,
output: PathBuf,
},
#[command(hide = true)]
Artifact {
#[command(subcommand)]
command: ArtifactCommand,
},
}
#[derive(Subcommand)]
enum ArtifactCommand {
Import {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
replace: bool,
},
PruneAnonymous {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
dry_run: bool,
#[clap(long)]
delete: bool,
#[clap(long)]
experiments: bool,
#[clap(long, value_parser = GcOptions::parse_grace_period)]
older_than: Option<Duration>,
#[clap(long)]
show_digests: bool,
},
Gc {
#[clap(long)]
root: Option<PathBuf>,
#[clap(long)]
dry_run: bool,
#[clap(long)]
delete: bool,
#[clap(long, default_value = "24h", value_parser = GcOptions::parse_grace_period)]
grace_period: Duration,
#[clap(long)]
show_digests: bool,
},
}
enum ImageRefOrPath {
Local(ImageRef),
Remote(ImageRef),
OciArchive(PathBuf),
OciDir(PathBuf),
}
impl ImageRefOrPath {
fn parse(input: &str) -> Result<Self> {
let path: &Path = input.as_ref();
if path.is_dir() {
return Ok(Self::OciDir(path.to_path_buf()));
}
if path.is_file() {
return Ok(Self::OciArchive(path.to_path_buf()));
}
if let Ok(name) = ImageRef::parse(input) {
match LocalArtifact::try_open(name.clone()) {
Ok(Some(_)) => return Ok(Self::Local(name)),
Ok(None) => {}
Err(e) => {
tracing::debug!(
"SQLite Local Registry probe for {name} failed ({e:#}); \
treating ref as not-local-in-SQLite"
);
}
}
return Ok(Self::Remote(name));
}
bail!("Invalid input: {}", input)
}
fn get_manifest(&self) -> Result<ImageManifest> {
let manifest = match self {
ImageRefOrPath::OciDir(path) => {
let dir_ref = OciDirRef::read(path)?;
let manifest_blob_path = path
.join("blobs")
.join(dir_ref.manifest_digest.algorithm().as_ref())
.join(dir_ref.manifest_digest.digest());
let bytes = std::fs::read(&manifest_blob_path).with_context(|| {
format!(
"Failed to read manifest blob at {}",
manifest_blob_path.display()
)
})?;
serde_json::from_slice::<ImageManifest>(&bytes).with_context(|| {
format!(
"Failed to parse OCI image manifest at {}",
manifest_blob_path.display()
)
})?
}
ImageRefOrPath::OciArchive(path) => ArchiveInspectView::read(path)?.manifest,
ImageRefOrPath::Local(name) => LocalArtifact::open(name.clone())?
.get_manifest()?
.clone()
.into_inner(),
ImageRefOrPath::Remote(name) => {
migration_hint_if_legacy_only(name)?;
fetch_remote_manifest(name)?
}
};
Ok(manifest)
}
}
fn migration_hint_if_legacy_only(name: &ImageRef) -> Result<()> {
if LocalRegistry::legacy_ref_path_in(get_local_registry_root(), name).exists() {
bail!(
"{name} exists only in the legacy local registry directory. \
Run `ommx import-legacy` once to migrate it into the v3 \
SQLite-backed registry, then retry."
);
}
Ok(())
}
fn bail_not_found_locally(name: &ImageRef) -> Result<()> {
migration_hint_if_legacy_only(name)?;
bail!("Image not found in local: {}", name)
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(tracing::level_filters::LevelFilter::INFO.into())
.from_env_lossy(),
)
.init();
let command = Command::parse();
match &command {
Command::Version => {
print_status("Version".blue().bold(), built_info::PKG_VERSION);
print_status("Target".blue().bold(), built_info::TARGET);
if let Some(hash) = built_info::GIT_COMMIT_HASH {
print_status("Git Commit".blue().bold(), hash);
}
}
Command::Inspect { image_name_or_path } => {
let manifest = ImageRefOrPath::parse(image_name_or_path)?.get_manifest()?;
println!("{}", serde_json::to_string_pretty(&manifest)?);
}
Command::Push { image_name_or_path } => handle_push(image_name_or_path)?,
Command::Pull { image_name } => handle_pull(image_name)?,
Command::Import { path } => handle_import(path)?,
Command::Export { image_name, output } => handle_export(image_name, output)?,
Command::Rm { image_name, root } => handle_rm(image_name, root.as_ref())?,
Command::RestoreRef {
image_name,
manifest_digest,
root,
} => handle_restore_ref(image_name, manifest_digest, root.as_ref())?,
Command::List => {
for image_name in ommx::artifact::get_images()? {
println!("{image_name}");
}
}
Command::Size { image_names } => handle_size(image_names)?,
Command::ImportLegacy { root, replace } => handle_import_legacy(root.as_ref(), *replace)?,
Command::PruneAnonymous {
root,
dry_run,
delete,
experiments,
older_than,
show_digests,
} => handle_prune_anonymous(
root.as_ref(),
*dry_run,
*delete,
*experiments,
*older_than,
*show_digests,
)?,
Command::Gc {
root,
dry_run,
delete,
grace_period,
show_digests,
} => handle_gc(
root.as_ref(),
*dry_run,
*delete,
*grace_period,
*show_digests,
)?,
Command::Load { path } => {
eprintln!("warning: `ommx load` is deprecated; use `ommx import` instead");
handle_import(path)?;
}
Command::Save { image_name, output } => {
eprintln!("warning: `ommx save` is deprecated; use `ommx export` instead");
handle_export(image_name, output)?;
}
Command::Artifact { command } => match command {
ArtifactCommand::Import { root, replace } => {
eprintln!(
"warning: `ommx artifact import` is deprecated; \
use `ommx import-legacy` instead"
);
handle_import_legacy(root.as_ref(), *replace)?;
}
ArtifactCommand::PruneAnonymous {
root,
dry_run,
delete,
experiments,
older_than,
show_digests,
} => {
eprintln!(
"warning: `ommx artifact prune-anonymous` is deprecated; \
use `ommx prune-anonymous` instead"
);
handle_prune_anonymous(
root.as_ref(),
*dry_run,
*delete,
*experiments,
*older_than,
*show_digests,
)?;
}
ArtifactCommand::Gc {
root,
dry_run,
delete,
grace_period,
show_digests,
} => {
eprintln!("warning: `ommx artifact gc` is deprecated; use `ommx gc` instead");
handle_gc(
root.as_ref(),
*dry_run,
*delete,
*grace_period,
*show_digests,
)?;
}
},
}
Ok(())
}
fn handle_push(image_name_or_path: &str) -> Result<()> {
match ImageRefOrPath::parse(image_name_or_path)? {
ImageRefOrPath::OciDir(path) => bail!(
"Cannot push OCI Image Layout directory `{}` directly. Run \
`ommx import <dir>` to import it into the SQLite Local Registry, \
then `ommx push <image_name>`.",
path.display(),
),
ImageRefOrPath::OciArchive(path) => bail!(
"Cannot push OCI archive `{}` directly. Run `ommx import <file>` \
to import it into the SQLite Local Registry, then \
`ommx push <image_name>`. (Archive is an exchange format; v3 \
pushes always source from the registry.)",
path.display(),
),
ImageRefOrPath::Local(name) => {
LocalArtifact::open(name)?.push()?;
}
ImageRefOrPath::Remote(name) => bail_not_found_locally(&name)?,
}
Ok(())
}
fn handle_pull(image_name: &str) -> Result<()> {
let name = ImageRef::parse(image_name)?;
let registry = std::sync::Arc::new(LocalRegistry::open_default()?);
registry.pull_image(&name)?;
Ok(())
}
fn handle_import(path: &Path) -> Result<()> {
let metadata =
std::fs::metadata(path).with_context(|| format!("Failed to stat {}", path.display()))?;
let registry = std::sync::Arc::new(LocalRegistry::open_default()?);
if metadata.is_dir() {
registry.import_oci_dir(path)?;
} else if metadata.is_file() {
registry.import_oci_archive(path)?;
} else {
bail!(
"Path is neither a directory nor a regular file: {}",
path.display()
);
}
Ok(())
}
fn handle_export(image_name: &str, output: &Path) -> Result<()> {
let name = ImageRef::parse(image_name)?;
LocalArtifact::open(name)?.save(output)?;
Ok(())
}
fn handle_size(image_names: &[String]) -> Result<()> {
let registry = LocalRegistry::open_default()?;
let sizes = image_names
.iter()
.map(|image_name| {
let image_name = ImageRef::parse(image_name)?;
let prefix = image_name.to_string();
let report = registry.list_artifacts_with_options(
Some(&prefix),
&ArtifactListOptions {
include_internal: true,
strict: false,
},
)?;
for warning in &report.warnings {
tracing::warn!("{warning}");
}
let record = exact_artifact_record(report, &image_name)?;
Ok((image_name, record.referenced_blob_size()?))
})
.collect::<Result<Vec<_>>>()?;
for (image_name, size) in sizes {
print_status("Image".blue().bold(), image_name);
print_status(
"Size".green().bold(),
format_args!("{} ({size} bytes)", format_bytes(size)),
);
}
Ok(())
}
fn exact_artifact_record(
report: RegistryListReport<ArtifactRefRecord>,
image_name: &ImageRef,
) -> Result<ArtifactRefRecord> {
if let Some(record) = report
.records
.into_iter()
.find(|record| record.image_name() == image_name)
{
return Ok(record);
}
if let Some(warning) = report
.warnings
.iter()
.find(|warning| warning.image_name == image_name.to_string())
{
bail!("{warning}");
}
bail!("Artifact not found in the Local Registry: {image_name}")
}
fn handle_rm(image_name: &str, root: Option<&PathBuf>) -> Result<()> {
let image_name = ImageRef::parse(image_name)?;
let registry = open_registry(root)?;
let Some(removed) = registry.remove_image_ref(&image_name)? else {
print_status("Not Found".yellow().bold(), image_name);
return Ok(());
};
print_status("Removed".red().bold(), &image_name);
print_rollback(&image_name.to_string(), &removed.manifest_digest, root);
print_status("Storage".blue().bold(), rm_storage_message());
Ok(())
}
fn handle_restore_ref(
image_name: &str,
manifest_digest: &Digest,
root: Option<&PathBuf>,
) -> Result<()> {
let image_name = ImageRef::parse(image_name)?;
let registry = open_registry(root)?;
match registry.restore_image_ref(&image_name, manifest_digest)? {
RefUpdate::Inserted => print_status("Restored".green().bold(), image_name),
RefUpdate::Unchanged => print_status("Unchanged".blue().bold(), image_name),
RefUpdate::Conflicted {
existing_manifest_digest,
incoming_manifest_digest,
} => bail!(
"Cannot restore {image_name} to {incoming_manifest_digest}: ref currently points to \
{existing_manifest_digest}"
),
RefUpdate::Replaced { .. } => {
unreachable!("restore_image_ref never replaces an existing ref")
}
}
Ok(())
}
fn open_registry(root: Option<&PathBuf>) -> Result<LocalRegistry> {
if let Some(root) = root {
LocalRegistry::open(root)
} else {
LocalRegistry::open_default()
}
}
fn handle_import_legacy(root: Option<&PathBuf>, replace: bool) -> Result<()> {
let registry = open_registry(root)?;
let report = if replace {
registry.replace_legacy_layout()?
} else {
registry.import_legacy_layout()?
};
print_status(
"Imported".green().bold(),
format_args!(
"{} legacy OCI dir(s) into {}",
report.imported_dirs,
registry.root().display()
),
);
print_status(
"Scanned".blue().bold(),
format_args!("{} legacy OCI dir(s)", report.scanned_dirs),
);
print_status(
"Verified".blue().bold(),
format_args!("{} existing ref(s)", report.verified_dirs),
);
print_status(
"Replaced".yellow().bold(),
format_args!("{} existing ref(s)", report.replaced_refs),
);
if report.conflicted_dirs > 0 {
print_status(
"Skipped".yellow().bold(),
format_args!(
"{} conflicting ref(s); rerun with --replace to overwrite them",
report.conflicted_dirs
),
);
}
Ok(())
}
fn handle_prune_anonymous(
root: Option<&PathBuf>,
dry_run: bool,
delete: bool,
experiments: bool,
older_than: Option<Duration>,
show_digests: bool,
) -> Result<()> {
if dry_run && delete {
bail!("--dry-run and --delete cannot be used together");
}
let registry = open_registry(root)?;
let options = AnonymousRefOptions {
include_experiments: experiments,
older_than,
};
let to_remove = registry.list_anonymous_refs(&options)?;
if to_remove.is_empty() {
print_status("Clean".green().bold(), "no matching anonymous refs found");
} else if delete {
let removed = registry.prune_anonymous_refs(&options)?;
print_status(
"Removed".red().bold(),
format_args!("{} anonymous ref(s)", removed.len()),
);
for r in &removed {
print_anonymous_ref(&r.name, &r.reference, &r.manifest_digest, show_digests);
print_rollback(
&format!("{}:{}", r.name, r.reference),
&r.manifest_digest,
root,
);
}
} else {
print_status(
"Candidates".yellow().bold(),
format_args!("{} anonymous ref(s)", to_remove.len()),
);
for r in &to_remove {
print_anonymous_ref(&r.name, &r.reference, &r.manifest_digest, show_digests);
}
print_status(
"Dry Run".yellow().bold(),
"registry unchanged; pass --delete to apply",
);
}
Ok(())
}
fn handle_gc(
root: Option<&PathBuf>,
dry_run: bool,
delete: bool,
grace_period: Duration,
show_digests: bool,
) -> Result<()> {
if dry_run && delete {
bail!("--dry-run and --delete cannot be used together");
}
let registry = open_registry(root)?;
let options = GcOptions {
grace_period,
..GcOptions::default()
};
if delete {
let result = registry.gc(&options)?;
print_gc_delete_report(®istry, &result, show_digests);
} else {
let report = registry.gc_report(&options)?;
print_gc_report(®istry, &report, show_digests);
print_status(
"Dry Run".yellow().bold(),
"registry unchanged; pass --delete to apply",
);
}
Ok(())
}
fn print_gc_delete_report(registry: &LocalRegistry, result: &GcDeleteReport, show_digests: bool) {
print_gc_report(registry, &result.report, show_digests);
print_status(
"Deleted".red().bold(),
format_args!(
"{} orphan blob(s), {}",
result.deleted_blobs.len(),
format_bytes(result.deleted_size())
),
);
if show_digests {
print_blob_list(&result.deleted_blobs);
}
if !result.skipped_blobs.is_empty() {
print_status(
"Skipped".yellow().bold(),
format_args!(
"{} blob(s) changed before deletion",
result.skipped_blobs.len()
),
);
if show_digests {
print_blob_list(&result.skipped_blobs);
}
}
}
fn print_gc_report(registry: &LocalRegistry, report: &GcReport, show_digests: bool) {
print_status("Registry".blue().bold(), registry.root().display());
print_status(
"Roots".blue().bold(),
format_args!("{} ref/protected digest(s)", report.roots.len()),
);
print_status(
"Reachable".green().bold(),
format_args!(
"{} blob(s), {}",
report.reachable_blobs.len(),
format_bytes(report.reachable_size())
),
);
print_status(
"Orphans".yellow().bold(),
format_args!(
"{} candidate blob(s), {}",
report.orphan_candidates.len(),
format_bytes(report.orphan_candidate_size())
),
);
if show_digests {
print_blob_list(&report.orphan_candidates);
}
print_status(
"Deferred".yellow().bold(),
format_args!(
"{} blob(s), {}",
report.deferred_blobs.len(),
format_bytes(report.deferred_size())
),
);
if show_digests {
print_blob_list(&report.deferred_blobs);
}
if !report.missing_blobs.is_empty() {
print_status(
"Missing".red().bold(),
format_args!("{} referenced blob(s)", report.missing_blobs.len()),
);
if show_digests {
for missing in &report.missing_blobs {
println!(
" {} {:?}",
missing.digest.to_string().dimmed(),
missing.kind
);
}
}
}
if !report.invalid_manifests.is_empty() {
print_status(
"Invalid".red().bold(),
format_args!("{} manifest blob(s)", report.invalid_manifests.len()),
);
if show_digests {
for invalid in &report.invalid_manifests {
println!(
" {} {:?}: {}",
invalid.digest.to_string().dimmed(),
invalid.kind,
invalid.error
);
}
}
}
}
fn print_status(label: ColoredString, message: impl std::fmt::Display) {
println!("{label:>12} {message}");
}
fn print_anonymous_ref(
name: &str,
reference: &str,
digest: impl std::fmt::Display,
show_digests: bool,
) {
if show_digests {
println!(
" {}:{} {} {}",
name.dimmed(),
reference,
"->".dimmed(),
digest
);
} else {
println!(" {}:{}", name.dimmed(), reference);
}
}
fn print_rollback(image_name: &str, manifest_digest: &Digest, root: Option<&PathBuf>) {
print_status(
"Rollback".blue().bold(),
rollback_command(image_name, manifest_digest, root),
);
}
fn rm_storage_message() -> &'static str {
"Unreferenced data remains until a later `ommx gc --delete` removes it after the grace period."
}
fn rollback_command(image_name: &str, manifest_digest: &Digest, root: Option<&PathBuf>) -> String {
let mut command = format!(
"ommx restore-ref {} {}",
shell_quote(image_name),
shell_quote(manifest_digest.as_ref())
);
if let Some(root) = root {
command.push_str(" --root ");
command.push_str(&shell_quote(&root.display().to_string()));
}
command
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn print_blob_list(blobs: &[GcBlob]) {
for blob in blobs {
println!(
" {} {}",
blob.digest.to_string().dimmed(),
format_bytes(blob.size)
);
}
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::*;
use ommx::artifact::local_registry::{RegistryListWarning, RegistryListWarningStage};
const DIGEST: &str = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn restore_ref_cli_parses_rollback_command() {
let command = Command::try_parse_from([
"ommx",
"restore-ref",
"example.com/ommx/demo:deleted",
DIGEST,
"--root",
"/tmp/registry",
])
.unwrap();
let Command::RestoreRef {
image_name,
manifest_digest,
root,
} = command
else {
panic!("expected restore-ref command");
};
assert_eq!(image_name, "example.com/ommx/demo:deleted");
assert_eq!(manifest_digest.as_ref(), DIGEST);
assert_eq!(root, Some(PathBuf::from("/tmp/registry")));
}
#[test]
fn rollback_command_is_shell_safe_and_preserves_root() {
let digest = DIGEST.parse().unwrap();
assert_eq!(
rollback_command(
"example.com/ommx/demo:deleted",
&digest,
Some(&PathBuf::from("/tmp/registry with ' quote")),
),
concat!(
"ommx restore-ref 'example.com/ommx/demo:deleted' '",
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' ",
"--root '/tmp/registry with '\"'\"' quote'"
)
);
}
#[test]
fn rm_has_no_implicit_gc_option_and_explains_storage_lifecycle() {
let command = Command::try_parse_from([
"ommx",
"rm",
"example.com/ommx/demo:deleted",
"--root",
"/tmp/registry",
])
.unwrap();
let Command::Rm { image_name, root } = command else {
panic!("expected rm command");
};
assert_eq!(image_name, "example.com/ommx/demo:deleted");
assert_eq!(root, Some(PathBuf::from("/tmp/registry")));
assert!(
Command::try_parse_from(["ommx", "rm", "example.com/ommx/demo:deleted", "--gc",])
.is_err()
);
assert_eq!(
rm_storage_message(),
"Unreferenced data remains until a later `ommx gc --delete` removes it after the grace period."
);
}
#[test]
fn size_cli_accepts_multiple_images() {
let command = Command::try_parse_from([
"ommx",
"size",
"example.com/ommx/experiment:first",
"example.com/ommx/experiment:second",
])
.unwrap();
let Command::Size { image_names } = command else {
panic!("expected size command");
};
assert_eq!(
image_names,
[
"example.com/ommx/experiment:first",
"example.com/ommx/experiment:second"
]
);
}
#[test]
fn size_cli_requires_at_least_one_image() {
assert!(Command::try_parse_from(["ommx", "size"]).is_err());
}
#[test]
fn size_cli_uses_the_default_registry() {
assert!(Command::try_parse_from([
"ommx",
"size",
"example.com/ommx/experiment:latest",
"--root",
"/tmp/registry",
])
.is_err());
}
#[test]
fn size_cli_help_names_the_counted_manifest_parts() {
let help = match Command::try_parse_from(["ommx", "size", "--help"]) {
Ok(_) => panic!("--help must stop argument parsing"),
Err(error) => error.to_string(),
};
assert!(help.contains("Manifest JSON, config, and unique layer sizes"));
assert!(!help.contains("reachable"));
}
#[test]
fn size_reports_target_corruption_instead_of_not_found() {
let image_name = ImageRef::parse("example.com/ommx/experiment:corrupt").unwrap();
let error = exact_artifact_record(
RegistryListReport {
records: Vec::new(),
warnings: vec![RegistryListWarning {
image_name: image_name.to_string(),
manifest_digest: DIGEST.to_string(),
stage: RegistryListWarningStage::ManifestCacheRepair,
message: "Invalid cached Manifest; CAS repair failed".to_string(),
}],
},
&image_name,
)
.expect_err("target corruption must be returned as an error");
let message = format!("{error:#}");
assert!(message.contains("CAS repair failed"));
assert!(!message.contains("Artifact not found"));
}
}