From c3f65c06c631476e6d45f6d5f9494a3b47a4c579 Mon Sep 17 00:00:00 2001 From: Jessica Black Date: Mon, 16 Dec 2024 11:21:28 -0800 Subject: [PATCH] Support docker-like references --- bin/src/extract.rs | 34 +++++++++--- bin/src/list.rs | 8 +-- lib/src/lib.rs | 109 +++++++++++++++++++++++--------------- lib/tests/it/reference.rs | 20 ++++++- 4 files changed, 116 insertions(+), 55 deletions(-) diff --git a/bin/src/extract.rs b/bin/src/extract.rs index caa322b..4302769 100644 --- a/bin/src/extract.rs +++ b/bin/src/extract.rs @@ -79,8 +79,26 @@ pub struct Options { #[derive(Debug, Args)] pub struct Target { /// Image reference being extracted (e.g. docker.io/library/ubuntu:latest) - #[arg(value_parser = Reference::from_str)] - pub image: Reference, + /// + /// If a fully specified reference is not provided, + /// the image is attempted to be resolved with the prefix + /// `docker.io/library`. + /// + /// The reference may optionally provide a digest, for example + /// `docker.io/library/ubuntu@sha256:1234567890`. + /// + /// Finally, the reference may optionally provide a tag, for example + /// `docker.io/library/ubuntu:latest` or `docker.io/library/ubuntu:24.04`. + /// If no digest or tag is provided, the tag "latest" is used. + /// + /// Put all that together and you get the following examples: + /// - `ubuntu` is resolved as `docker.io/library/ubuntu:latest` + /// - `ubuntu:24.04` is resolved as `docker.io/library/ubuntu:24.04` + /// - `docker.io/library/ubuntu` is resolved as `docker.io/library/ubuntu:latest` + /// - `docker.io/library/ubuntu@sha256:1234567890` is resolved as `docker.io/library/ubuntu@sha256:1234567890` + /// - `docker.io/library/ubuntu:24.04` is resolved as `docker.io/library/ubuntu:24.04` + #[arg(verbatim_doc_comment)] + pub image: String, /// Platform to extract (e.g. linux/amd64) /// @@ -131,20 +149,20 @@ pub enum Mode { pub async fn main(opts: Options) -> Result<()> { info!("extracting image"); - let auth = match (opts.target.username, opts.target.password) { - (Some(username), Some(password)) => Authentication::basic(username, password), - _ => Authentication::default(), - }; - + let reference = Reference::from_str(&opts.target.image)?; let layer_globs = Filters::parse_glob(opts.layer_glob.into_iter().flatten())?; let file_globs = Filters::parse_glob(opts.file_glob.into_iter().flatten())?; let layer_regexes = Filters::parse_regex(opts.layer_regex.into_iter().flatten())?; let file_regexes = Filters::parse_regex(opts.file_regex.into_iter().flatten())?; + let auth = match (opts.target.username, opts.target.password) { + (Some(username), Some(password)) => Authentication::basic(username, password), + _ => Authentication::default(), + }; let output = canonicalize_output_dir(&opts.output_dir, opts.overwrite)?; let registry = Registry::builder() .maybe_platform(opts.target.platform) - .reference(opts.target.image) + .reference(reference) .auth(auth) .layer_filters(layer_globs + layer_regexes) .file_filters(file_globs + file_regexes) diff --git a/bin/src/list.rs b/bin/src/list.rs index d488c9e..ad02eb7 100644 --- a/bin/src/list.rs +++ b/bin/src/list.rs @@ -1,9 +1,9 @@ -use circe_lib::{registry::Registry, Authentication}; +use circe_lib::{registry::Registry, Authentication, Reference}; use clap::Parser; use color_eyre::eyre::{Context, Result}; use derive_more::Debug; use pluralizer::pluralize; -use std::collections::HashMap; +use std::{collections::HashMap, str::FromStr}; use tracing::{debug, info}; use crate::extract::Target; @@ -19,13 +19,15 @@ pub struct Options { pub async fn main(opts: Options) -> Result<()> { info!("extracting image"); + let reference = Reference::from_str(&opts.target.image)?; let auth = match (opts.target.username, opts.target.password) { (Some(username), Some(password)) => Authentication::basic(username, password), _ => Authentication::default(), }; + let registry = Registry::builder() .maybe_platform(opts.target.platform) - .reference(opts.target.image) + .reference(reference) .auth(auth) .build() .await diff --git a/lib/src/lib.rs b/lib/src/lib.rs index b087d18..7c464f5 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -2,7 +2,7 @@ use bon::Builder; use color_eyre::{ - eyre::{self, bail, eyre, Context}, + eyre::{self, bail, ensure, eyre, Context}, Result, Section, SectionExt, }; use derive_more::derive::{Debug, Display, From}; @@ -388,28 +388,6 @@ impl Version { } /// A parsed container image reference. -/// -/// ``` -/// # use circe_lib::{Reference, Version}; -/// # use std::str::FromStr; -/// // Default to latest tag -/// let reference = Reference::from_str("docker.io/library/ubuntu").expect("parse reference"); -/// assert_eq!(reference.host, "docker.io"); -/// assert_eq!(reference.repository, "library/ubuntu"); -/// assert_eq!(reference.version, Version::tag("latest")); -/// -/// // Parse a tag -/// let reference = Reference::from_str("docker.io/library/ubuntu:other").expect("parse reference"); -/// assert_eq!(reference.host, "docker.io"); -/// assert_eq!(reference.repository, "library/ubuntu"); -/// assert_eq!(reference.version, Version::tag("other")); -/// -/// // Parse a digest -/// let reference = Reference::from_str("docker.io/library/ubuntu@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").expect("parse reference"); -/// assert_eq!(reference.host, "docker.io"); -/// assert_eq!(reference.repository, "library/ubuntu"); -/// assert_eq!(reference.version.to_string(), "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"); -/// ``` #[derive(Debug, Clone, PartialEq, Eq, Builder)] pub struct Reference { /// Registry host (e.g. "docker.io", "ghcr.io") @@ -450,32 +428,77 @@ impl FromStr for Reference { type Err = eyre::Error; fn from_str(s: &str) -> Result { - let input_section = || s.to_string().header("Input:"); - let (host, remainder) = s.split_once('/').ok_or_else(|| { - eyre!("invalid reference: missing host separator '/'").with_section(input_section) - })?; + // Returns an owned string so that we can support multiple name segments. + fn parse_name(name: &str) -> Result<(String, Version)> { + if let Some((name, digest)) = name.split_once('@') { + let digest = Digest::from_str(digest).context("parse digest")?; + Ok((name.to_string(), Version::Digest(digest))) + } else if let Some((name, tag)) = name.split_once(':') { + Ok((name.to_string(), Version::Tag(tag.to_string()))) + } else { + Ok((name.to_string(), Version::latest())) + } + } - // Find either ':' for tag or '@' for digest. - // Check for '@' first since digest identifiers also contain ':'. - let (repository, version) = if let Some((repo, digest)) = remainder.split_once('@') { - let digest = Digest::from_str(digest).context("parse digest")?; - (repo, Version::Digest(digest)) - } else if let Some((repo, tag)) = remainder.split_once(':') { - (repo, Version::Tag(tag.to_string())) - } else { - (remainder, Version::latest()) + // Docker supports `docker pull ubuntu` and `docker pull library/ubuntu`, + // both of which are parsed as `docker.io/library/ubuntu`. + // The below recreates this behavior. + const DOCKER_IO: &str = "docker.io"; + const LIBRARY: &str = "library"; + let parts = s.split('/').collect::>(); + let (host, namespace, name, version) = match parts.as_slice() { + // For docker compatibility, `{name}` is parsed as `docker.io/library/{name}`. + [name] => { + let (name, version) = parse_name(name)?; + (DOCKER_IO, LIBRARY, name, version) + } + + // Two segments may mean "{namespace}/{name}" or may mean "docker.io/{name}". + // This is a special case for docker compatibility. + [host, name] if *host == DOCKER_IO => { + let (name, version) = parse_name(name)?; + (*host, LIBRARY, name, version) + } + [namespace, name] => { + let (name, version) = parse_name(name)?; + (DOCKER_IO, *namespace, name, version) + } + + // Some names have multiple segments, e.g. `docker.io/library/ubuntu/foo`. + // We can't handle multi-segment names in other branches since they conflict with the various shorthands, + // but handle them here since they're not ambiguous. + [host, namespace, name @ ..] => { + let name = name.join("/"); + let (name, version) = parse_name(&name)?; + (*host, *namespace, name, version) + } + _ => { + return eyre!("invalid reference format: {s}") + .with_section(|| { + [ + "Provide either a fully qualified OCI reference, or a short form.", + "Short forms are in the format `{name}` or `{namespace}/{name}`.", + "If you provide a short form, the default registry is `docker.io`.", + ] + .join("\n") + .header("Help:") + }) + .with_section(|| { + ["docker.io/library/ubuntu", "library/ubuntu", "ubuntu"] + .join("\n") + .header("Examples:") + }) + .pipe(Err) + } }; - if host.is_empty() { - return Err(eyre!("host cannot be empty").with_section(input_section)); - } - if repository.is_empty() { - return Err(eyre!("repository cannot be empty").with_section(input_section)); - } + ensure!(!host.is_empty(), "host cannot be empty: {s}"); + ensure!(!namespace.is_empty(), "namespace cannot be empty: {s}"); + ensure!(!name.is_empty(), "name cannot be empty: {s}"); Ok(Reference { host: host.to_string(), - repository: repository.to_string(), + repository: format!("{namespace}/{name}"), version, }) } diff --git a/lib/tests/it/reference.rs b/lib/tests/it/reference.rs index 2ca1e0b..a2da4e6 100644 --- a/lib/tests/it/reference.rs +++ b/lib/tests/it/reference.rs @@ -20,7 +20,25 @@ fn display(reference: Reference, expected: &str) { pretty_assertions::assert_eq!(reference.to_string(), expected); } -#[test_case("invalid:latest"; "invalid:latest")] +#[test_case("ubuntu", "docker.io/library/ubuntu:latest"; "ubuntu")] +#[test_case("ubuntu:14.04", "docker.io/library/ubuntu:14.04"; "ubuntu:14.04")] +#[test_case("ubuntu@sha256:123abc", "docker.io/library/ubuntu@sha256:123abc"; "ubuntu@sha256:123abc")] +#[test_case("library/ubuntu", "docker.io/library/ubuntu:latest"; "library/ubuntu")] +#[test_case("contribsys/faktory", "docker.io/contribsys/faktory:latest"; "contribsys/faktory")] +#[test_case("contribsys/faktory:1.0.0", "docker.io/contribsys/faktory:1.0.0"; "contribsys/faktory:1.0.0")] +#[test_case("library/ubuntu:14.04", "docker.io/library/ubuntu:14.04"; "library/ubuntu:14.04")] +#[test_case("library/ubuntu@sha256:123abc", "docker.io/library/ubuntu@sha256:123abc"; "library/ubuntu@sha256:123abc")] +#[test_case("docker.io/library/ubuntu:14.04", "docker.io/library/ubuntu:14.04"; "docker.io/library/ubuntu:14.04")] +#[test_case("docker.io/library/ubuntu@sha256:123abc", "docker.io/library/ubuntu@sha256:123abc"; "docker.io/library/ubuntu@sha256:123abc")] +#[test_case("host.dev/somecorp/someproject/someimage", "host.dev/somecorp/someproject/someimage:latest"; "host.dev/somecorp/someproject/someimage")] +#[test_case("host.dev/somecorp/someproject/someimage:1.0.0", "host.dev/somecorp/someproject/someimage:1.0.0"; "host.dev/somecorp/someproject/someimage:1.0.0")] +#[test_case("host.dev/somecorp/someproject/someimage@sha256:123abc", "host.dev/somecorp/someproject/someimage@sha256:123abc"; "host.dev/somecorp/someproject/someimage@sha256:123abc")] +#[test] +fn docker_like(input: &str, expected: &str) { + let reference = input.parse::().unwrap(); + pretty_assertions::assert_eq!(reference.to_string(), expected); +} + #[test_case("/repo:tag"; "/repo:tag")] #[test_case("host/:tag"; "host/tag")] #[test_case("host/"; "host/")]