From 4efad6e930e61cfb6677b381649782f4f106df9b Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Thu, 20 Aug 2026 14:41:18 +0300 Subject: [PATCH] fix(images): Accept http+oci image references An http(s)+oci image is fetched by the platform, so the CLI only carries the URI through, verbatim. Parsing moves to image-spec's imageref. GitHub-Fixes: TOOL-1093 Signed-off-by: Cezar Craciunoiu --- .../integration/instance_http_oci_test.go | 61 ++ cmd/unikraft/testdata/TestHelp/instances | 16 +- cmd/unikraft/testdata/TestHelp/run | 4 +- internal/builder/kernel.go | 3 +- internal/cmd/build.go | 3 +- internal/cmd/images.go | 186 +++--- internal/cmd/images_internal_test.go | 168 +++++ internal/cmd/instance_checkpoints.go | 5 +- internal/cmd/instance_templates.go | 5 +- internal/cmd/instances.go | 15 +- internal/cmd/output_test.go | 19 + .../testdata/TestOutput/instances-http-oci | 621 ++++++++++++++++++ internal/images/images.go | 113 +++- internal/images/images_test.go | 192 ++++++ internal/types/image.go | 86 ++- internal/types/image_test.go | 141 ++++ internal/x/reference/match.go | 44 -- internal/x/reference/reference.go | 172 ----- 18 files changed, 1477 insertions(+), 377 deletions(-) create mode 100644 cmd/unikraft/integration/instance_http_oci_test.go create mode 100644 internal/cmd/images_internal_test.go create mode 100644 internal/cmd/testdata/TestOutput/instances-http-oci create mode 100644 internal/images/images_test.go create mode 100644 internal/types/image_test.go delete mode 100644 internal/x/reference/match.go delete mode 100644 internal/x/reference/reference.go diff --git a/cmd/unikraft/integration/instance_http_oci_test.go b/cmd/unikraft/integration/instance_http_oci_test.go new file mode 100644 index 00000000..25d4a6c3 --- /dev/null +++ b/cmd/unikraft/integration/instance_http_oci_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + integ "unikraft.com/cli/internal/integration" +) + +// testDigest is an arbitrary but well-formed digest, for the cases that need a +// digest nothing is ever fetched for. +const testDigest = "sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + +func TestInstancesHTTPOCI(t *testing.T) { + // An http+oci image must reach the API with its scheme intact, since the + // node is what fetches it. --dry-run stops before any request is made, + // so this needs no metro. + t.Run("dry-run", func(t *testing.T) { + r := runner(t, false, []string{staging, stable, prod}) + + for _, image := range []string{ + "http+oci://cdn.example.com/me/app/latest", + "https+oci://cdn.example.com/me/app/@" + testDigest, + } { + // The --image shortcut and the generic field are separate paths into + // the same value, so both have to carry the URI. + for _, args := range [][]string{ + {"--set", "metro=fra", "--set", "name=test-" + uniq(), "--set", "image=" + image}, + {"--metro", "fra", "--name", "test-" + uniq(), "--image", image}, + } { + out := r.Run(t, append([]string{ + "unikraft", "instance", "create", "--dry-run", + }, args...)) + assert.Contains(t, out, image, + "the image URI must reach the API verbatim (%v)", args) + } + } + }) + + // An unusable scheme has to say so. + t.Run("reports an unusable scheme", func(t *testing.T) { + r := runner(t, false, []string{staging, stable, prod}) + + out := r.Run(t, []string{ + "unikraft", "instance", "create", "--dry-run", + "--metro", "fra", + "--name", "test-" + uniq(), + "--image", "oci-archive:///tmp/image.tar", + }, integ.ExpectFail()) + + assert.Regexp(t, `addresses a local image`, out) + assert.NotRegexp(t, `invalid reference format`, out, + "a scheme this CLI cannot address is not a malformed reference") + }) +} diff --git a/cmd/unikraft/testdata/TestHelp/instances b/cmd/unikraft/testdata/TestHelp/instances index 49a94cce..5dbf1300 100644 --- a/cmd/unikraft/testdata/TestHelp/instances +++ b/cmd/unikraft/testdata/TestHelp/instances @@ -443,9 +443,9 @@ Create flags: [examples: fra, sfo, nyc] -n, --name= Instance name. - --image=: + --image= Image to deploy. - [examples: nginx:latest, my-app:v1.2.3] + [examples: nginx:latest, my-app:v1.2.3, https+oci://cdn.example.com/me/app/latest] --pull-policy= Image pull policy. [examples: always, never, if_not_present] @@ -613,9 +613,9 @@ Create flags: [examples: fra, sfo, nyc] -n, --name= Instance name. - --image=: + --image= Image to deploy. - [examples: nginx:latest, my-app:v1.2.3] + [examples: nginx:latest, my-app:v1.2.3, https+oci://cdn.example.com/me/app/latest] --pull-policy= Image pull policy. [examples: always, never, if_not_present] @@ -804,9 +804,9 @@ Create flags: [examples: fra, sfo, nyc] -n, --name= Instance name. - --image=: + --image= Image to deploy. - [examples: nginx:latest, my-app:v1.2.3] + [examples: nginx:latest, my-app:v1.2.3, https+oci://cdn.example.com/me/app/latest] --pull-policy= Image pull policy. [examples: always, never, if_not_present] @@ -982,9 +982,9 @@ Global flags: Set a deadline for the command (e.g. 30s, 5m, 1h). Edit flags: - --image=: + --image= Image to deploy. - [examples: nginx:latest, my-app:v1.2.3] + [examples: nginx:latest, my-app:v1.2.3, https+oci://cdn.example.com/me/app/latest] --args= Arguments to pass to the instance. -e, --env== diff --git a/cmd/unikraft/testdata/TestHelp/run b/cmd/unikraft/testdata/TestHelp/run index 81f31b84..69688bdb 100644 --- a/cmd/unikraft/testdata/TestHelp/run +++ b/cmd/unikraft/testdata/TestHelp/run @@ -119,9 +119,9 @@ Create flags: [examples: fra, sfo, nyc] -n, --name= Instance name. - --image=: + --image= Image to deploy. - [examples: nginx:latest, my-app:v1.2.3] + [examples: nginx:latest, my-app:v1.2.3, https+oci://cdn.example.com/me/app/latest] --pull-policy= Image pull policy. [examples: always, never, if_not_present] diff --git a/internal/builder/kernel.go b/internal/builder/kernel.go index 10e7c1ca..e14c5dee 100644 --- a/internal/builder/kernel.go +++ b/internal/builder/kernel.go @@ -12,6 +12,7 @@ import ( "github.com/containerd/platforms" ocispec "github.com/opencontainers/image-spec/specs-go/v1" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/imageref" "unikraft.com/cli/internal/images" ) @@ -26,7 +27,7 @@ func BuildKernel(ctx context.Context, opts BuildOpts) ([]*imagespec.Image, error if err != nil { return nil, fmt.Errorf("parsing runtime reference: %w", err) } - if runtime.Scheme != imagespec.URISchemeOCI { + if runtime.Scheme != imageref.SchemeOCI { return nil, fmt.Errorf("unsupported runtime reference scheme: %s", runtime.Scheme) } diff --git a/internal/cmd/build.go b/internal/cmd/build.go index 26220f57..17fa0a9d 100644 --- a/internal/cmd/build.go +++ b/internal/cmd/build.go @@ -17,6 +17,7 @@ import ( "unikraft.com/cli/internal/images" "unikraft.com/cli/internal/resource" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/imageref" "unikraft.com/x/kingkong" "unikraft.com/x/kraftfile" "unikraft.com/x/log" @@ -166,7 +167,7 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, sandbox *re return err } - if sandbox != nil && output.Scheme == imagespec.URISchemeOCI { + if sandbox != nil && output.Scheme == imageref.SchemeOCI { if err := addImageToSandbox(ctx, sandbox, output.Path); err != nil { return fmt.Errorf("adding built image to sandbox: %w", err) } diff --git a/internal/cmd/images.go b/internal/cmd/images.go index f692f8fa..db5e6da4 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -15,7 +15,7 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/platforms" - "github.com/distribution/reference" + ociref "github.com/distribution/reference" "github.com/opencontainers/go-digest" "unikraft.com/cloud/sdk/controlplane" "unikraft.com/cloud/sdk/platform" @@ -25,6 +25,7 @@ import ( "unikraft.com/x/log" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/imageref" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/images" @@ -32,7 +33,6 @@ import ( "unikraft.com/cli/internal/resource" "unikraft.com/cli/internal/resource/cmd" "unikraft.com/cli/internal/types" - xreference "unikraft.com/cli/internal/x/reference" ) type ImagesCmd struct { @@ -46,8 +46,8 @@ type ImagesCmd struct { } type Image struct { - Ref types.ImageRef[reference.Named] `field:",short"` - Digest digest.Digest `field:",long"` + Ref types.ImageRef `field:",short"` + Digest digest.Digest `field:",long"` Config ImageConfig `field:",embed"` Metadata ImageMetadata `field:",long,embed"` @@ -89,7 +89,7 @@ func (Image) Type() resource.Type { } func (i Image) Key() resource.Key { - return staticKey(i.Ref.Reference.String()) + return staticKey(i.Ref.WireURL()) } func (i Image) Raw() any { @@ -169,9 +169,7 @@ func (Image) Get(ctx context.Context, keys []string) ([]resource.Resource, error meta := img.Metadata() resource := Image{ - Ref: types.ImageRef[reference.Named]{ - Reference: img.Name, - }, + Ref: types.NewImageRef(img.Name), Digest: img.Descriptor.Digest, Config: ImageConfig{ Cmd: config.Config.Cmd, @@ -251,12 +249,12 @@ func (Image) Examples() map[cmd.CmdType][]kingkong.Example { } type ImageEntry struct { - Ref types.ImageRef[reference.Named] `field:",short"` - Digest digest.Digest `field:",short"` + Ref types.ImageRef `field:",short"` + Digest digest.Digest `field:",short"` Namespace string - Canonical reference.Canonical `field:"-"` + Canonical ociref.Canonical `field:"-"` controlplaneImage *controlplane.Image platformImage *platform.Image @@ -270,7 +268,7 @@ func (ImageEntry) Type() resource.Type { } func (i ImageEntry) Key() resource.Key { - return staticKey(i.Ref.Reference.String()) + return staticKey(i.Ref.WireURL()) } func (i ImageEntry) Raw() any { @@ -310,17 +308,29 @@ func (ImageEntry) List(ctx context.Context) ([]resource.Resource, error) { if err != nil { return err } - if resp.Data != nil { - for _, image := range resp.Data.Images { - entries, err := ImageEntry{}.loadFromControlplane(image) - if err != nil { - return err - } - for _, entry := range entries { - controlplaneResults = append(controlplaneResults, entry) - } + if resp.Data == nil { + return nil + } + + var errs []error + for _, image := range resp.Data.Images { + entries, err := ImageEntry{}.loadFromControlplane(image) + if err != nil { + errs = append(errs, err) + continue + } + for _, entry := range entries { + controlplaneResults = append(controlplaneResults, entry) } } + // An image the CLI cannot interpret is warned about and skipped rather + // than taking the whole listing down with it. + if len(errs) > 0 && len(errs) == len(resp.Data.Images) { + return errors.Join(errs...) + } + for _, err := range errs { + log.G(ctx).Warn().Err(err).Msg("skipping image") + } return nil }) eg.Go(func() error { @@ -332,13 +342,14 @@ func (ImageEntry) List(ctx context.Context) ([]resource.Resource, error) { return nil, err } + // Deduplicate on the wire identity rather than the reference. seen := make(map[string]struct{}, len(controlplaneResults)) for _, r := range controlplaneResults { - seen[r.(ImageEntry).Ref.Reference.String()] = struct{}{} + seen[r.(ImageEntry).Ref.WireURL()] = struct{}{} } results := controlplaneResults for _, r := range platformResults { - ref := r.(ImageEntry).Ref.Reference.String() + ref := r.(ImageEntry).Ref.WireURL() if _, ok := seen[ref]; ok { continue } @@ -391,19 +402,42 @@ func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { }) } +// imageKey addresses an image being looked up. +type imageKey struct { + ref imageref.Reference +} + +// String returns the identifier the caller addressed the image by. +func (k imageKey) String() string { + return k.ref.String() +} + +// matches reports whether entry is the image k addresses. +func (k imageKey) matches(entry ImageEntry) bool { + subject := entry.Ref.Reference() + // A key naming a digest only matches the digest form, so prefer it where + // there is one. + if entry.Canonical != nil && !subject.Scheme().IsHTTP() { + if canonical, err := imageref.FromNamed(entry.Canonical); err == nil { + subject = canonical + } + } + return subject.Matches(k.ref) +} + func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, error) { client, err := multimetro.NewControlClient(ctx) if err != nil { return nil, err } - normalizedKeys := make([]string, 0, len(keys)) + normalizedKeys := make([]imageKey, 0, len(keys)) for _, key := range keys { - named, err := images.ParseNormalizedNamed(key) + parsed, err := images.ParseRef(key) if err != nil { return nil, fmt.Errorf("could not parse image key %q: %w", key, err) } - normalizedKeys = append(normalizedKeys, named.String()) + normalizedKeys = append(normalizedKeys, imageKey{ref: parsed}) } log.G(ctx).Trace().Msg("getting images") @@ -423,16 +457,12 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, continue } for _, key := range normalizedKeys { - if _, ok := found[key]; ok { + if _, ok := found[key.String()]; ok { continue } for _, entry := range entries { - matchRef := reference.Named(entry.Ref.Reference) - if entry.Canonical != nil { - matchRef = entry.Canonical - } - if xreference.MatchNamed(matchRef, key) { - found[key] = struct{}{} + if key.matches(entry) { + found[key.String()] = struct{}{} results = append(results, entry) break } @@ -448,15 +478,11 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, for _, r := range platformResults { entry := r.(ImageEntry) for _, key := range normalizedKeys { - if _, ok := found[key]; ok { + if _, ok := found[key.String()]; ok { continue } - matchRef := reference.Named(entry.Ref.Reference) - if entry.Canonical != nil { - matchRef = entry.Canonical - } - if xreference.MatchNamed(matchRef, key) { - found[key] = struct{}{} + if key.matches(entry) { + found[key.String()] = struct{}{} results = append(results, r) break } @@ -465,10 +491,10 @@ func (ImageEntry) Get(ctx context.Context, keys []string) ([]resource.Resource, missing := make(group.Refs, 0, len(normalizedKeys)) for _, key := range normalizedKeys { - if _, ok := found[key]; ok { + if _, ok := found[key.String()]; ok { continue } - missing = append(missing, group.Ref{Name: key}) + missing = append(missing, group.Ref{Name: key.String()}) } var missingErr error if len(missing) > 0 { @@ -482,21 +508,30 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, if name == "" { return nil, fmt.Errorf("image has no name") } - base, err := images.ParseNormalizedNamed(name) + parsed, err := images.ParseRef(name) if err != nil { return nil, fmt.Errorf("could not parse image name %q: %w", name, err) } + if parsed.Scheme().IsHTTP() { + // A layout served over HTTP is addressed by its URI alone. + return []ImageEntry{{ + controlplaneImage: &image, + Digest: parsed.Digest(), + Ref: types.NewImageRefFrom(parsed), + }}, nil + } + base := parsed.Named() var baseDigest digest.Digest - if d, ok := base.(reference.Digested); ok { + if d, ok := base.(ociref.Digested); ok { baseDigest = d.Digest() } - base = reference.TrimNamed(base) + base = ociref.TrimNamed(base) if len(image.Tags) == 0 { return nil, nil } - tagged := make([]reference.NamedTagged, 0, len(image.Tags)) + tagged := make([]ociref.NamedTagged, 0, len(image.Tags)) tagDigests := make(map[string]digest.Digest, len(image.Tags)) for _, tag := range image.Tags { tagName := strings.TrimSpace(tag.Name) @@ -504,18 +539,18 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, continue } - var taggedRef reference.NamedTagged + var taggedRef ociref.NamedTagged if strings.Contains(tagName, "/") || strings.Contains(tagName, ":") { - parsed, err := images.ParseNormalizedNamed(tagName) + named, err := images.ParseName(tagName) if err == nil { - parsed = reference.TagNameOnly(parsed) - if parsedTagged, ok := parsed.(reference.NamedTagged); ok { + named = ociref.TagNameOnly(named) + if parsedTagged, ok := named.(ociref.NamedTagged); ok { taggedRef = parsedTagged } } } if taggedRef == nil { - ref, err := reference.WithTag(base, tagName) + ref, err := ociref.WithTag(base, tagName) if err != nil { return nil, fmt.Errorf("could not parse image tag %q: %w", tagName, err) } @@ -538,7 +573,7 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, } // Move latest to front if present. - if idx := slices.IndexFunc(tagged, func(t reference.NamedTagged) bool { + if idx := slices.IndexFunc(tagged, func(t ociref.NamedTagged) bool { return t.Tag() == "latest" }); idx > 0 { latest := tagged[idx] @@ -561,14 +596,14 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, Digest: tagDigest, } if tagDigest != "" { - canonical, err := reference.WithDigest(tag, tagDigest) + canonical, err := ociref.WithDigest(tag, tagDigest) if err != nil { return nil, fmt.Errorf("could not create image canonical reference: %w", err) } result.Canonical = canonical } - result.Ref.Reference = tag - if ns, _, ok := strings.Cut(reference.Path(tag), "/"); ok { + result.Ref = types.NewImageRef(tag) + if ns, _, ok := strings.Cut(ociref.Path(tag), "/"); ok { result.Namespace = ns } results = append(results, result) @@ -581,22 +616,27 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ if url == "" { return nil, fmt.Errorf("platform image has no url") } - parsed, err := images.ParseNormalizedNamedMetro(metro, url) + parsedRef, err := images.ParseRefMetro(metro, url) if err != nil { return nil, fmt.Errorf("could not parse platform image url %q: %w", url, err) } + baseDigest := parsedRef.Digest() - var baseDigest digest.Digest - if d, ok := parsed.(reference.Digested); ok { - baseDigest = d.Digest() + if parsedRef.Scheme().IsHTTP() { + // A layout served over HTTP is addressed by its URI alone. + return []ImageEntry{{ + platformImage: &image, + Digest: baseDigest, + Ref: types.NewImageRefFrom(parsedRef), + }}, nil } - base, err := reference.ParseNamed(metro.Index().Host + "/" + reference.Path(parsed)) - if err != nil { - return nil, fmt.Errorf("could not construct platform image ref: %w", err) - } + // ParseRefMetro has already supplied the metro's index as the default domain + // for a name that carried none, and canonicalized that index onto the + // default registry so this listing agrees with the controlplane one. + base := ociref.TrimNamed(parsedRef.Named()) - var tagged []reference.NamedTagged + var tagged []ociref.NamedTagged for _, tag := range image.Tags { if strings.HasPrefix(tag, "sha256:") { // Digest entry, not a tag. @@ -617,7 +657,7 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ if tagVal == "" { continue } - ref, err := reference.WithTag(base, tagVal) + ref, err := ociref.WithTag(base, tagVal) if err != nil { return nil, fmt.Errorf("could not parse platform image tag %q: %w", tag, err) } @@ -625,7 +665,7 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ } // Move latest to front if present. - if idx := slices.IndexFunc(tagged, func(t reference.NamedTagged) bool { + if idx := slices.IndexFunc(tagged, func(t ociref.NamedTagged) bool { return t.Tag() == "latest" }); idx > 0 { latest := tagged[idx] @@ -644,14 +684,14 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ Digest: baseDigest, } if baseDigest != "" { - canonical, err := reference.WithDigest(tag, baseDigest) + canonical, err := ociref.WithDigest(tag, baseDigest) if err != nil { return nil, fmt.Errorf("could not create image canonical reference: %w", err) } result.Canonical = canonical } - result.Ref.Reference = tag - if ns, _, ok := strings.Cut(reference.Path(tag), "/"); ok { + result.Ref = types.NewImageRef(tag) + if ns, _, ok := strings.Cut(ociref.Path(tag), "/"); ok { result.Namespace = ns } results = append(results, result) @@ -767,7 +807,7 @@ func (cmd ImagesCopyCmd) Run(ctx context.Context, sandbox *resource.Sandbox) err return fmt.Errorf("saving image to destination: %w", err) } - if sandbox != nil && dest.Scheme == imagespec.URISchemeOCI { + if sandbox != nil && dest.Scheme == imageref.SchemeOCI { if err := addImageToSandbox(ctx, sandbox, dest.Path); err != nil { return fmt.Errorf("adding copied image to sandbox: %w", err) } @@ -789,14 +829,12 @@ func (c *ImagesListCmd) Run(ctx context.Context, stdio config.Stdio, sandbox *re // addImageToSandbox registers an image reference with the sandbox so it gets // cleaned up during teardown. func addImageToSandbox(ctx context.Context, sandbox *resource.Sandbox, ref string) error { - named, err := images.ParseNormalizedNamed(ref) + named, err := images.ParseName(ref) if err != nil { return fmt.Errorf("parsing image reference %q: %w", ref, err) } img := &Image{ - Ref: types.ImageRef[reference.Named]{ - Reference: named, - }, + Ref: types.NewImageRef(named), } return sandbox.Add(ctx, img) } diff --git a/internal/cmd/images_internal_test.go b/internal/cmd/images_internal_test.go new file mode 100644 index 00000000..6e7d7143 --- /dev/null +++ b/internal/cmd/images_internal_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + "unikraft.com/cloud/sdk/controlplane" + "unikraft.com/cloud/sdk/platform" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/images" + "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/types" +) + +func imageEntry(t *testing.T, key string) ImageEntry { + t.Helper() + var ref types.ImageRef + require.NoError(t, ref.UnmarshalText([]byte(key))) + return ImageEntry{Ref: ref} +} + +func imageLookupKey(t *testing.T, key string) imageKey { + t.Helper() + var ref types.ImageRef + require.NoError(t, ref.UnmarshalText([]byte(key))) + return imageKey{ref: ref.Reference()} +} + +// Two URIs differing only in transport or host decompose to the same reference, +// so matching on the reference would return an image served from somewhere else. +func TestImageKeyMatches(t *testing.T) { + tests := []struct { + name string + key string + entry string + want bool + }{ + { + name: "same URI", + key: "http+oci://cdn.example.com/me/app/latest", + entry: "http+oci://cdn.example.com/me/app/latest", + want: true, + }, + { + name: "transport differs", + key: "http+oci://cdn.example.com/me/app/latest", + entry: "https+oci://cdn.example.com/me/app/latest", + want: false, + }, + { + name: "host differs", + key: "http+oci://cdn.example.com/me/app/latest", + entry: "http+oci://other.example.com/me/app/latest", + want: false, + }, + { + // The reference a URI decomposes to does not address it. + name: "reference does not match a URI entry", + key: "cdn.example.com/me/app:latest", + entry: "http+oci://cdn.example.com/me/app/latest", + want: false, + }, + { + name: "URI does not match a registry entry", + key: "http+oci://cdn.example.com/me/app/latest", + entry: "cdn.example.com/me/app:latest", + want: false, + }, + // Registry references keep matching as before, including the familiar + // short forms. + { + name: "registry reference", + key: "unikraft.io/official/nginx:latest", + entry: "unikraft.io/official/nginx:latest", + want: true, + }, + { + name: "familiar short form", + key: "nginx", + entry: "unikraft.io/official/nginx:latest", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := imageLookupKey(t, tt.key) + require.Equal(t, tt.want, key.matches(imageEntry(t, tt.entry))) + }) + } +} + +// A key reports itself the way the caller addressed it, so a lookup that finds +// nothing names the URI rather than the reference it decomposes to. +func TestImageKeyString(t *testing.T) { + require.Equal(t, + "http+oci://cdn.example.com/me/app/latest", + imageLookupKey(t, "http+oci://cdn.example.com/me/app/latest").String()) + require.Equal(t, + "unikraft.io/official/nginx:latest", + imageLookupKey(t, "nginx").String()) +} + +// A controlplane image the image store cannot address as a registry reference +// must not abort the listing. +func TestLoadFromControlplaneDoesNotFailOnHTTPImage(t *testing.T) { + const uri = "http+oci://cdn.example.com/me/app/latest" + + entries, err := ImageEntry{}.loadFromControlplane(controlplane.Image{ + Name: uri, + Tags: []controlplane.ImageTag{{Name: "latest"}}, + }) + require.NoError(t, err) + require.Len(t, entries, 1) + + // It is addressed by URI, not by the name it decomposes to: that name says + // nothing about which host serves it. + require.Equal(t, uri, entries[0].Ref.WireURL()) +} + +// Whatever a listing displays has to be something the sibling get command can +// look up. +func TestLoadFromPlatformNamesImagesAddressably(t *testing.T) { + metro := config.Metro{Name: "fra0", Endpoint: "https://api.fra0.unikraft.cloud"} + + for _, tt := range []struct{ url, short, wire string }{ + // A registry that is genuinely somewhere else keeps its name. + {"myregistry.io/nginx", "myregistry.io/nginx", "myregistry.io/nginx:latest"}, + {"docker.io/library/nginx", "docker.io/library/nginx", "docker.io/library/nginx:latest"}, + + // The metro's own index is the default registry under another name, so + // it is canonicalized and shows in the short form a human types. + {"index.fra0.unikraft.cloud/official/nginx", "nginx", "unikraft.io/official/nginx:latest"}, + {"nginx", "nginx", "unikraft.io/official/nginx:latest"}, + + // A layout served over HTTP is addressed by its URI throughout. + { + "http+oci://cdn.example.com/me/app/latest", + "http+oci://cdn.example.com/me/app/latest", + "http+oci://cdn.example.com/me/app/latest", + }, + } { + t.Run(tt.url, func(t *testing.T) { + entries, err := ImageEntry{}.loadFromPlatform( + platform.Image{Url: tt.url, Tags: []string{"latest"}}, &metro) + require.NoError(t, err) + require.NotEmpty(t, entries) + entry := entries[0] + + short, err := entry.Ref.Render(value.RenderOpts{Short: true}) + require.NoError(t, err) + require.Equal(t, tt.short, short) + require.Equal(t, tt.wire, entry.Ref.WireURL()) + + // The displayed name has to address the image it was displayed for. + ref, err := images.ParseRef(short) + require.NoError(t, err, "the displayed name %q does not even parse", short) + require.True(t, imageKey{ref: ref}.matches(entry), + "listing shows %q, which image get cannot find", short) + }) + } +} diff --git a/internal/cmd/instance_checkpoints.go b/internal/cmd/instance_checkpoints.go index 0dd51402..f4411b21 100644 --- a/internal/cmd/instance_checkpoints.go +++ b/internal/cmd/instance_checkpoints.go @@ -13,7 +13,6 @@ import ( "io" "github.com/alecthomas/kong" - "github.com/distribution/reference" "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" @@ -83,8 +82,8 @@ type InstanceCheckpoint struct { Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del"` DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,hidden" edit:"set"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef[reference.Named] `mirror:"instance.image" field:",short"` + State types.InstanceState `mirror:"instance.state" field:",short"` + Image types.ImageRef `mirror:"instance.image" field:",short"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short"` diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index 7871a560..09e90494 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -12,7 +12,6 @@ import ( "fmt" "github.com/alecthomas/kong" - "github.com/distribution/reference" "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" @@ -80,8 +79,8 @@ type InstanceTemplate struct { Tags []string `mirror:"instance.tags" field:",long" edit:"set,add,del"` DeleteLock bool `mirror:"instance.delete_lock" field:"delete-lock,long" edit:"set"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef[reference.Named] `mirror:"instance.image" field:",short"` + State types.InstanceState `mirror:"instance.state" field:",short"` + Image types.ImageRef `mirror:"instance.image" field:",short"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short"` diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 5c1bb0c4..2f183824 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -23,7 +23,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/alecthomas/kong" - "github.com/distribution/reference" "github.com/go-json-experiment/json/jsontext" "mvdan.cc/sh/v3/shell" "unikraft.com/cloud/sdk/platform" @@ -78,7 +77,7 @@ type InstanceCreateCmd struct { Metro string `group:"flag-create" shortcut:"metro" help:"Metro to deploy in." placeholder:"metro" example:"fra,sfo,nyc"` Name string `group:"flag-create" shortcut:"name" short:"n" help:"Instance name." placeholder:"name"` - Image string `group:"flag-create" shortcut:"image" help:"Image to deploy." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` + Image string `group:"flag-create" shortcut:"image" help:"Image to deploy." placeholder:"" example:"nginx:latest,my-app:v1.2.3,https+oci://cdn.example.com/me/app/latest"` PullPolicy *platform.PullPolicy `group:"flag-create" shortcut:"pull-policy" help:"Image pull policy." placeholder:"policy" example:"always,never,if_not_present"` Type *platform.InstanceType `group:"flag-create" shortcut:"type" help:"Type of virtual machine to run. \"full\" requires a plan with full VM support." placeholder:"type" example:"micro,full"` @@ -138,7 +137,7 @@ type InstanceEditCmd struct { cmd.ResourceEditCmd[Instance] // Shortcut flags - only fields that support editing. - Image string `group:"flag-edit" shortcut:"image" help:"Image to deploy." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` + Image string `group:"flag-edit" shortcut:"image" help:"Image to deploy." placeholder:"" example:"nginx:latest,my-app:v1.2.3,https+oci://cdn.example.com/me/app/latest"` Args InstanceArgs `group:"flag-edit" shortcut:"runtime.args" help:"Arguments to pass to the instance." placeholder:"arg"` Env []string `group:"flag-edit" shortcut:"runtime.env" short:"e" help:"Environment variables." placeholder:"=" example:"DEBUG=true,PORT=8080"` @@ -172,9 +171,9 @@ type Instance struct { State types.InstanceState `mirror:"instance.state" field:",short" edit:"set"` - Image types.ImageRef[reference.Named] `mirror:"instance.image" field:",short" create:"set" edit:"set"` - PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set"` + Image types.ImageRef `mirror:"instance.image" field:",short" create:"set" edit:"set"` + PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set"` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short" create:"set" edit:"set"` @@ -931,7 +930,7 @@ func instancePatchSpec(path string, op patchOp, value any) (platform.MutableInst case "tags": return platform.MutableInstancePropertyTags, value.([]string), nil case "image": - return platform.MutableInstancePropertyImage, value.(types.ImageRef[reference.Named]).Reference.String(), nil + return platform.MutableInstancePropertyImage, value.(types.ImageRef).WireURL(), nil case "runtime.args": return platform.MutableInstancePropertyArgs, []string(value.(InstanceArgs)), nil case "runtime.env": @@ -1024,7 +1023,7 @@ func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource case "metro": metro = string(field.Create.Set.(LinkName[Metro])) case "image": - imageURL = field.Create.Set.(types.ImageRef[reference.Named]).Reference.String() + imageURL = field.Create.Set.(types.ImageRef).WireURL() case "pull-policy": pullPolicy = field.Create.Set.(*platform.PullPolicy) case "type": diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index b8cec45e..308a6645 100644 --- a/internal/cmd/output_test.go +++ b/internal/cmd/output_test.go @@ -94,6 +94,7 @@ func TestOutput(t *testing.T) { run("services", servicesOutputTests) run("certificates", certificatesOutputTests) run("images", imagesOutputTests) + run("instances-http-oci", instancesHTTPOCIOutputTests) } func instancesOutputTests(t *testing.T) { @@ -159,6 +160,24 @@ func instancesOutputTests(t *testing.T) { integ.Gild[resource.Resource](t, dumpResource, sample) } +// Every other sample carries a registry reference, so nothing exercises how an +// OCI layout served over HTTP renders. +func instancesHTTPOCIOutputTests(t *testing.T) { + sample := cmd.Instance{ + Metro: "fra", + Name: "my-instance", + UUID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + State: types.InstanceState(platform.InstanceStateRunning), + } + sample.Resources.Memory = 256 + sample.Resources.VCPUs = 1 + require.NoError(t, sample.Image.UnmarshalText([]byte( + "https+oci://cdn.example.com/me/app/@sha256:"+ + "43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190"))) + + integ.Gild[resource.Resource](t, dumpResource, sample) +} + func instanceTemplatesOutputTests(t *testing.T) { sample := cmd.InstanceTemplate{ Metro: "fra", diff --git a/internal/cmd/testdata/TestOutput/instances-http-oci b/internal/cmd/testdata/TestOutput/instances-http-oci new file mode 100644 index 00000000..8a5072fb --- /dev/null +++ b/internal/cmd/testdata/TestOutput/instances-http-oci @@ -0,0 +1,621 @@ +====================================== kv ====================================== +metro: fra +name: my-instance +uuid: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +state: running +image: https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190 +resources: + memory: 256MiB + vcpus: 1 + +==================================== kv-all ==================================== +metro: fra +name: my-instance +uuid: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +tags: +state: running +image: https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190 +type: +runtime: + args: + env: +resources: + memory: 256MiB + vcpus: 1 + gpus: 0 +service: + name: + uuid: + domains: +volumes: +roms: +networks: +gpus: +timestamps: + created: never + started: never + stopped: never +scale-to-zero: + enabled: false + policy: + stateful: false + cooldown-time: 0s + notify-time: 0s +timing: + uptime: 0s + boot-time: 0s + net-time: 0s +restart: + policy: + start-count: 0 + restart-count: 0 +sched-priority: +stop: + reason: + origin: + errno: + exit-code: +delete-lock: false + +==================================== table ===================================== +METRO NAME STATE IMAGE ARGS MEMORY VCPUS FQDN CREATED +fra my-instance running https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190 256MiB 1 never + +==================================== debug ===================================== +[ + { + "name": "metro", + "value": "fra", + "links": [ + { + "type": "metro", + "key": "fra" + } + ], + "verbosity": "short", + "create": { + "set": "fra", + "required": true + } + }, + { + "name": "name", + "value": "my-instance", + "verbosity": "short", + "create": { + "set": "my-instance" + } + }, + { + "name": "uuid", + "value": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "verbosity": "long" + }, + { + "name": "tags", + "value": null, + "verbosity": "long", + "create": { + "set": null + }, + "edit": { + "set": null, + "add": null, + "del": null + } + }, + { + "name": "state", + "value": "running", + "verbosity": "short", + "edit": { + "set": "running" + } + }, + { + "name": "image", + "value": "https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190", + "links": [ + { + "type": "image", + "key": "https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + } + ], + "verbosity": "short", + "create": { + "set": "https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + }, + "edit": { + "set": "https+oci://cdn.example.com/me/app/@sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + } + }, + { + "name": "pull-policy", + "verbosity": "invisible", + "create": { + "set": null + } + }, + { + "name": "type", + "value": null, + "verbosity": "long", + "create": { + "set": null + } + }, + { + "name": "runtime", + "subfields": [ + { + "name": "args", + "value": null, + "verbosity": "short", + "create": { + "set": null + }, + "edit": { + "set": null + } + }, + { + "name": "env", + "value": null, + "verbosity": "long", + "create": { + "set": null + }, + "edit": { + "set": null, + "add": null, + "del": null + } + } + ], + "verbosity": "short" + }, + { + "name": "resources", + "subfields": [ + { + "name": "memory", + "value": "256MiB", + "verbosity": "short", + "create": { + "set": "256MiB" + }, + "edit": { + "set": "256MiB" + } + }, + { + "name": "vcpus", + "value": 1, + "verbosity": "short", + "create": { + "set": 1 + }, + "edit": { + "set": 1 + } + }, + { + "name": "gpus", + "value": 0, + "verbosity": "long", + "create": { + "set": 0 + } + } + ], + "verbosity": "short" + }, + { + "name": "service", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "services", + "verbosity": "invisible", + "create": { + "set": null + } + }, + { + "name": "domains", + "elem": { + "name": "", + "subfields": [ + { + "name": "fqdn", + "value": "", + "verbosity": "short" + }, + { + "name": "certificate", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + } + ], + "verbosity": "short" + }, + "verbosity": "short", + "create": { + "set": null + } + }, + { + "name": "soft-limit", + "verbosity": "invisible", + "create": { + "set": 0 + } + }, + { + "name": "hard-limit", + "verbosity": "invisible", + "create": { + "set": 0 + } + } + ], + "verbosity": "short", + "create": { + "set": null + } + }, + { + "name": "volumes", + "elem": { + "name": "", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "at", + "value": "", + "verbosity": "long" + }, + { + "name": "readonly", + "value": false, + "verbosity": "long" + }, + { + "name": "size", + "verbosity": "invisible", + "create": { + "set": "0B" + } + } + ], + "verbosity": "long" + }, + "verbosity": "long", + "create": { + "set": null + }, + "edit": { + "add": null, + "del": null + } + }, + { + "name": "roms", + "elem": { + "name": "", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "image", + "value": "", + "verbosity": "long" + }, + { + "name": "dir", + "value": "", + "verbosity": "invisible" + }, + { + "name": "at", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + }, + "verbosity": "long", + "create": { + "set": null + }, + "edit": { + "set": null, + "add": null, + "del": null + } + }, + { + "name": "networks", + "elem": { + "name": "", + "subfields": [ + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "private-ip", + "value": "", + "verbosity": "long" + }, + { + "name": "mac", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + }, + "verbosity": "long" + }, + { + "name": "gpus", + "elem": { + "name": "", + "subfields": [ + { + "name": "uuid", + "value": "", + "verbosity": "long" + }, + { + "name": "model", + "value": "", + "verbosity": "long" + } + ], + "verbosity": "long" + }, + "verbosity": "long" + }, + { + "name": "timestamps", + "subfields": [ + { + "name": "created", + "value": "never", + "verbosity": "short" + }, + { + "name": "started", + "value": "never", + "verbosity": "hidden" + }, + { + "name": "stopped", + "value": "never", + "verbosity": "hidden" + } + ], + "verbosity": "short" + }, + { + "name": "scale-to-zero", + "subfields": [ + { + "name": "enabled", + "value": false, + "verbosity": "long" + }, + { + "name": "policy", + "value": "", + "verbosity": "long" + }, + { + "name": "stateful", + "value": false, + "verbosity": "long" + }, + { + "name": "cooldown-time", + "value": "0s", + "verbosity": "long" + }, + { + "name": "notify-time", + "value": "0s", + "verbosity": "long" + } + ], + "verbosity": "long", + "create": { + "set": {} + }, + "edit": { + "set": {} + } + }, + { + "name": "timing", + "subfields": [ + { + "name": "uptime", + "value": "0s", + "verbosity": "hidden" + }, + { + "name": "boot-time", + "value": "0s", + "verbosity": "long" + }, + { + "name": "net-time", + "value": "0s", + "verbosity": "hidden" + } + ], + "verbosity": "long" + }, + { + "name": "restart", + "subfields": [ + { + "name": "policy", + "value": "", + "verbosity": "hidden", + "create": { + "set": "" + } + }, + { + "name": "start-count", + "value": 0, + "verbosity": "hidden" + }, + { + "name": "restart-count", + "value": 0, + "verbosity": "hidden" + } + ], + "verbosity": "hidden" + }, + { + "name": "sched-priority", + "value": null, + "verbosity": "long", + "create": { + "set": null + }, + "edit": { + "set": null + } + }, + { + "name": "autostart", + "verbosity": "invisible", + "create": { + "set": false + } + }, + { + "name": "replicas", + "verbosity": "invisible", + "create": { + "set": 0 + } + }, + { + "name": "wait-timeout", + "verbosity": "invisible", + "create": { + "set": "0s" + } + }, + { + "name": "features", + "verbosity": "invisible", + "create": { + "set": null + } + }, + { + "name": "vsock", + "verbosity": "invisible", + "create": { + "set": false + }, + "edit": { + "set": false + } + }, + { + "name": "template", + "verbosity": "invisible", + "create": { + "set": "" + } + }, + { + "name": "branch", + "verbosity": "invisible", + "create": { + "set": "" + } + }, + { + "name": "checkpoint", + "verbosity": "invisible", + "create": { + "set": "" + } + }, + { + "name": "stop", + "subfields": [ + { + "name": "reason", + "value": "", + "verbosity": "long" + }, + { + "name": "origin", + "value": "", + "verbosity": "hidden" + }, + { + "name": "errno", + "value": 0, + "verbosity": "hidden" + }, + { + "name": "exit-code", + "value": null, + "verbosity": "long" + } + ], + "verbosity": "long" + }, + { + "name": "delete-lock", + "value": false, + "verbosity": "long", + "edit": { + "set": false + } + } +] diff --git a/internal/images/images.go b/internal/images/images.go index e011ae86..f0f2402a 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -10,11 +10,11 @@ import ( "fmt" "github.com/containerd/containerd/v2/core/remotes/docker" - "github.com/distribution/reference" + ociref "github.com/distribution/reference" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/imageref" "unikraft.com/cli/internal/config" - xreference "unikraft.com/cli/internal/x/reference" ) const DefaultRegistry = "unikraft.io" @@ -56,7 +56,7 @@ func Accessor(ctx context.Context, opts ...AccessorOpt) (*imagespec.Accessor, er imagespec.WithResolver(resolver), imagespec.WithRegistryHosts(options.Hosts), imagespec.WithRegistryHeaders(options.Headers), - imagespec.WithReferenceParser(ParseNormalizedNamed), + imagespec.WithReferenceParser(ParseName), ), nil } @@ -80,33 +80,96 @@ func WithInsecureRegistries() AccessorOpt { } } -func ParseNormalizedNamed(key string) (reference.Named, error) { - return ParseNormalizedNamedMetro(nil, key) -} +// officialPrefix is the namespace Unikraft's registry serves its own images +// from. +const officialPrefix = "official/" -func ParseNormalizedNamedMetro(metro *config.Metro, key string) (reference.Named, error) { - if uri, err := imagespec.ParseURI(key); err == nil { - if uri.Scheme != imagespec.URISchemeOCI { - return nil, fmt.Errorf("%w: invalid scheme %q", reference.ErrReferenceInvalidFormat, uri.Scheme) - } - key = uri.Path - } +// Policy is what an image identifier is allowed to leave implicit: the registry +// it lives in and the namespace within it. +type Policy struct { + // Domain is the registry an identifier without one names. + Domain string - index := DefaultRegistry + // Prefix is the namespace a single-segment repository on Domain names. + Prefix string +} + +// PolicyFor returns the policy for identifiers exchanged with metro, or the +// default registry's policy when there is no metro in scope. +func PolicyFor(metro *config.Metro) Policy { + domain := DefaultRegistry if metro != nil { - index = metro.Index().Host + domain = metro.Index().Host } - return xreference.ParseNormalizedNamed( - key, - xreference.WithDefaultDomain(index), - xreference.WithDefaultPrefix("official/"), - ) + return Policy{Domain: domain, Prefix: officialPrefix} } -func FamiliarString(ref reference.Reference) string { - return xreference.FamiliarString( - ref, - xreference.WithDefaultDomain(DefaultRegistry), - xreference.WithDefaultPrefix("official/"), +// Parse parses an image identifier supplied by the user or returned by the API. +func (p Policy) Parse(key string) (imageref.Reference, error) { + ref, err := imageref.Parse(key, + imageref.WithDefaultDomain(p.Domain), + imageref.WithDefaultPrefix(p.Prefix), ) + if err != nil { + return imageref.Reference{}, err + } + return p.canonical(ref) +} + +// canonical returns ref on the canonical spelling of its registry, so that the +// same image reached through a metro's index and through the default registry +// deduplicates to one entry rather than being listed twice. +func (p Policy) canonical(ref imageref.Reference) (imageref.Reference, error) { + if p.Domain == DefaultRegistry || ref.Scheme() != imageref.SchemeOCI || ref.Domain() != p.Domain { + return ref, nil + } + return ref.WithDomain(DefaultRegistry) +} + +// Format renders ref the way the CLI displays an image: an HTTP-served layout +// by the URI it is fetched from, and a registry image in its familiar short +// form. +func (p Policy) Format(ref imageref.Reference, short bool) string { + // Parse canonicalizes p.Domain onto DefaultRegistry, so eliding + // DefaultRegistry here - not p.Domain. + return ref.WithoutDefaultTag().Format(imageref.FormatOpts{ + OmitDigest: short, + DefaultDomain: DefaultRegistry, + DefaultPrefix: p.Prefix, + }) +} + +// ParseRef parses an image identifier without metro context. +func ParseRef(key string) (imageref.Reference, error) { + return PolicyFor(nil).Parse(key) +} + +// ParseRefMetro parses an image identifier exchanged with metro, applying its +// index as the default registry domain. +func ParseRefMetro(metro *config.Metro, key string) (imageref.Reference, error) { + return PolicyFor(metro).Parse(key) +} + +// ParseName parses an image identifier and returns just the OCI name it +// decomposes to. +func ParseName(key string) (ociref.Named, error) { + ref, err := ParseRef(key) + if err != nil { + return nil, err + } + named := ref.Named() + if named == nil { + return nil, fmt.Errorf("image %q is served over %s, so it has no registry name", key, ref.Scheme()) + } + return named, nil +} + +// Format renders ref for display, against the default registry's policy. +func Format(ref imageref.Reference) string { + return PolicyFor(nil).Format(ref, false) +} + +// FormatShort renders ref for concise display, eliding the digest. +func FormatShort(ref imageref.Reference) string { + return PolicyFor(nil).Format(ref, true) } diff --git a/internal/images/images_test.go b/internal/images/images_test.go new file mode 100644 index 00000000..420b632e --- /dev/null +++ b/internal/images/images_test.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package images_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "unikraft.com/x/image-spec/imageref" + + "unikraft.com/cli/internal/config" + "unikraft.com/cli/internal/images" +) + +const testDigest = "sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + +// The grammar itself is pinned in unikraft.com/x/image-spec/imageref. What is +// the CLI's own is the policy it parses against. +func TestParseRef(t *testing.T) { + for _, tt := range []struct { + name string + key string + named string + scheme imageref.Scheme + // wire is the identifier handed back to the platform. + wire string + }{ + { + name: "a bare reference gets the default registry and namespace", + key: "nginx", + named: "unikraft.io/official/nginx", + scheme: imageref.SchemeOCI, + wire: "unikraft.io/official/nginx", + }, + { + name: "the oci scheme is stripped on the wire", + key: "oci://index.unikraft.io/jedevc/http-oci-registry@" + testDigest, + named: "index.unikraft.io/jedevc/http-oci-registry@" + testDigest, + scheme: imageref.SchemeOCI, + wire: "index.unikraft.io/jedevc/http-oci-registry@" + testDigest, + }, + { + // Docker Hub's namespace is "library/", not ours. Applying the + // CLI's prefix here would name an image no registry can serve. + name: "docker hub keeps its own namespace", + key: "docker.io/nginx", + named: "docker.io/library/nginx", + scheme: imageref.SchemeOCI, + wire: "docker.io/library/nginx", + }, + { + // The host comes from the URI, never from the CLI's default, and + // the whole URI is what goes back to the platform. + name: "an http+oci uri keeps its own host", + key: "http+oci://dawn-sky-atgon69g.ukp-stable.apw.unikraft.internal/test/http-oci-registry/latest", + scheme: imageref.SchemeHTTPOCI, + wire: "http+oci://dawn-sky-atgon69g.ukp-stable.apw.unikraft.internal/test/http-oci-registry/latest", + }, + { + name: "an https+oci digest uri round-trips", + key: "https+oci://cdn.example.com/me/app/@" + testDigest, + scheme: imageref.SchemeHTTPSOCI, + wire: "https+oci://cdn.example.com/me/app/@" + testDigest, + }, + } { + t.Run(tt.name, func(t *testing.T) { + parsed, err := images.ParseRef(tt.key) + require.NoError(t, err) + require.Equal(t, tt.scheme, parsed.Scheme()) + require.Equal(t, tt.wire, parsed.String()) + + if tt.named == "" { + // A layout served over HTTP has no registry name, and asking for + // one has to fail rather than hand back a name that resolves to + // an image on some other host. + require.Nil(t, parsed.Named()) + _, err := images.ParseName(tt.key) + require.ErrorContains(t, err, "has no registry name") + return + } + + require.Equal(t, tt.named, parsed.Named().String()) + + // ParseName is the name-only view of the same parse. + named, err := images.ParseName(tt.key) + require.NoError(t, err) + require.Equal(t, tt.named, named.String()) + }) + } +} + +// An unusable identifier has to say what is actually wrong with it. +func TestParseRefErrors(t *testing.T) { + for _, tt := range []struct{ name, key, wantErr string }{ + {"unknown scheme", "banana://x", `unsupported image URI scheme: "banana"`}, + {"a plain https url is not an image", "https://cdn.example.com/me/app", `unsupported image URI scheme: "https"`}, + {"a local path is not an image", "oci-archive:///tmp/image.tar", "addresses a local image"}, + {"a malformed uri says so once", "http+oci://cdn.example.com/me/app/", "unexpected terminating '/'"}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := images.ParseRef(tt.key) + require.ErrorContains(t, err, tt.wantErr) + require.NotContains(t, err.Error(), "invalid reference format: invalid reference format") + require.NotContains(t, err.Error(), "invalid image reference: invalid image reference") + }) + } +} + +// A metro's index host supplies the default domain for a bare reference, but is +// then canonicalized onto the default registry. +func TestParseRefMetro(t *testing.T) { + metro := &config.Metro{ + Name: "stable", + Endpoint: "https://api.stable.apw.unikraft.cloud", + } + require.Equal(t, "index.stable.apw.unikraft.cloud", metro.Index().Host) + + withMetro, err := images.ParseRefMetro(metro, "nginx") + require.NoError(t, err) + require.Equal(t, "unikraft.io/official/nginx", withMetro.Named().String()) + + withoutMetro, err := images.ParseRef("nginx") + require.NoError(t, err) + require.Equal(t, withoutMetro, withMetro) + + // A host that is genuinely a different registry is left alone. + other, err := images.ParseRefMetro(metro, "index.unikraft.io/me/app") + require.NoError(t, err) + require.Equal(t, "index.unikraft.io/me/app", other.Named().String()) + + const uri = "http+oci://dawn-sky-atgon69g.ukp-stable.apw.unikraft.internal/test/http-oci-registry/latest" + parsed, err := images.ParseRefMetro(metro, uri) + require.NoError(t, err) + require.Equal(t, "dawn-sky-atgon69g.ukp-stable.apw.unikraft.internal", parsed.Domain()) + require.Equal(t, "test/http-oci-registry", parsed.Path()) + require.Equal(t, uri, parsed.String()) +} + +// One policy drives both directions, so what parsing adds formatting takes back +// off again. A reference is only displayable in short form if it round-trips. +func TestPolicyFormatIsTheInverseOfParse(t *testing.T) { + p := images.PolicyFor(nil) + + for _, key := range []string{ + "nginx", + "nginx:v1", + "myuser/app:v1", + "official/utils/volimport:1.0", + "index.unikraft.io/me/app:v1", + "http+oci://cdn.example.com/me/app/latest", + } { + ref, err := p.Parse(key) + require.NoError(t, err) + + formatted := p.Format(ref, false) + back, err := p.Parse(formatted) + require.NoError(t, err, "formatting %q gave %q", key, formatted) + require.Equal(t, ref, back, "%q formatted to %q, which names a different image", key, formatted) + } +} + +// The policy has to be its own inverse for a metro too, not just for the default +// registry: parsing adds what an identifier leaves implicit and formatting takes +// exactly that back off. +func TestMetroPolicyFormatIsTheInverseOfParse(t *testing.T) { + p := images.PolicyFor(&config.Metro{ + Name: "fra0", + Endpoint: "https://api.fra0.unikraft.cloud", + }) + + for _, key := range []string{"nginx", "nginx:v1", "myuser/app:v1", "index.fra0.unikraft.cloud/official/nginx"} { + ref, err := p.Parse(key) + require.NoError(t, err) + + formatted := p.Format(ref, false) + back, err := p.Parse(formatted) + require.NoError(t, err, "formatting %q gave %q", key, formatted) + require.Equal(t, ref, back, "%q formatted to %q, which names a different image", key, formatted) + } + + ref, err := p.Parse("index.fra0.unikraft.cloud/official/nginx") + require.NoError(t, err) + require.Equal(t, "nginx", p.Format(ref, false)) + + // Another metro's index is not in scope, so it is left alone and shown. + other, err := p.Parse("index.sfo0.unikraft.cloud/official/nginx") + require.NoError(t, err) + require.Equal(t, "index.sfo0.unikraft.cloud/official/nginx", p.Format(other, false)) +} diff --git a/internal/types/image.go b/internal/types/image.go index 4c0ae2d4..8bc88d66 100644 --- a/internal/types/image.go +++ b/internal/types/image.go @@ -6,9 +6,8 @@ package types import ( - "strings" - - "github.com/distribution/reference" + ociref "github.com/distribution/reference" + "unikraft.com/x/image-spec/imageref" "unikraft.com/cli/internal/images" "unikraft.com/cli/internal/multimetro" @@ -16,59 +15,74 @@ import ( "unikraft.com/cli/internal/resource/value" ) -// ImageRef is a generic wrapper around a Docker image reference. -type ImageRef[T interface { - reference.Named - comparable -}] struct { - Reference T +// ImageRef is a resource field holding an image reference, which is either an +// image in a registry or an OCI layout served over HTTP. The distinction is the +// parsed reference's to make. +type ImageRef struct { + ref imageref.Reference } -func (ir ImageRef[T]) MarshalText() ([]byte, error) { - var zero T - if ir.Reference == zero { - return []byte{}, nil +// NewImageRef returns an ImageRef for an image that is already named, which is +// always an image in a registry. A nil name yields the unset reference. +func NewImageRef(named ociref.Named) ImageRef { + ref, err := imageref.FromNamed(named) + if err != nil { + return ImageRef{} } - s := images.FamiliarString(ir.Reference) - return []byte(s), nil + return ImageRef{ref: ref} } -// Render implements value.Renderer. In short form (e.g. table output), the -// digest is elided to keep output concise; in long form (e.g. detail views, -// JSON/YAML output via MarshalText) the full canonical reference, including -// any digest, is shown. -func (ir ImageRef[T]) Render(opts value.RenderOpts) (string, error) { - var zero T - if ir.Reference == zero { - return "", nil - } - s := images.FamiliarString(ir.Reference) +// NewImageRefFrom returns an ImageRef for an already-parsed reference. +func NewImageRefFrom(ref imageref.Reference) ImageRef { + return ImageRef{ref: ref} +} + +// Reference returns the parsed reference, which is the zero Reference when the +// field is unset. +func (ir ImageRef) Reference() imageref.Reference { + return ir.ref +} + +func (ir ImageRef) MarshalText() ([]byte, error) { + return []byte(images.Format(ir.ref)), nil +} + +// Render implements value.Renderer. In short form (e.g. table output) the digest +// is elided to keep output concise. +func (ir ImageRef) Render(opts value.RenderOpts) (string, error) { if opts.Short { - s, _, _ = strings.Cut(s, "@") + return images.FormatShort(ir.ref), nil } - return s, nil + return images.Format(ir.ref), nil } -func (ir ImageRef[T]) Value() any { +func (ir ImageRef) Value() any { return ir } -func (ir *ImageRef[T]) UnmarshalText(text []byte) error { - ref, err := images.ParseNormalizedNamed(string(text)) +func (ir *ImageRef) UnmarshalText(text []byte) error { + ref, err := images.ParseRef(string(text)) if err != nil { return err } - ref = reference.TagNameOnly(ref) - ir.Reference = ref.(T) + ir.ref = ref.WithDefaultTag() return nil } -func (ir ImageRef[T]) Link() (string, resource.Key, bool) { - var zero T - if ir.Reference == zero { +// WireURL returns the identifier to send to the platform API. +// It differs from MarshalText, which renders the familiar short form a human +// reads: the API resolves what it is given against its own registry and applies +// no namespace of its own, so "nginx:latest" there is not the image +// "unikraft.io/official/nginx:latest" that the CLI means by it. +func (ir ImageRef) WireURL() string { + return ir.ref.String() +} + +func (ir ImageRef) Link() (string, resource.Key, bool) { + if ir.ref.IsZero() { return "", nil, false } return "image", multimetro.Key{ - Name: ir.Reference.String(), + Name: ir.WireURL(), }, false } diff --git a/internal/types/image_test.go b/internal/types/image_test.go new file mode 100644 index 00000000..a1a66dba --- /dev/null +++ b/internal/types/image_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package types_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "unikraft.com/cli/internal/resource/value" + "unikraft.com/cli/internal/types" +) + +const testDigest = "sha256:43d3d758e6fba7d4734ac142cfdbf8aa786fcbbfd828017eecaadc5140a4b190" + +func imageRef(t *testing.T, s string) types.ImageRef { + t.Helper() + var ref types.ImageRef + require.NoError(t, ref.UnmarshalText([]byte(s))) + return ref +} + +func TestImageRef(t *testing.T) { + tests := []struct { + name string + in string + // text is what MarshalText and long-form Render produce. + text string + // short is what short-form Render produces. + short string + // wire is what gets sent to the platform API. + wire string + }{ + { + name: "http+oci tag", + in: "http+oci://cdn.example.com/me/app/latest", + text: "http+oci://cdn.example.com/me/app/latest", + short: "http+oci://cdn.example.com/me/app/latest", + wire: "http+oci://cdn.example.com/me/app/latest", + }, + { + name: "https+oci digest is never shortened", + in: "https+oci://cdn.example.com/me/app/@" + testDigest, + text: "https+oci://cdn.example.com/me/app/@" + testDigest, + short: "https+oci://cdn.example.com/me/app/@" + testDigest, + wire: "https+oci://cdn.example.com/me/app/@" + testDigest, + }, + // Plain OCI references must keep behaving exactly as before. + { + name: "bare reference", + in: "nginx", + text: "nginx", + short: "nginx", + wire: "unikraft.io/official/nginx:latest", + }, + { + name: "reference with digest", + in: "myuser/app@" + testDigest, + text: "myuser/app@" + testDigest, + short: "myuser/app", + wire: "unikraft.io/myuser/app@" + testDigest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ref := imageRef(t, tt.in) + + text, err := ref.MarshalText() + require.NoError(t, err) + require.Equal(t, tt.text, string(text)) + + long, err := ref.Render(value.RenderOpts{}) + require.NoError(t, err) + require.Equal(t, tt.text, long) + + short, err := ref.Render(value.RenderOpts{Short: true}) + require.NoError(t, err) + require.Equal(t, tt.short, short) + + require.Equal(t, tt.wire, ref.WireURL()) + + // Marshalling and unmarshalling is a fixed point, which the patch + // machinery relies on to avoid reporting spurious edits. + require.Equal(t, ref, imageRef(t, tt.text)) + }) + } +} + +func TestImageRefLink(t *testing.T) { + kind, key, strong := imageRef(t, "http+oci://cdn.example.com/me/app/latest").Link() + require.Equal(t, "image", kind) + require.Equal(t, "http+oci://cdn.example.com/me/app/latest", key.String()) + require.False(t, strong) + + kind, key, strong = imageRef(t, "nginx").Link() + require.Equal(t, "image", kind) + require.Equal(t, "unikraft.io/official/nginx:latest", key.String()) + require.False(t, strong) +} + +func TestImageRefZero(t *testing.T) { + var ref types.ImageRef + + text, err := ref.MarshalText() + require.NoError(t, err) + require.Empty(t, text) + + rendered, err := ref.Render(value.RenderOpts{}) + require.NoError(t, err) + require.Empty(t, rendered) + + require.Empty(t, ref.WireURL()) + + _, _, ok := ref.Link() + require.False(t, ok) +} + +// ImageRef is compared against its zero value throughout and used as a lookup +// key, so it has to stay comparable by value. +var _ = map[types.ImageRef]struct{}{} + +// Comparability is only useful if it is value equality. +func TestImageRefEquality(t *testing.T) { + a, b := imageRef(t, "nginx:latest"), imageRef(t, "nginx:latest") + + // Compared with == deliberately, and via a variable so that testifylint + // does not rewrite it. + sameImage := a == b + require.True(t, sameImage, "two references to the same image must be equal") + + seen := map[types.ImageRef]struct{}{a: {}} + _, ok := seen[b] + require.True(t, ok, "an equal reference must find its own map entry") + + differentImage := a == imageRef(t, "nginx:v1") + require.False(t, differentImage, "references to different images must not be equal") +} diff --git a/internal/x/reference/match.go b/internal/x/reference/match.go deleted file mode 100644 index d7b39764..00000000 --- a/internal/x/reference/match.go +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. -// Licensed under the BSD-3-Clause License (the "License"). -// You may not use this file except in compliance with the License. - -package reference - -import ( - "github.com/distribution/reference" -) - -// MatchNamed compares a reference.Named against a pattern string. -func MatchNamed(ref reference.Named, pattern string) bool { - spec, err := reference.Parse(pattern) - if err != nil { - return false - } - specNamed, ok := spec.(reference.Named) - if !ok { - return false - } - - if specNamed.Name() != ref.Name() { - if specNamed.Name() != reference.Path(ref) { - return false - } - } - - if digested, ok := specNamed.(reference.Digested); ok { - n, ok := ref.(reference.Digested) - if !ok || digested.Digest() != n.Digest() { - return false - } - return true - } - if tagged, ok := specNamed.(reference.Tagged); ok { - n, ok := ref.(reference.Tagged) - if !ok || tagged.Tag() != n.Tag() { - return false - } - } - - return true -} diff --git a/internal/x/reference/reference.go b/internal/x/reference/reference.go deleted file mode 100644 index 4b74418c..00000000 --- a/internal/x/reference/reference.go +++ /dev/null @@ -1,172 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause -// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. -// Licensed under the BSD-3-Clause License (the "License"). -// You may not use this file except in compliance with the License. - -package reference - -import ( - "fmt" - "strings" - - "github.com/distribution/reference" -) - -const ( - dockerDomain = "docker.io" - legacyDockerDomain = "index.docker.io" -) - -type parseOptions struct { - defaultDomain string - defaultPrefix string -} - -type ParseOpt func(*parseOptions) - -func WithDefaultDomain(domain string) ParseOpt { - return func(opts *parseOptions) { - opts.defaultDomain = domain - } -} - -func WithDefaultPrefix(prefix string) ParseOpt { - return func(opts *parseOptions) { - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - opts.defaultPrefix = prefix - } -} - -// ParseNormalizedNamed is forked from github.com/distribution/reference, and -// performs the same operation, with the exception of being able to modify the default -// domain. -func ParseNormalizedNamed(s string, opts ...ParseOpt) (reference.Named, error) { - opt := &parseOptions{ - defaultDomain: dockerDomain, - defaultPrefix: "library/", - } - for _, o := range opts { - o(opt) - } - - domain, remainder := splitDockerDomain(s, opt) - var remote string - if tagSep := strings.IndexRune(remainder, ':'); tagSep > -1 { - remote = remainder[:tagSep] - } else { - remote = remainder - } - if strings.ToLower(remote) != remote { - return nil, fmt.Errorf("invalid reference format: repository name (%s) must be lowercase", remote) - } - - ref, err := reference.Parse(domain + "/" + remainder) - if err != nil { - return nil, err - } - named, isNamed := ref.(reference.Named) - if !isNamed { - return nil, fmt.Errorf("reference %s has no name", ref.String()) - } - return named, nil -} - -const ( - localhost = `localhost` -) - -// splitDockerDomain splits a repository name to domain and remote-name. -// If no valid domain is found, the default domain is used. Repository name -// needs to be already validated before. -func splitDockerDomain(name string, opt *parseOptions) (domain, remoteName string) { - maybeDomain, maybeRemoteName, ok := strings.Cut(name, "/") - if !ok { - // Fast-path for single element ("familiar" names), such as "ubuntu" - // or "ubuntu:latest". Familiar names must be handled separately, to - // prevent them from being handled as "hostname:port". - // - // Canonicalize them as "docker.io/library/name[:tag]" - - // FIXME(thaJeztah): account for bare "localhost" or "example.com" names, which SHOULD be considered a domain. - return opt.defaultDomain, opt.defaultPrefix + name - } - - switch { - case maybeDomain == localhost: - // localhost is a reserved namespace and always considered a domain. - domain, remoteName = maybeDomain, maybeRemoteName - case maybeDomain == legacyDockerDomain: - // canonicalize the Docker Hub and legacy "Docker Index" domains. - domain, remoteName = dockerDomain, maybeRemoteName - case strings.ContainsAny(maybeDomain, ".:"): - // Likely a domain or IP-address: - // - // - contains a "." (e.g., "example.com" or "127.0.0.1") - // - contains a ":" (e.g., "example:5000", "::1", or "[::1]:5000") - domain, remoteName = maybeDomain, maybeRemoteName - case strings.ToLower(maybeDomain) != maybeDomain: - // Uppercase namespaces are not allowed, so if the first element - // is not lowercase, we assume it to be a domain-name. - domain, remoteName = maybeDomain, maybeRemoteName - default: - // None of the above: it's not a domain, so use the default, and - // use the name input the remote-name. - domain, remoteName = opt.defaultDomain, name - } - - if (domain == dockerDomain || domain == opt.defaultDomain) && !strings.ContainsRune(remoteName, '/') { - // Canonicalize "familiar" names, but only on Docker Hub, or the default domain - // - // "docker.io/ubuntu[:tag]" => "docker.io/library/ubuntu[:tag]" - remoteName = opt.defaultPrefix + remoteName - } - - return domain, remoteName -} - -func FamiliarString(ref reference.Reference, opts ...ParseOpt) string { - opt := &parseOptions{ - defaultDomain: dockerDomain, - defaultPrefix: "library/", - } - for _, o := range opts { - o(opt) - } - - nn, ok := ref.(reference.Named) - if !ok { - return ref.String() - } - - domain := reference.Domain(nn) - if domain == opt.defaultDomain { - domain = "" - } - - path := reference.Path(nn) - if domain == "" { - path = strings.TrimPrefix(path, opt.defaultPrefix) - path = strings.TrimPrefix(path, "/") - } else { - path = "/" + path - } - - tag := "" - if tagged, ok := ref.(reference.NamedTagged); ok { - tag = tagged.Tag() - if tag == "latest" { - tag = "" - } else { - tag = ":" + tag - } - } - - digest := "" - if canonical, ok := ref.(reference.Canonical); ok { - digest = "@" + canonical.Digest().String() - } - - return domain + path + tag + digest -}