Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support docker-like references #6

Merged
merged 5 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link

@csasarak csasarak Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

[Optional, probably more relevant for the CLI and super minor] It might be worth making the "base" configurable via env var. If I'm a company who has a list of image names it may be simpler for me to set an env var specifying the base rather than have to program my CI jobs to concatenate it together with the base name. It also means that I can create a CI job template that sets the var and that makes calls to this do the right thing rather than making my engineers have to remember to use the FQN.

^^ I'm a little dubious that this is super useful, but I think it's worth explicitly rejecting.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's an incredible idea! made that quick change.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about this more over lunch, and it would be similar to also have CLI options that can do that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this is behavior that we probably don't want in flags; it's not really intended to be used most of the time, it's just an escape hatch.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, thanks!


/// 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
2 changes: 1 addition & 1 deletion bin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async fn main() -> Result<()> {
.with_deferred_spans(true)
.with_bracketed_fields(true)
.with_span_retrace(true)
.with_targets(true),
.with_targets(false),
)
.with(
tracing_subscriber::EnvFilter::builder()
Expand Down
114 changes: 70 additions & 44 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 All @@ -11,7 +11,7 @@ use itertools::Itertools;
use std::{borrow::Cow, ops::Add, str::FromStr};
use strum::{AsRefStr, EnumIter, IntoEnumIterator};
use tap::{Pipe, Tap};
use tracing::debug;
use tracing::{debug, warn};

mod ext;
pub mod registry;
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,80 @@ 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)?;
warn!("expanding '{name}' to '{DOCKER_IO}/{LIBRARY}/{name}'; fully specify the reference to avoid this behavior");
(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)?;
warn!("expanding '{host}/{name}' to '{host}/{LIBRARY}/{name}'; fully specify the reference to avoid this behavior");
(*host, LIBRARY, name, version)
}
[namespace, name] => {
let (name, version) = parse_name(name)?;
warn!("expanding '{namespace}/{name}' to '{DOCKER_IO}/{namespace}/{name}'; fully specify the reference to avoid this behavior");
(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
Loading