diff --git a/Cargo.lock b/Cargo.lock index 965e46cb1..912279603 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,7 @@ dependencies = [ "serde_yaml", "similar-asserts", "static_assertions", + "tabled", "tempfile", "tini", "tokio", @@ -235,6 +236,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + [[package]] name = "bytes" version = "1.5.0" @@ -1412,6 +1419,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "papergrid" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7419ad52a7de9b60d33e11085a0fe3df1fbd5926aa3f93d3dd53afbc9e86725" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "pin-project" version = "1.1.4" @@ -1931,6 +1949,29 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tabled" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c9303ee60b9bedf722012ea29ae3711ba13a67c9b9ae28993838b63057cb1b" +dependencies = [ + "papergrid", + "tabled_derive", +] + +[[package]] +name = "tabled_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0fb8bfdc709786c154e24a66777493fb63ae97e3036d914c8666774c477069" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tar" version = "0.4.43" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index c50c250a4..e5b808da1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -46,6 +46,7 @@ toml = "0.8.12" xshell = { version = "0.2.6", optional = true } uuid = { version = "1.8.0", features = ["v4"] } tini = "1.3.0" +tabled = "0.16.0" [dev-dependencies] indoc = { workspace = true } diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 5e19d400e..fad500d49 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -21,6 +21,7 @@ use ostree_ext::container as ostree_container; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; use schemars::schema_for; +use serde::{Deserialize, Serialize}; use crate::deploy::RequiredHostSpec; use crate::lints; @@ -235,13 +236,54 @@ pub(crate) enum ImageCmdOpts { }, } +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImageListType { + /// List all images + #[default] + All, + /// List only logically bound images + Logical, + /// List only host images + Host, +} + +impl std::fmt::Display for ImageListType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ImageListFormat { + /// Human readable table format + #[default] + Table, + /// JSON format + Json, +} +impl std::fmt::Display for ImageListFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + /// Subcommands which operate on images. #[derive(Debug, clap::Subcommand, PartialEq, Eq)] pub(crate) enum ImageOpts { /// List fetched images stored in the bootc storage. /// /// Note that these are distinct from images stored via e.g. `podman`. - List, + List { + /// Type of image to list + #[clap(long)] + #[arg(default_value_t)] + list_type: ImageListType, + #[clap(long)] + #[arg(default_value_t)] + list_format: ImageListFormat, + }, /// Copy a container image from the bootc storage to `containers-storage:`. /// /// The source and target are both optional; if both are left unspecified, @@ -876,7 +918,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } }, Opt::Image(opts) => match opts { - ImageOpts::List => crate::image::list_entrypoint().await, + ImageOpts::List { + list_type, + list_format, + } => crate::image::list_entrypoint(list_type, list_format).await, ImageOpts::CopyToStorage { source, target } => { crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await } diff --git a/lib/src/image.rs b/lib/src/image.rs index eac891a0d..09405cc32 100644 --- a/lib/src/image.rs +++ b/lib/src/image.rs @@ -2,33 +2,111 @@ //! //! APIs for operating on container images in the bootc storage. -use anyhow::{Context, Result}; +use anyhow::{ensure, Context, Result}; use bootc_utils::CommandRunExt; +use clap::ValueEnum; use fn_error_context::context; use ostree_ext::container::{ImageReference, Transport}; +use serde::Serialize; +use tabled::{settings::Style, Tabled}; -use crate::imgstorage::Storage; +use crate::cli::{ImageListFormat, ImageListType}; /// The name of the image we push to containers-storage if nothing is specified. const IMAGE_DEFAULT: &str = "localhost/bootc"; +#[derive(Clone, Tabled, Serialize, ValueEnum)] +enum ImageListTypeColumn { + Host, + Logical, +} + +impl std::fmt::Display for ImageListTypeColumn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value().unwrap().get_name().fmt(f) + } +} + +#[derive(Tabled, Serialize)] +struct ImageOutput { + #[tabled(rename = "IMAGE TYPE")] + image_type: ImageListTypeColumn, + + #[tabled(rename = "IMAGE")] + image: String, + // TODO: Add hash, size, etc? Difficult because [`ostree_ext::container::store::list_images`] + // only gives us the pullspec. +} + +#[context("Listing host images")] +fn list_host_images(sysroot: &crate::store::Storage) -> Result> { + let repo = sysroot.repo(); + let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?; + + Ok(images + .iter() + .map(|x| ImageOutput { + image: x.to_string(), + image_type: ImageListTypeColumn::Host, + }) + .collect()) +} + +#[context("Listing logical images")] +fn list_logical_images(sysroot: &crate::store::Storage) -> Result> { + let output = sysroot + .get_ensure_imgstore()? + .new_image_cmd()? + .arg("list") + .arg("--format={{.Repository}}:{{.Tag}}") + .log_debug() + .output()?; + + ensure!( + output.status.success(), + "Failed to list images: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout)?; + + let images = stdout + .lines() + .map(|x| ImageOutput { + image: x.to_string(), + image_type: ImageListTypeColumn::Logical, + }) + .collect(); + + Ok(images) +} + #[context("Listing images")] -pub(crate) async fn list_entrypoint() -> Result<()> { - let sysroot = crate::cli::get_storage().await?; - let repo = &sysroot.repo(); +pub(crate) async fn list_entrypoint( + list_type: ImageListType, + list_format: ImageListFormat, +) -> Result<()> { + // TODO: Get the storage from the container image, not the booted storage + let sysroot: crate::store::Storage = crate::cli::get_storage().await?; - let images = ostree_ext::container::store::list_images(repo).context("Querying images")?; + let images = match list_type { + ImageListType::All => list_host_images(&sysroot)? + .into_iter() + .chain(list_logical_images(&sysroot)?) + .collect(), + ImageListType::Host => list_host_images(&sysroot)?, + ImageListType::Logical => list_logical_images(&sysroot)?, + }; - println!("# Host images"); - for image in images { - println!("{image}"); + match list_format { + ImageListFormat::Table => { + println!("{}", tabled::Table::new(images).with(Style::blank())); + } + ImageListFormat::Json => { + let mut stdout = std::io::stdout(); + serde_json::to_writer_pretty(&mut stdout, &images)?; + } } - println!(); - - println!("# Logically bound images"); - let mut listcmd = sysroot.get_ensure_imgstore()?.new_image_cmd()?; - listcmd.arg("list"); - listcmd.run()?; Ok(()) } @@ -79,7 +157,7 @@ pub(crate) async fn push_entrypoint(source: Option<&str>, target: Option<&str>) /// Thin wrapper for invoking `podman image ` but set up for our internal /// image store (as distinct from /var/lib/containers default). pub(crate) async fn imgcmd_entrypoint( - storage: &Storage, + storage: &crate::imgstorage::Storage, arg: &str, args: &[std::ffi::OsString], ) -> std::result::Result<(), anyhow::Error> {