Skip to content

Commit

Permalink
Support docker-like references
Browse files Browse the repository at this point in the history
  • Loading branch information
jssblck committed Dec 16, 2024
1 parent 8e33a95 commit c3f65c0
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 55 deletions.
34 changes: 26 additions & 8 deletions bin/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
///
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions bin/src/list.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
109 changes: 66 additions & 43 deletions lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -450,32 +428,77 @@ impl FromStr for Reference {
type Err = eyre::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
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::<Vec<_>>();
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,
})
}
Expand Down
20 changes: 19 additions & 1 deletion lib/tests/it/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Reference>().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/")]
Expand Down

0 comments on commit c3f65c0

Please sign in to comment.