From cf9ea2e8169022e4edb95d8433aa337ec9c18268 Mon Sep 17 00:00:00 2001 From: Valentin Rothberg Date: Thu, 12 Nov 2020 12:17:44 +0100 Subject: [PATCH] pkg/shortnames Add a new package for short-name resolution. `pkg/shortnames` is built around the short-name aliasing in the registries.conf and introduces two functions. Signed-off-by: Valentin Rothberg --- docs/containers-registries.conf.5.md | 69 +++ go.mod | 1 + go.sum | 17 + pkg/shortnames/shortnames.go | 458 ++++++++++++++++ pkg/shortnames/shortnames_test.go | 518 ++++++++++++++++++ pkg/shortnames/testdata/aliases.conf | 7 + pkg/shortnames/testdata/no-reg.conf | 2 + pkg/shortnames/testdata/one-reg.conf | 4 + .../testdata/registries.conf.d/config-1.conf | 9 + .../testdata/registries.conf.d/config-2.conf | 14 + .../testdata/registries.conf.d/config-3.conf | 0 .../registries.conf.d/config-3.ignore | 7 + pkg/shortnames/testdata/two-reg.conf | 4 + pkg/sysregistriesv2/system_registries_v2.go | 3 + types/types.go | 16 +- 15 files changed, 1125 insertions(+), 4 deletions(-) create mode 100644 pkg/shortnames/shortnames.go create mode 100644 pkg/shortnames/shortnames_test.go create mode 100644 pkg/shortnames/testdata/aliases.conf create mode 100644 pkg/shortnames/testdata/no-reg.conf create mode 100644 pkg/shortnames/testdata/one-reg.conf create mode 100644 pkg/shortnames/testdata/registries.conf.d/config-1.conf create mode 100644 pkg/shortnames/testdata/registries.conf.d/config-2.conf create mode 100644 pkg/shortnames/testdata/registries.conf.d/config-3.conf create mode 100644 pkg/shortnames/testdata/registries.conf.d/config-3.ignore create mode 100644 pkg/shortnames/testdata/two-reg.conf diff --git a/docs/containers-registries.conf.5.md b/docs/containers-registries.conf.5.md index 8cfa99522..98b8a8d80 100644 --- a/docs/containers-registries.conf.5.md +++ b/docs/containers-registries.conf.5.md @@ -102,6 +102,75 @@ internet without having to change `Dockerfile`s, or to add redundancy). *Note*: Redirection and mirrors are currently processed only when reading images, not when pushing to a registry; that may change in the future. +#### Short-Name Aliasing +The use of unqualified-search registries entails an ambiguity as it is +unclear from which registry a given image, referenced by a short name, +may be pulled from. + +As mentioned in the note at the end of this man page, using short names is +subject to the risk of hitting squatted registry namespaces. If the +unqualified-search registries are set to `["registry1.com", "registry2.com"]` +an attacker may take over a namespace of registry1.com such that an image may +be pulled from registry1.com instead of the intended source registry2.com. + +While it is highly recommended to always use fully-qualified image references, +existing deployments using short names may not be easily changed. To +circumvent the aforementioned ambiguity, so called short-name aliases can be +configured that point to a fully-qualified image +reference. + +Short-name aliases can be configured in the `[aliases]` table in the form of +`"name"="value"` with the left-hand `name` being the short name (e.g., "image") +and the right-hand `value` being the fully-qualified image reference (e.g., +"registry.com/namespace/image"). Note that neither "name" nor "value" can +include a tag or digest. Moreover, "name" must be a short name and hence +cannot include a registry domain or refer to localhost. + +When pulling a short name, the configured aliases table will be used for +resolving the short name. If a matching alias is found, it will be used +without further consulting the unqualified-search registries list. If no +matching alias is found, the behavior can be controlled via the +`short-name-mode` option as described below. + +Note that tags and digests are stripped off a user-specified short name for +alias resolution. Hence, "image", "image:tag" and "image@digest" all resolve +to the same alias (i.e., "image"). Stripped off tags and digests are later +appended to the resolved alias. + +Further note that drop-in configuration files (see containers-registries.conf.d(5)) +can override aliases in the specific loading order of the files. If the "value" of +an alias is empty (i.e., ""), the alias will be erased. However, a given +"name" may only be specified once in a single config file. + + +#### Short-Name Aliasing: Modes + +The `short-name-mode` option supports three modes to control the behaviour of +short-name resolution. + +* `enforcing`: If only one unqualified-search registry is set, use it as there + is no ambiguity. If there is more than one registry and the user program is + running in a terminal (i.e., stdout & stdin are a TTY), prompt the user to + select one of the specified search registries. If the program is not running + in a terminal, the ambiguity cannot be resolved which will lead to an error. + +* `permissive`: Behaves as enforcing but does not lead to an error if the + program is not running in a terminal. Instead, fallback to using all + unqualified-search registries. + +* `disabled`: Use all unqualified-search registries without prompting. + +If `short-name-mode` is not specified at all or left empty, default to the +`permissive` mode. If the user-specified short name was not aliased already, +the `enforcing` and `permissive` mode if prompted, will record a new alias +after a successful pull. Note that the recorded alias will be written to +`$XDG_CONFIG_HOME/containers/short-name-aliases.conf` to have a clear +separation between possibly human-edited registries.conf files and the +machine-generated `short-name-aliases-conf`. Note that `$HOME/.config` is used +if `$XDG_CONFIG_HOME` is not set. If an alias is specified in a +`registries.conf` file and also the machine-generated +`short-name-aliases.conf`, the `short-name-aliases.conf` file has precedence. + #### Normalization of docker.io references The Docker Hub `docker.io` is handled in a special way: every push and pull diff --git a/go.mod b/go.mod index 3010fcb6f..2ec15a594 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/imdario/mergo v0.3.11 github.com/klauspost/compress v1.11.2 github.com/klauspost/pgzip v1.2.5 + github.com/manifoldco/promptui v0.8.0 github.com/morikuni/aec v1.0.0 // indirect github.com/mtrmac/gpgme v0.1.2 github.com/opencontainers/go-digest v1.0.0 diff --git a/go.sum b/go.sum index d19cf257a..c1f056ded 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/checkpoint-restore/go-criu/v4 v4.0.2/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.0.0-20200507155900-a9f01edf17e3/go.mod h1:XT+cAw5wfvsodedcijoh1l9cf7v1x9FlFB/3VmF/O8s= github.com/cilium/ebpf v0.0.0-20200702112145-1c8d4c9ef775/go.mod h1:7cR51M8ViRLIdUjrmSXlK9pkrsDlLHbO8jiB8X8JnOc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -42,6 +46,8 @@ github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= +github.com/containers/image v1.5.1 h1:ssEuj1c24uJvdMkUa2IrawuEFZBP12p6WzrjNBTQxE0= +github.com/containers/image v3.0.2+incompatible h1:B1lqAE8MUPCrsBLE86J0gnXleeRq8zJnQryhiiGQNyE= github.com/containers/libtrust v0.0.0-20190913040956-14b96171aa3b h1:Q8ePgVfHDplZ7U33NwHZkrVELsZP5fYj9pM5WBZB2GE= github.com/containers/libtrust v0.0.0-20190913040956-14b96171aa3b/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= github.com/containers/ocicrypt v1.0.1 h1:EToign46OSLTFWnb2oNj9RG3XDnkOX8r28ZIXUuk5Pc= @@ -161,6 +167,8 @@ github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= @@ -205,6 +213,14 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= @@ -404,6 +420,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/shortnames/shortnames.go b/pkg/shortnames/shortnames.go new file mode 100644 index 000000000..e02703d77 --- /dev/null +++ b/pkg/shortnames/shortnames.go @@ -0,0 +1,458 @@ +package shortnames + +import ( + "fmt" + "os" + "strings" + + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/pkg/sysregistriesv2" + "github.com/containers/image/v5/types" + "github.com/manifoldco/promptui" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" + "golang.org/x/crypto/ssh/terminal" +) + +// IsShortName returns true if the specified input is a "short name". A "short +// name" refers to a container image without a fully-qualified reference, and +// is hence missing a registry (or domain). Names including a digest are not +// short names. +// +// Examples: +// * short names: "image:tag", "library/fedora" +// * not short names: "quay.io/image", "localhost/image:tag", +// "server.org:5000/lib/image", "image@sha256:..." +func IsShortName(input string) bool { + isShort, _, _ := parseUnnormalizedShortName(input) + return isShort +} + +// parseUnnormalizedShortName parses the input and returns if it's short name, +// the unnormalized reference.Named, and a parsing error. +func parseUnnormalizedShortName(input string) (bool, reference.Named, error) { + ref, err := reference.Parse(input) + if err != nil { + return false, nil, errors.Wrapf(err, "cannot parse input: %q", input) + } + + named, ok := ref.(reference.Named) + if !ok { + return true, nil, errors.Errorf("%q is not a named reference", input) + } + + registry := reference.Domain(named) + if strings.ContainsAny(registry, ".:") || registry == "localhost" { + // A final parse to make sure that docker.io references are correctly + // normalized (e.g., docker.io/alpine to docker.io/library/alpine. + named, err = reference.ParseNormalizedNamed(input) + if err != nil { + return false, nil, errors.Wrapf(err, "cannot normalize input: %q", input) + } + return false, named, nil + } + + return true, named, nil +} + +// splitUserInput parses the user-specified reference. Namely, it strips off +// the tag or digest and stores it in the return values so that both can be +// re-added to a possible resolved alias' or USRs at a later point. +func splitUserInput(named reference.Named) (isTagged bool, isDigested bool, normalized reference.Named, tag string, digest digest.Digest) { + normalized = named + + tagged, isT := named.(reference.NamedTagged) + if isT { + isTagged = true + tag = tagged.Tag() + } + + digested, isD := named.(reference.Digested) + if isD { + isDigested = true + digest = digested.Digest() + } + + // Strip off tag/digest if present. + normalized = reference.TrimNamed(named) + + return +} + +// Add records the specified name-value pair as a new short-name alias to the +// user-specific aliases.conf. It may override an existing alias for `name`. +func Add(ctx *types.SystemContext, name string, value reference.Named) error { + isShort, _, err := parseUnnormalizedShortName(name) + if err != nil { + return err + } + if !isShort { + return errors.Errorf("%q is not a short name", name) + } + return sysregistriesv2.AddShortNameAlias(ctx, name, value.String()) +} + +// Remove clears the short-name alias for the specified name. It throws an +// error in case name does not exist in the machine-generated +// short-name-alias.conf. In such case, the alias must be specified in one of +// the registries.conf files, which is the users' responsibility. +func Remove(ctx *types.SystemContext, name string) error { + isShort, _, err := parseUnnormalizedShortName(name) + if err != nil { + return err + } + if !isShort { + return errors.Errorf("%q is not a short name", name) + } + return sysregistriesv2.RemoveShortNameAlias(ctx, name) +} + +// Resolved encapsulates all data for a resolved image name. +type Resolved struct { + PullCandidates []PullCandidate + + userInput reference.Named + systemContext *types.SystemContext + rationale rationale + originDescription string +} + +func (r *Resolved) addCandidate(named reference.Named) { + r.PullCandidates = append(r.PullCandidates, PullCandidate{named, false, r}) +} + +func (r *Resolved) addCandidateToRecord(named reference.Named) { + r.PullCandidates = append(r.PullCandidates, PullCandidate{named, true, r}) +} + +// Allows to reason over pull errors and add some context information. +// Used in (*Resolved).WrapPullError. +type rationale int + +const ( + // No additional context. + rationaleNone rationale = iota + // Resolved value is a short-name alias. + rationaleAlias + // Resolved value has been completed with an Unqualified Search Registry. + rationaleUSR + // Resolved value has been selected by the user (via the prompt). + rationaleUserSelection +) + +// Description returns a human-readable description about the resolution +// process (e.g., short-name alias, unqualified-search registries, etc.). +// It is meant to be printed before attempting to pull the pull candidates +// to make the short-name resolution more transparent to user. +// +// If the returned string is empty, it is not meant to be printed. +func (r *Resolved) Description() string { + switch r.rationale { + case rationaleAlias: + return fmt.Sprintf("Resolved short name %q to a recorded short-name alias (origin: %s)", r.userInput, r.originDescription) + case rationaleUSR: + return fmt.Sprintf("Completed short name %q with unqualified-search registries (origin: %s)", r.userInput, r.originDescription) + case rationaleUserSelection, rationaleNone: + fallthrough + default: + return "" + } +} + +// FormatPullErrors is a convenience function to format errors that occurred +// while trying to pull all of the resolved pull candidates. +// +// Note that nil is returned if len(pullErrors) == 0. Otherwise, the amount of +// pull errors must equal the amount of pull candidates. +func (r *Resolved) FormatPullErrors(pullErrors []error) error { + if len(pullErrors) >= 0 && len(pullErrors) != len(r.PullCandidates) { + pullErrors = append(pullErrors, + errors.Errorf("internal error: expected %d instead of %d errors for %d pull candidates", + len(r.PullCandidates), len(pullErrors), len(r.PullCandidates))) + } + + switch len(pullErrors) { + case 0: + return nil + case 1: + return pullErrors[0] + default: + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%d errors occurred while pulling:", len(pullErrors))) + for _, e := range pullErrors { + sb.WriteString("\n * ") + sb.WriteString(e.Error()) + } + return errors.New(sb.String()) + } +} + +// PullCandidate is a resolved name. Once the Value has been used +// successfully, users MUST call `(*PullCandidate).Record(..)` to possibly +// record it as a new short-name alias. +type PullCandidate struct { + // Fully-qualified reference with tag or digest. + Value reference.Named + // Control whether to record it permanently as an alias. + record bool + + // Backwards pointer to the Resolved "parent". + resolved *Resolved +} + +// Record may store a short-name alias for the PullCandidate. +func (c *PullCandidate) Record() error { + if !c.record { + return nil + } + + // Strip off tags/digests from name/value. + name := reference.TrimNamed(c.resolved.userInput) + value := reference.TrimNamed(c.Value) + + if err := Add(c.resolved.systemContext, name.String(), value); err != nil { + return errors.Wrapf(err, "error recording short-name alias (%q=%q)", c.resolved.userInput, c.Value) + } + return nil +} + +// Resolve resolves the specified name to either one or more fully-qualified +// image references that the short name may be *pulled* from. If the specified +// name is already a fully-qualified reference (i.e., not a short name), it is +// returned as is. In case, it's a short name, it's resolved according to the +// ShortNameMode in the SystemContext (if specified) or in the registries.conf. +// +// Note that tags and digests are stripped from the specified name before +// looking up an alias. Stripped off tags and digests are later on appended to +// all candidates. If neither tag nor digest is specified, candidates are +// normalized with the "latest" tag. PullCandidates in the returned value may +// be empty if there is no matching alias and no unqualified-search registries +// are configured. +// +// Note that callers *must* call `(PullCandidate).Record` after a returned +// item has been pulled successfully; this callback will record a new +// short-name alias (depending on the specified short-name mode). +// +// Furthermore, before attempting to pull callers *should* call +// `(Resolved).Description` and afterwards use +// `(Resolved).FormatPullErrors` in case of pull errors. +func Resolve(ctx *types.SystemContext, name string) (*Resolved, error) { + resolved := &Resolved{} + + // Create a copy of the system context to make it usable beyond this + // function call. + var sys *types.SystemContext + if ctx != nil { + sys = &(*ctx) + } + resolved.systemContext = ctx + + // Detect which mode we're running in. + mode, err := sysregistriesv2.GetShortNameMode(sys) + if err != nil { + return nil, err + } + + // Sanity check the short-name mode. + switch mode { + case types.ShortNameModeDisabled, types.ShortNameModePermissive, types.ShortNameModeEnforcing: + // We're good. + default: + return nil, errors.Errorf("unsupported short-name mode (%v)", mode) + } + + isShort, shortRef, err := parseUnnormalizedShortName(name) + if err != nil { + return nil, err + } + if !isShort { // no short name + named := reference.TagNameOnly(shortRef) // Make sure to add ":latest" if needed + resolved.addCandidate(named) + return resolved, nil + } + + // Strip off the tag to normalize the short name for looking it up in + // the config files. + isTagged, isDigested, shortNameRepo, tag, digest := splitUserInput(shortRef) + resolved.userInput = shortNameRepo + + // If there's already an alias, use it. + namedAlias, aliasOriginDescription, err := sysregistriesv2.ResolveShortNameAlias(sys, shortNameRepo.String()) + if err != nil { + return nil, err + } + + // Always use an alias if present. + if namedAlias != nil { + if isTagged { + namedAlias, err = reference.WithTag(namedAlias, tag) + if err != nil { + return nil, err + } + } + if isDigested { + namedAlias, err = reference.WithDigest(namedAlias, digest) + if err != nil { + return nil, err + } + } + // Make sure to add ":latest" if needed + namedAlias = reference.TagNameOnly(namedAlias) + + resolved.addCandidate(namedAlias) + resolved.rationale = rationaleAlias + resolved.originDescription = aliasOriginDescription + return resolved, nil + } + + resolved.rationale = rationaleUSR + + // Query the registry for unqualified-search registries. + unqualifiedSearchRegistries, usrConfig, err := sysregistriesv2.UnqualifiedSearchRegistriesWithOrigin(sys) + if err != nil { + return nil, err + } + resolved.originDescription = usrConfig + + for _, reg := range unqualifiedSearchRegistries { + named, err := reference.ParseNormalizedNamed(fmt.Sprintf("%s/%s", reg, name)) + if err != nil { + return nil, errors.Wrapf(err, "error creating reference with unqualified-search registry %q", reg) + } + // Make sure to add ":latest" if needed + named = reference.TagNameOnly(named) + + resolved.addCandidate(named) + } + + // If we're running in disabled, return the candidates without + // prompting (and without recording). + if mode == types.ShortNameModeDisabled { + return resolved, nil + } + + // If we have only one candidate, there's no ambiguity. In case of an + // empty candidate slices, callers can implement custom logic or raise + // an error. + if len(resolved.PullCandidates) <= 1 { + return resolved, nil + } + + // If we don't have a TTY, act according to the mode. + if !terminal.IsTerminal(int(os.Stdout.Fd())) || !terminal.IsTerminal(int(os.Stdin.Fd())) { + switch mode { + case types.ShortNameModePermissive: + // Permissive falls back to using all candidates. + return resolved, nil + case types.ShortNameModeEnforcing: + // Enforcing errors out without a prompt. + return nil, errors.New("short-name resolution enforced but cannot prompt without a TTY") + default: + // We should not end up here. + return nil, errors.Errorf("unexpected short-name mode (%v) during resolution", mode) + } + } + + // We have a TTY, and can prompt the user with a selection of all + // possible candidates. + strCandidates := []string{} + for _, candidate := range resolved.PullCandidates { + strCandidates = append(strCandidates, candidate.Value.String()) + } + prompt := promptui.Select{ + Label: "Please select an image", + Items: strCandidates, + HideHelp: true, // do not show navigation help + } + + _, selection, err := prompt.Run() + if err != nil { + return nil, err + } + + named, err := reference.ParseNormalizedNamed(selection) + if err != nil { + return nil, errors.Wrapf(err, "selection %q is not a valid reference", selection) + } + + resolved.PullCandidates = nil + resolved.addCandidateToRecord(named) + resolved.rationale = rationaleUserSelection + + return resolved, nil +} + +// ResolveLocally resolves the specified name to either one or more local +// images. If the specified name is already a fully-qualified reference (i.e., +// not a short name), it is returned as is. In case, it's a short name, the +// returned slice of named references looks as follows: +// +// 1) If present, the short-name alias +// 2) "localhost/" as used by many container engines such as Podman and Buildah +// 3) Unqualified-search registries from the registries.conf files +// +// Note that tags and digests are stripped from the specified name before +// looking up an alias. Stripped off tags and digests are later on appended to +// all candidates. If neither tag nor digest is specified, candidates are +// normalized with the "latest" tag. The returned slice contains at least one +// item. +func ResolveLocally(ctx *types.SystemContext, name string) ([]reference.Named, error) { + isShort, shortRef, err := parseUnnormalizedShortName(name) + if err != nil { + return nil, err + } + if !isShort { // no short name + named := reference.TagNameOnly(shortRef) // Make sure to add ":latest" if needed + return []reference.Named{named}, nil + } + + var candidates []reference.Named + + // Strip off the tag to normalize the short name for looking it up in + // the config files. + isTagged, isDigested, shortNameRepo, tag, digest := splitUserInput(shortRef) + + // If there's already an alias, use it. + namedAlias, _, err := sysregistriesv2.ResolveShortNameAlias(ctx, shortNameRepo.String()) + if err != nil { + return nil, err + } + if namedAlias != nil { + if isTagged { + namedAlias, err = reference.WithTag(namedAlias, tag) + if err != nil { + return nil, err + } + } + if isDigested { + namedAlias, err = reference.WithDigest(namedAlias, digest) + if err != nil { + return nil, err + } + } + // Make sure to add ":latest" if needed + namedAlias = reference.TagNameOnly(namedAlias) + + candidates = append(candidates, namedAlias) + } + + // Query the registry for unqualified-search registries. + unqualifiedSearchRegistries, err := sysregistriesv2.UnqualifiedSearchRegistries(ctx) + if err != nil { + return nil, err + } + + // Note that "localhost" has precedence over the unqualified-search registries. + for _, reg := range append([]string{"localhost"}, unqualifiedSearchRegistries...) { + named, err := reference.ParseNormalizedNamed(fmt.Sprintf("%s/%s", reg, name)) + if err != nil { + return nil, errors.Wrapf(err, "error creating reference with unqualified-search registry %q", reg) + } + // Make sure to add ":latest" if needed + named = reference.TagNameOnly(named) + + candidates = append(candidates, named) + } + + return candidates, nil +} diff --git a/pkg/shortnames/shortnames_test.go b/pkg/shortnames/shortnames_test.go new file mode 100644 index 000000000..7e0f30e5f --- /dev/null +++ b/pkg/shortnames/shortnames_test.go @@ -0,0 +1,518 @@ +package shortnames + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/pkg/sysregistriesv2" + "github.com/containers/image/v5/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsShortName(t *testing.T) { + tests := []struct { + input string + parseUnnormalizedShortName bool + mustFail bool + }{ + // SHORT NAMES + {"fedora", true, false}, + {"fedora:latest", true, false}, + {"library/fedora", true, false}, + {"library/fedora:latest", true, false}, + {"busybox@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", true, false}, + {"busybox:latest@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", true, false}, + // !SHORT NAMES + {"quay.io/fedora", false, false}, + {"docker.io/fedora", false, false}, + {"docker.io/library/fedora:latest", false, false}, + {"localhost/fedora", false, false}, + {"localhost:5000/fedora:latest", false, false}, + {"example.foo.this.may.be.garbage.but.maybe.not:1234/fedora:latest", false, false}, + {"docker.io/library/busybox@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", false, false}, + {"docker.io/library/busybox:latest@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", false, false}, + {"docker.io/fedora@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", false, false}, + // INVALID NAMES + {"", false, true}, + {"$$$", false, true}, + {"::", false, true}, + {"docker://quay.io/library/foo:bar", false, true}, + {" ", false, true}, + } + + for _, test := range tests { + res, _, err := parseUnnormalizedShortName(test.input) + if test.mustFail { + require.Error(t, err, "%q should not be parseable") + continue + } + require.NoError(t, err, "%q should be parseable") + assert.Equal(t, test.parseUnnormalizedShortName, res, "%q", test.input) + } +} + +func TestSplitUserInput(t *testing.T) { + tests := []struct { + input string + repo string + isTagged bool + isDigested bool + }{ + // Neither tags nor digests + {"fedora", "fedora", false, false}, + {"repo/fedora", "repo/fedora", false, false}, + {"registry.com/fedora", "registry.com/fedora", false, false}, + // Tags + {"fedora:tag", "fedora", true, false}, + {"repo/fedora:tag", "repo/fedora", true, false}, + {"registry.com/fedora:latest", "registry.com/fedora", true, false}, + // Digests + {"fedora@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "fedora", false, true}, + {"repo/fedora@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "repo/fedora", false, true}, + {"registry.com/fedora@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "registry.com/fedora", false, true}, + } + + for _, test := range tests { + _, ref, err := parseUnnormalizedShortName(test.input) + require.NoError(t, err, "%v", test) + + isTagged, isDigested, shortNameRepo, tag, digest := splitUserInput(ref) + require.NotNil(t, shortNameRepo) + normalized := shortNameRepo.String() + assert.Equal(t, test.repo, normalized) + assert.Equal(t, test.isTagged, isTagged) + assert.Equal(t, test.isDigested, isDigested) + if isTagged { + normalized = normalized + ":" + tag + } else if isDigested { + normalized = normalized + "@" + digest.String() + } + assert.Equal(t, test.input, normalized) + } +} + +func TestResolve(t *testing.T) { + tmp, err := ioutil.TempFile("", "aliases.conf") + require.NoError(t, err) + defer os.Remove(tmp.Name()) + + sys := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/aliases.conf", + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + UserShortNameAliasConfPath: tmp.Name(), + } + + _, err = sysregistriesv2.TryUpdatingCache(sys) + require.NoError(t, err) + + tests := []struct { + name, value string + }{ + {"docker", "docker.io/library/foo:latest"}, + {"docker:tag", "docker.io/library/foo:tag"}, + { + "docker@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "docker.io/library/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"quay/foo", "quay.io/library/foo:latest"}, + {"quay/foo:tag", "quay.io/library/foo:tag"}, + { + "quay/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "quay.io/library/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"example", "example.com/library/foo:latest"}, + {"example:tag", "example.com/library/foo:tag"}, + { + "example@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "example.com/library/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + } + + // All of them should resolve correctly. + for _, test := range tests { + resolved, err := Resolve(sys, test.name) + require.NoError(t, err, "%v", test) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, test.value, resolved.PullCandidates[0].Value.String()) + assert.False(t, resolved.PullCandidates[0].record) + } + + // Non-existent should return an empty slice as no search registries + // are configured in the config. + resolved, err := Resolve(sys, "dontexist") + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 0) + + // An empty name is not valid. + resolved, err = Resolve(sys, "") + require.Error(t, err) + require.Nil(t, resolved) + + // Invalid input. + resolved, err = Resolve(sys, "Invalid#$") + require.Error(t, err) + require.Nil(t, resolved) + + // Fully-qualified input will be returned as is. + resolved, err = Resolve(sys, "quay.io/repo/fedora") + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, "quay.io/repo/fedora:latest", resolved.PullCandidates[0].Value.String()) + assert.False(t, resolved.PullCandidates[0].record) +} + +func toNamed(t *testing.T, input string, trim bool) reference.Named { + ref, err := reference.Parse(input) + require.NoError(t, err) + named := ref.(reference.Named) + require.NotNil(t, named) + + if trim { + named = reference.TrimNamed(named) + } + + return named +} + +func addAlias(t *testing.T, sys *types.SystemContext, name string, value string, mustFail bool) { + namedValue := toNamed(t, value, false) + + if mustFail { + require.Error(t, Add(sys, name, namedValue)) + } else { + require.NoError(t, Add(sys, name, namedValue)) + } +} + +func removeAlias(t *testing.T, sys *types.SystemContext, name string, mustFail bool, trim bool) { + namedName := toNamed(t, name, trim) + + if mustFail { + require.Error(t, Remove(sys, namedName.String())) + } else { + require.NoError(t, Remove(sys, namedName.String())) + } +} + +func TestResolveWithDropInConfigs(t *testing.T) { + tmp, err := ioutil.TempFile("", "aliases.conf") + require.NoError(t, err) + defer os.Remove(tmp.Name()) + + sys := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/aliases.conf", + SystemRegistriesConfDirPath: "testdata/registries.conf.d", + UserShortNameAliasConfPath: tmp.Name(), + } + + _, err = sysregistriesv2.TryUpdatingCache(sys) + require.NoError(t, err) + + tests := []struct { + name, value string + }{ + {"docker", "docker.io/library/config1:latest"}, // overriden by config1 + {"docker:tag", "docker.io/library/config1:tag"}, + { + "docker@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "docker.io/library/config1@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"quay/foo", "quay.io/library/foo:latest"}, + {"quay/foo:tag", "quay.io/library/foo:tag"}, + { + "quay/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "quay.io/library/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"config1", "config1.com/image:latest"}, + {"config1:tag", "config1.com/image:tag"}, + { + "config1@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "config1.com/image@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"barz", "barz.com/config2:latest"}, // from config1, overridden by config2 + {"barz:tag", "barz.com/config2:tag"}, + { + "barz@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "barz.com/config2@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"added1", "aliases.conf/added1:latest"}, // from Add() + {"added1:tag", "aliases.conf/added1:tag"}, + { + "added1@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "aliases.conf/added1@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"added2", "aliases.conf/added2:latest"}, // from Add() + {"added2:tag", "aliases.conf/added2:tag"}, + { + "added2@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "aliases.conf/added2@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + {"added3", "aliases.conf/added3:latest"}, // from Add() + {"added3:tag", "aliases.conf/added3:tag"}, + { + "added3@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + "aliases.conf/added3@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", + }, + } + + addAlias(t, sys, "added1", "aliases.conf/added1", false) + addAlias(t, sys, "added2", "aliases.conf/added2", false) + addAlias(t, sys, "added3", "aliases.conf/added3", false) + + // Tags/digests are invalid! + addAlias(t, sys, "added3", "aliases.conf/added3:tag", true) + addAlias(t, sys, "added3:tag", "aliases.conf/added3", true) + addAlias(t, sys, "added3", "aliases.conf/added3@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", true) + addAlias(t, sys, "added3@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "aliases.conf/added3", true) + + // All of them should resolve correctly. + for _, test := range tests { + resolved, err := Resolve(sys, test.name) + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, test.value, resolved.PullCandidates[0].Value.String()) + assert.False(t, resolved.PullCandidates[0].record) + } + + // config1 sets one search registry. + resolved, err := Resolve(sys, "dontexist") + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, "example-overwrite.com/dontexist:latest", resolved.PullCandidates[0].Value.String()) + + // An empty name is not valid. + resolved, err = Resolve(sys, "") + require.Error(t, err) + require.Nil(t, resolved) + + // Invalid input. + resolved, err = Resolve(sys, "Invalid#$") + require.Error(t, err) + require.Nil(t, resolved) + + // Fully-qualified input will be returned as is. + resolved, err = Resolve(sys, "quay.io/repo/fedora") + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, "quay.io/repo/fedora:latest", resolved.PullCandidates[0].Value.String()) + assert.False(t, resolved.PullCandidates[0].record) + + resolved, err = Resolve(sys, "localhost/repo/fedora:sometag") + require.NoError(t, err) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, 1) + assert.Equal(t, "localhost/repo/fedora:sometag", resolved.PullCandidates[0].Value.String()) + assert.False(t, resolved.PullCandidates[0].record) + + // Now test removal. + + // Stored in aliases.conf, so we can remove it. + removeAlias(t, sys, "added1", false, false) + removeAlias(t, sys, "added2", false, false) + removeAlias(t, sys, "added3", false, false) + removeAlias(t, sys, "added2:tag", true, false) + removeAlias(t, sys, "added3@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", true, false) + + // Doesn't exist -> error. + removeAlias(t, sys, "added1", true, false) + removeAlias(t, sys, "added2", true, false) + removeAlias(t, sys, "added3", true, false) + + // Cannot remove entries from registries.conf files -> error. + removeAlias(t, sys, "docker", true, false) + removeAlias(t, sys, "docker", true, false) + removeAlias(t, sys, "docker", true, false) +} + +func TestResolveWithVaryingShortNameModes(t *testing.T) { + tmp, err := ioutil.TempFile("", "aliases.conf") + require.NoError(t, err) + defer os.Remove(tmp.Name()) + + tests := []struct { + confPath string + mode types.ShortNameMode + name string + mustFail bool + numAliases int + }{ + // Invalid -> error + {"testdata/no-reg.conf", types.ShortNameModeInvalid, "repo/image", true, 0}, + {"testdata/one-reg.conf", types.ShortNameModeInvalid, "repo/image", true, 0}, + {"testdata/two-reg.conf", types.ShortNameModeInvalid, "repo/image", true, 0}, + // Permisive + match -> return alias + {"testdata/no-reg.conf", types.ShortNameModePermissive, "repo/image", false, 1}, + {"testdata/one-reg.conf", types.ShortNameModePermissive, "repo/image", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModePermissive, "repo/image", false, 1}, + // Permisive + no match -> search (no tty) + {"testdata/no-reg.conf", types.ShortNameModePermissive, "donotexist", false, 0}, + {"testdata/one-reg.conf", types.ShortNameModePermissive, "donotexist", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModePermissive, "donotexist", false, 2}, + // Disabled + match -> return alias + {"testdata/no-reg.conf", types.ShortNameModeDisabled, "repo/image", false, 1}, + {"testdata/one-reg.conf", types.ShortNameModeDisabled, "repo/image", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModeDisabled, "repo/image", false, 1}, + // Disabled + no match -> search + {"testdata/no-reg.conf", types.ShortNameModeDisabled, "donotexist", false, 0}, + {"testdata/one-reg.conf", types.ShortNameModeDisabled, "donotexist", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModeDisabled, "donotexist", false, 2}, + // Enforcing + match -> return alias + {"testdata/no-reg.conf", types.ShortNameModeEnforcing, "repo/image", false, 1}, + {"testdata/one-reg.conf", types.ShortNameModeEnforcing, "repo/image", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModeEnforcing, "repo/image", false, 1}, + // Enforcing + no match -> error if search regs > 1 and no tty + {"testdata/no-reg.conf", types.ShortNameModeEnforcing, "donotexist", false, 0}, + {"testdata/one-reg.conf", types.ShortNameModeEnforcing, "donotexist", false, 1}, + {"testdata/two-reg.conf", types.ShortNameModeEnforcing, "donotexist", true, 0}, + } + + for _, test := range tests { + sys := &types.SystemContext{ + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + UserShortNameAliasConfPath: tmp.Name(), + // From test + SystemRegistriesConfPath: test.confPath, + ShortNameMode: &test.mode, + } + + _, err := sysregistriesv2.TryUpdatingCache(sys) + require.NoError(t, err) + + resolved, err := Resolve(sys, test.name) + if test.mustFail { + require.Error(t, err, "%v", test) + continue + } + require.NoError(t, err, "%v", test) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, test.numAliases, "%v", test) + } +} + +func TestResolveAndRecord(t *testing.T) { + tmp, err := ioutil.TempFile("", "aliases.conf") + require.NoError(t, err) + defer os.Remove(tmp.Name()) + + sys := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/two-reg.conf", + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + UserShortNameAliasConfPath: tmp.Name(), + } + + _, err = sysregistriesv2.TryUpdatingCache(sys) + require.NoError(t, err) + + tests := []struct { + name string + expected []string + }{ + // No alias -> USRs + {"foo", []string{"quay.io/foo:latest", "registry.com/foo:latest"}}, + {"foo:tag", []string{"quay.io/foo:tag", "registry.com/foo:tag"}}, + {"foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", []string{"quay.io/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "registry.com/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a"}}, + {"repo/foo", []string{"quay.io/repo/foo:latest", "registry.com/repo/foo:latest"}}, + {"repo/foo:tag", []string{"quay.io/repo/foo:tag", "registry.com/repo/foo:tag"}}, + {"repo/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", []string{"quay.io/repo/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", "registry.com/repo/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a"}}, + // Alias + {"repo/image", []string{"quay.io/repo/image:latest"}}, + {"repo/image:tag", []string{"quay.io/repo/image:tag"}}, + {"repo/image@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", []string{"quay.io/repo/image@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a"}}, + } + for _, test := range tests { + resolved, err := Resolve(sys, test.name) + require.NoError(t, err, "%v", test) + require.NotNil(t, resolved) + require.Len(t, resolved.PullCandidates, len(test.expected), "%v", test) + + for i, candidate := range resolved.PullCandidates { + require.Equal(t, test.expected[i], candidate.Value.String(), "%v", test) + + require.False(t, candidate.record, "%v", test) + candidate.record = true // make sure we can actually record + + // Record the alias, look it up another time and make + // sure there's only one match (i.e., the new alias) + // and that is has the expected value. + require.NoError(t, candidate.Record()) + + newResolved, err := Resolve(sys, test.name) + require.NoError(t, err, "%v", test) + require.Len(t, newResolved.PullCandidates, 1, "%v", test) + require.Equal(t, candidate.Value.String(), newResolved.PullCandidates[0].Value.String(), "%v", test) + + // Now remove the alias again. + removeAlias(t, sys, test.name, false, true) + + // Now set recording to false and try recording again. + candidate.record = false + require.NoError(t, candidate.Record()) + removeAlias(t, sys, test.name, true, true) // must error out now + } + } +} + +func TestResolveLocally(t *testing.T) { + tmp, err := ioutil.TempFile("", "aliases.conf") + require.NoError(t, err) + defer os.Remove(tmp.Name()) + + sys := &types.SystemContext{ + SystemRegistriesConfPath: "testdata/two-reg.conf", + SystemRegistriesConfDirPath: "testdata/this-does-not-exist", + UserShortNameAliasConfPath: tmp.Name(), + } + + aliases, err := ResolveLocally(sys, "repo/image") // alias match + require.NoError(t, err) + require.Len(t, aliases, 4) // alias + localhost + two regs + assert.Equal(t, "quay.io/repo/image:latest", aliases[0].String()) // alias + assert.Equal(t, "localhost/repo/image:latest", aliases[1].String()) // localhost + assert.Equal(t, "quay.io/repo/image:latest", aliases[2].String()) // registry 0 + assert.Equal(t, "registry.com/repo/image:latest", aliases[3].String()) // registry 0 + + aliases, err = ResolveLocally(sys, "foo") // no alias match + require.NoError(t, err) + require.Len(t, aliases, 3) // localhost + two regs + assert.Equal(t, "localhost/foo:latest", aliases[0].String()) // localhost + assert.Equal(t, "quay.io/foo:latest", aliases[1].String()) // registry 0 + assert.Equal(t, "registry.com/foo:latest", aliases[2].String()) // registry 0 + + aliases, err = ResolveLocally(sys, "foo:tag") // no alias match tagged + require.NoError(t, err) + require.Len(t, aliases, 3) // localhost + two regs + assert.Equal(t, "localhost/foo:tag", aliases[0].String()) // localhost + assert.Equal(t, "quay.io/foo:tag", aliases[1].String()) // registry 0 + assert.Equal(t, "registry.com/foo:tag", aliases[2].String()) // registry 0 + + aliases, err = ResolveLocally(sys, "foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a") // no alias match digested + require.NoError(t, err) + require.Len(t, aliases, 3) // localhost + two regs + assert.Equal(t, "localhost/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", aliases[0].String()) // localhost + assert.Equal(t, "quay.io/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", aliases[1].String()) // registry 0 + assert.Equal(t, "registry.com/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", aliases[2].String()) // registry 0 + + aliases, err = ResolveLocally(sys, "localhost/foo") // localhost + require.NoError(t, err) + require.Len(t, aliases, 1) + assert.Equal(t, "localhost/foo:latest", aliases[0].String()) + + aliases, err = ResolveLocally(sys, "localhost/foo:tag") // localhost + tag + require.NoError(t, err) + require.Len(t, aliases, 1) + assert.Equal(t, "localhost/foo:tag", aliases[0].String()) + + aliases, err = ResolveLocally(sys, "localhost/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a") // localhost + digest + require.NoError(t, err) + require.Len(t, aliases, 1) + assert.Equal(t, "localhost/foo@sha256:d366a4665ab44f0648d7a00ae3fae139d55e32f9712c67accd604bb55df9d05a", aliases[0].String()) +} diff --git a/pkg/shortnames/testdata/aliases.conf b/pkg/shortnames/testdata/aliases.conf new file mode 100644 index 000000000..cb05b275a --- /dev/null +++ b/pkg/shortnames/testdata/aliases.conf @@ -0,0 +1,7 @@ +short-name-mode="enforcing" + +[aliases] +docker="docker.io/library/foo" +"quay/foo"="quay.io/library/foo" +example="example.com/library/foo" +empty="" diff --git a/pkg/shortnames/testdata/no-reg.conf b/pkg/shortnames/testdata/no-reg.conf new file mode 100644 index 000000000..968f7ecbf --- /dev/null +++ b/pkg/shortnames/testdata/no-reg.conf @@ -0,0 +1,2 @@ +[aliases] +"repo/image"="quay.io/repo/image" diff --git a/pkg/shortnames/testdata/one-reg.conf b/pkg/shortnames/testdata/one-reg.conf new file mode 100644 index 000000000..e9dfab2de --- /dev/null +++ b/pkg/shortnames/testdata/one-reg.conf @@ -0,0 +1,4 @@ +unqualified-search-registries=["quay.io"] + +[aliases] +"repo/image"="quay.io/repo/image" diff --git a/pkg/shortnames/testdata/registries.conf.d/config-1.conf b/pkg/shortnames/testdata/registries.conf.d/config-1.conf new file mode 100644 index 000000000..f02e618a0 --- /dev/null +++ b/pkg/shortnames/testdata/registries.conf.d/config-1.conf @@ -0,0 +1,9 @@ +unqualified-search-registries = ["example-overwrite.com"] + +[[registry]] +location = "1.com" + +[aliases] +docker="docker.io/library/config1" +config1="config1.com/image" +barz="barz.com/image/config1" diff --git a/pkg/shortnames/testdata/registries.conf.d/config-2.conf b/pkg/shortnames/testdata/registries.conf.d/config-2.conf new file mode 100644 index 000000000..7ec82c75f --- /dev/null +++ b/pkg/shortnames/testdata/registries.conf.d/config-2.conf @@ -0,0 +1,14 @@ +short-name-mode="permissive" + +[[registry]] +location = "2.com" + +[[registry]] +location = "base.com" +blocked = true + +[aliases] +config2="config2.com/image" +barz="barz.com/config2" +added3="xxx.com/image" +example="" diff --git a/pkg/shortnames/testdata/registries.conf.d/config-3.conf b/pkg/shortnames/testdata/registries.conf.d/config-3.conf new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/shortnames/testdata/registries.conf.d/config-3.ignore b/pkg/shortnames/testdata/registries.conf.d/config-3.ignore new file mode 100644 index 000000000..65866fd78 --- /dev/null +++ b/pkg/shortnames/testdata/registries.conf.d/config-3.ignore @@ -0,0 +1,7 @@ +unqualified-search-registries = ["ignore-example-overwrite.com"] + +[[registry]] +location = "ignore-me-because-i-have-a-wrong-suffix.com" + +[aliases] +ignore="me because i have a wrong suffix" diff --git a/pkg/shortnames/testdata/two-reg.conf b/pkg/shortnames/testdata/two-reg.conf new file mode 100644 index 000000000..2ed782965 --- /dev/null +++ b/pkg/shortnames/testdata/two-reg.conf @@ -0,0 +1,4 @@ +unqualified-search-registries=["quay.io", "registry.com"] + +[aliases] +"repo/image"="quay.io/repo/image" diff --git a/pkg/sysregistriesv2/system_registries_v2.go b/pkg/sysregistriesv2/system_registries_v2.go index bfff7ec4e..89ad7c533 100644 --- a/pkg/sysregistriesv2/system_registries_v2.go +++ b/pkg/sysregistriesv2/system_registries_v2.go @@ -647,6 +647,9 @@ func parseShortNameMode(mode string) (types.ShortNameMode, error) { // GetShortNameMode returns the configured types.ShortNameMode. func GetShortNameMode(ctx *types.SystemContext) (types.ShortNameMode, error) { + if ctx != nil && ctx.ShortNameMode != nil { + return *ctx.ShortNameMode, nil + } config, err := getConfig(ctx) if err != nil { return -1, err diff --git a/types/types.go b/types/types.go index 3972175d1..3c5126b4e 100644 --- a/types/types.go +++ b/types/types.go @@ -500,13 +500,19 @@ const ( // Use all configured unqualified-search registries without prompting // the user. ShortNameModeDisabled - // If stdout is a TTY, prompt the user to select a configured + // If stdout and stdin are a TTY, prompt the user to select a configured // unqualified-search registry. Otherwise, use all configured // unqualified-search registries. + // + // Note that if only one unqualified-search registry is set, it will be + // used without prompting. ShortNameModePermissive - // Always prompt the user to select a configured unqualified-serach - // registry. Throw an error if stdout is not a TTY as prompting - // isn't possible. + // Always prompt the user to select a configured unqualified-search + // registry. Throw an error if stdout or stdin is not a TTY as + // prompting isn't possible. + // + // Note that if only one unqualified-search registry is set, it will be + // used without prompting. ShortNameModeEnforcing ) @@ -535,6 +541,8 @@ type SystemContext struct { SystemRegistriesConfDirPath string // Path to the user-specific short-names configuration file UserShortNameAliasConfPath string + // If set, short-name resolution in pkg/shortnames must follow the specified mode + ShortNameMode *ShortNameMode // If not "", overrides the default path for the authentication file, but only new format files AuthFilePath string // if not "", overrides the default path for the authentication file, but with the legacy format;