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..7a0d82ec --- /dev/null +++ b/cmd/unikraft/integration/instance_http_oci_test.go @@ -0,0 +1,201 @@ +// 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 ( + "archive/tar" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "maps" + "os" + "path/filepath" + "strings" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + integ "unikraft.com/cli/internal/integration" +) + +// httpOCIServer serves a volume mounted at /data as http+oci layouts: the node +// only fetches a layout if it is served with the layout media type. +var httpOCIServer = &integ.SharedImage{ + Name: "http-oci-server-e2e", + Files: map[string]string{ + "main.go": `package main + +import "net/http" + +func main() { + files := http.FileServer(http.Dir("/data")) + http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.layout.v1+tar") + files.ServeHTTP(w, r) + })) +} +`, + "Dockerfile": `FROM golang:1.26-alpine AS build +COPY main.go /src/main.go +RUN cd /src && CGO_ENABLED=0 go build -o /server main.go + +FROM scratch +COPY --from=build /server /server +`, + "Kraftfile": `spec: v0.7 +name: http-oci-server-e2e +runtime: base-compat:latest +rootfs: + format: erofs + source: ./Dockerfile +cmd: ["/server"] +`, + }, +} + +func TestInstancesHTTPOCI(t *testing.T) { + r := runner(t, true, []string{staging, stable}) + payload := integ.Busybox.Build(t, r) + server := httpOCIServer.Build(t, r) + volName := "test-" + uniq() + serverName := "test-" + uniq() + instName := "test-" + uniq() + + layout := filepath.Join(t.TempDir(), "layout") + r.Run(t, []string{"unikraft", "image", "copy", payload, "oci-layout://" + layout}) + + repository := r.Config.Profile.Organization + "/http-oci-e2e" + content := t.TempDir() + dgst := tarLayout(t, layout, filepath.Join(content, repository)) + + r.Run(t, []string{ + "unikraft", "volume", "create", "--output", "quiet", + "--set", "name=" + volName, "--set", "size=64", "--set", "metro=" + r.Config.MetroName, + }) + r.Run(t, []string{"unikraft", "--timeout", "30s", "volume", "wait", "--until", "state==available", volName}) + r.Run(t, []string{"unikraft", "volume", "import", volName, "--source", content}) + + r.Run(t, []string{ + "unikraft", "run", "--name", serverName, "--metro", r.Config.MetroName, "--output", "quiet", + "--image", server, "-p", "80:8080/http", "-v", volName + ":/data", + }) + r.Run(t, []string{"unikraft", "--timeout", "30s", "instance", "wait", "--until", "state==running", serverName}) + fqdn := strings.TrimSpace(r.Run(t, []string{ + "unikraft", "instance", "inspect", serverName, + "--output", "template={{ range .service.domains }}{{ .fqdn }}{{ end }}", + })) + require.NotEmpty(t, fqdn) + + image := "http+oci://" + fqdn + "/" + repository + "/@" + dgst + r.Run(t, []string{ + "unikraft", "run", "--name", instName, "--metro", r.Config.MetroName, "--output", "quiet", + "--image", image, "--args", "echo UNIKRAFT_HTTP_OCI_OK", + }) + r.Run(t, []string{"unikraft", "--timeout", "60s", "instance", "wait", "--until", "state==stopped", instName}) + + out := r.Run(t, []string{"unikraft", "instance", "inspect", instName}) + assert.Contains(t, out, image) + out = r.Run(t, []string{"unikraft", "instance", "logs", instName}) + assert.Contains(t, out, "UNIKRAFT_HTTP_OCI_OK") + + r.Run(t, []string{"unikraft", "instance", "delete", instName}) + r.Run(t, []string{"unikraft", "instance", "delete", serverName}) +} + +// tarLayout packs the layout at src into dir, named by its digest as http+oci +// addresses it. +// +// HACK: the node's unpacker only takes an index.json that lists the manifest +// itself, and a tarball with every directory before its contents, root +// included - neither of which the CLI's layouts give it (TOOL-369). +func tarLayout(t *testing.T, src, dir string) string { + t.Helper() + flattenIndex(t, src) + require.NoError(t, os.MkdirAll(dir, 0o755)) + + f, err := os.CreateTemp(dir, "layout-*") + require.NoError(t, err) + defer f.Close() + + h := sha256.New() + tw := tar.NewWriter(io.MultiWriter(f, h)) + require.NoError(t, filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + hdr.Name = "./" + filepath.ToSlash(rel) + if rel == "." { + hdr.Name = "./" + } else if d.IsDir() { + hdr.Name += "/" + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if d.IsDir() { + return nil + } + r, err := os.Open(path) + if err != nil { + return err + } + defer r.Close() + _, err = io.Copy(tw, r) + return err + })) + require.NoError(t, tw.Close()) + + dgst := "sha256:" + hex.EncodeToString(h.Sum(nil)) + require.NoError(t, os.Rename(f.Name(), filepath.Join(dir, "@"+dgst))) + return dgst +} + +// flattenIndex lifts the manifests of a nested index up into index.json. +func flattenIndex(t *testing.T, layout string) { + t.Helper() + + var index ocispec.Index + data, err := os.ReadFile(filepath.Join(layout, ocispec.ImageIndexFile)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &index)) + require.Len(t, index.Manifests, 1) + if index.Manifests[0].MediaType != ocispec.MediaTypeImageIndex { + return + } + + var nested ocispec.Index + desc := index.Manifests[0] + data, err = os.ReadFile(filepath.Join(layout, ocispec.ImageBlobsDir, desc.Digest.Algorithm().String(), desc.Digest.Encoded())) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &nested)) + + index.Manifests = nested.Manifests + for i := range index.Manifests { + if index.Manifests[i].Annotations == nil { + index.Manifests[i].Annotations = map[string]string{} + } + maps.Copy(index.Manifests[i].Annotations, desc.Annotations) + } + data, err = json.Marshal(index) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(layout, ocispec.ImageIndexFile), data, 0o644)) +} diff --git a/go.mod b/go.mod index 597ae56a..4ce9fb59 100644 --- a/go.mod +++ b/go.mod @@ -14,13 +14,12 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/x/ansi v0.11.8 - github.com/containerd/containerd/v2 v2.4.0 + github.com/containerd/containerd/v2 v2.4.1 github.com/containerd/continuity v0.5.0 github.com/containerd/errdefs v1.0.0 github.com/containerd/log v0.2.0 github.com/containerd/platforms v1.0.0-rc.5 github.com/cpuguy83/go-md2man/v2 v2.0.7 - github.com/distribution/reference v0.6.0 github.com/docker/cli v29.8.1+incompatible github.com/docker/go-units v0.5.0 github.com/ettle/strcase v0.2.0 @@ -58,7 +57,7 @@ require ( unikraft.com/x/filters v0.0.0-20260929150216-7e946e02e58b unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 unikraft.com/x/guesstermwidth v0.0.0-20260904140856-1944f6df62e1 - unikraft.com/x/image-spec v0.0.0-20260924124354-cba1e2dc1921 + unikraft.com/x/image-spec v0.0.0-20260930145451-e11e6b4df7f4 unikraft.com/x/io v0.0.0-20260917141509-02a61e7c1812 unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de unikraft.com/x/kingkong v0.0.0-20260824095305-c69507b68d29 @@ -100,6 +99,7 @@ require ( github.com/containerd/typeurl/v2 v2.3.0 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect diff --git a/go.sum b/go.sum index 21e44020..4d93b890 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/q github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd/api v1.12.0 h1:kuQm82SbDrCuO4n7hf2L8zsBtZLuympyq5X/VotfX2A= github.com/containerd/containerd/api v1.12.0/go.mod h1:EBcSzoi9Vl18cdODaXUCskf3D2NT8lsSXeZJnU5jIUc= -github.com/containerd/containerd/v2 v2.4.0 h1:mZLYWkPAgF4tfTjinClrRfvUxmxHxg1PQL7cjrrpTtk= -github.com/containerd/containerd/v2 v2.4.0/go.mod h1:gHZz+v5y8mGt9grF3ynuaa3r6bXLmghBZWTAftHAUtg= +github.com/containerd/containerd/v2 v2.4.1 h1:DUx/ZJN7cEu0WuzHClDB+68H/bqMEH5pWoEjf0ae4hc= +github.com/containerd/containerd/v2 v2.4.1/go.mod h1:vgLdtvl3prFk1d3ZVqQcsDD5Q9IKM+vAIdU54U85w+I= github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg= github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -482,8 +482,8 @@ unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679 h1:zdvJjNkjsriS8RM unikraft.com/x/fingerprint v0.0.0-20260126094137-ab6e717e5679/go.mod h1:FP7uOxux/W5PKqSRQsR4tyjNuLq4Cfio7mc5QVH1kW8= unikraft.com/x/guesstermwidth v0.0.0-20260904140856-1944f6df62e1 h1:1gkowpZPI25+G+lpuNbjdcUxQZPXnAuJA1e03WAt998= unikraft.com/x/guesstermwidth v0.0.0-20260904140856-1944f6df62e1/go.mod h1:q3EH6bLqLAJ1PqZgHlCJPpvvP6scnjJJspBMyGQtMt4= -unikraft.com/x/image-spec v0.0.0-20260924124354-cba1e2dc1921 h1:/r4nQMA0K7pq3dgtZLilMVrN7+RlijDYjuKFADYdV74= -unikraft.com/x/image-spec v0.0.0-20260924124354-cba1e2dc1921/go.mod h1:MbRzNPIIPOHJ0x/SNt/rVWHbIg+1mx8EABFXqt+LehY= +unikraft.com/x/image-spec v0.0.0-20260930145451-e11e6b4df7f4 h1:hGAgHUd8NW8wJSgUCtTzPBp1P9Xe/W9s9kquWQpvf0I= +unikraft.com/x/image-spec v0.0.0-20260930145451-e11e6b4df7f4/go.mod h1:Zn33DV6nsRwJj57I5Trch99KxGLmfcw3chTEOJIjI3Y= unikraft.com/x/io v0.0.0-20260917141509-02a61e7c1812 h1:OZcX6u+KB5AnO7svzHLf9UYfKUDqxtknzakoW4EaTMs= unikraft.com/x/io v0.0.0-20260917141509-02a61e7c1812/go.mod h1:KU1WcMXAREtyS+e/K0zuYZABwAAuOiu/ezTTcirAIxM= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de h1:cm4FnPvnahRIK0derbI+T4ds1LsD5CFeyyAvIqcOCek= diff --git a/internal/builder/kernel.go b/internal/builder/kernel.go index cc81fad0..338d3987 100644 --- a/internal/builder/kernel.go +++ b/internal/builder/kernel.go @@ -11,6 +11,7 @@ import ( "github.com/containerd/platforms" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/schemes" "unikraft.com/cli/internal/images" ) @@ -21,11 +22,11 @@ func BuildKernel(ctx context.Context, opts BuildOpts) ([]*imagespec.Image, error return nil, err } - runtime, err := imagespec.ParseURIDefault(opts.Runtime) + runtime, err := imagespec.ParseLocationDefault(opts.Runtime) if err != nil { return nil, fmt.Errorf("parsing runtime reference: %w", err) } - if runtime.Scheme != imagespec.URISchemeOCI { + if runtime.Scheme != schemes.OCI { return nil, fmt.Errorf("unsupported runtime reference scheme: %s", runtime.Scheme) } diff --git a/internal/cmd/build.go b/internal/cmd/build.go index 58b7387e..25a461ff 100644 --- a/internal/cmd/build.go +++ b/internal/cmd/build.go @@ -16,6 +16,7 @@ import ( "unikraft.com/cli/internal/images" "unikraft.com/cli/internal/resource" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/schemes" "unikraft.com/x/kingkong" ) @@ -133,7 +134,7 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition * if c.Output == "" { return nil } - output, err := imagespec.GuessURI(c.Output) + output, err := imagespec.GuessLocation(c.Output) if err != nil { return err } @@ -156,7 +157,7 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, partition * return err } - if partition != nil && output.Scheme == imagespec.URISchemeOCI { + if partition != nil && output.Scheme == schemes.OCI { if err := addImageToPartition(ctx, partition, output.Path); err != nil { return fmt.Errorf("adding built image to partition: %w", err) } diff --git a/internal/cmd/images.go b/internal/cmd/images.go index 96c7aedc..d62edf42 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -15,7 +15,6 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/platforms" - "github.com/distribution/reference" "github.com/opencontainers/go-digest" "unikraft.com/cloud/sdk/controlplane" "unikraft.com/cloud/sdk/platform" @@ -25,6 +24,8 @@ import ( "unikraft.com/x/log" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/reference" + "unikraft.com/x/image-spec/schemes" "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.String()) } func (i Image) Raw() any { @@ -141,7 +141,7 @@ func (Image) Get(ctx context.Context, keys []string) ([]resource.Resource, error eg := joinerrgroup.Group{} for i, key := range keys { eg.Go(func() error { - src, err := imagespec.GuessURI(key) + src, err := imagespec.GuessLocation(key) if err != nil { return fmt.Errorf("parsing image reference %q: %w", key, err) } @@ -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, @@ -206,7 +204,7 @@ func (Image) Delete(ctx context.Context, keys []string) error { eg := joinerrgroup.Group{} for _, key := range keys { eg.Go(func() error { - uri, err := imagespec.GuessURI(key) + uri, err := imagespec.GuessLocation(key) if err != nil { return fmt.Errorf("parsing image reference %q: %w", key, err) } @@ -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 reference.Reference `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.String()) } func (i ImageEntry) Raw() any { @@ -310,16 +308,28 @@ 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 }) @@ -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.String()] = struct{}{} } results := controlplaneResults for _, r := range platformResults { - ref := r.(ImageEntry).Ref.Reference.String() + ref := r.(ImageEntry).Ref.String() if _, ok := seen[ref]; ok { continue } @@ -391,19 +402,40 @@ func listPlatformImages(ctx context.Context) ([]resource.Resource, error) { }) } +// imageKey addresses an image being looked up. +type imageKey struct { + ref reference.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.IsZero() { + subject = entry.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 +455,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 +476,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 +489,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 +506,26 @@ 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) } - var baseDigest digest.Digest - if d, ok := base.(reference.Digested); ok { - baseDigest = d.Digest() + if parsed.Scheme().IsHTTP() { + // A layout served over HTTP is addressed by its URI alone. + return []ImageEntry{{ + controlplaneImage: &image, + Digest: parsed.Digest(), + Ref: types.NewImageRef(parsed), + }}, nil } - base = reference.TrimNamed(base) + baseDigest := parsed.Digest() + base := parsed.WithoutTag().WithoutDigest() if len(image.Tags) == 0 { return nil, nil } - tagged := make([]reference.NamedTagged, 0, len(image.Tags)) + tagged := make([]reference.Reference, 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 +533,16 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, continue } - var taggedRef reference.NamedTagged + var taggedRef reference.Reference if strings.Contains(tagName, "/") || strings.Contains(tagName, ":") { - parsed, err := images.ParseNormalizedNamed(tagName) - if err == nil { - parsed = reference.TagNameOnly(parsed) - if parsedTagged, ok := parsed.(reference.NamedTagged); ok { - taggedRef = parsedTagged + if ref, err := images.ParseRef(tagName); err == nil && !ref.Scheme().IsHTTP() { + if ref = ref.WithDefaultTag(); ref.Tag() != "" { + taggedRef = ref } } } - if taggedRef == nil { - ref, err := reference.WithTag(base, tagName) + if taggedRef.IsZero() { + ref, err := base.WithTag(tagName) if err != nil { return nil, fmt.Errorf("could not parse image tag %q: %w", tagName, err) } @@ -538,7 +565,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 reference.Reference) bool { return t.Tag() == "latest" }); idx > 0 { latest := tagged[idx] @@ -561,14 +588,14 @@ func (ImageEntry) loadFromControlplane(image controlplane.Image) ([]ImageEntry, Digest: tagDigest, } if tagDigest != "" { - canonical, err := reference.WithDigest(tag, tagDigest) + canonical, err := tag.WithDigest(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(tag.Path(), "/"); ok { result.Namespace = ns } results = append(results, result) @@ -581,22 +608,28 @@ 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.NewImageRef(parsedRef), + }}, nil } - base, err := reference.ParseNamed(metro.Index().Host + "/" + reference.Path(parsed)) + // Whatever the URL names, the image is served from the metro's own index. + base, err := parsedRef.WithoutTag().WithoutDigest().WithDomain(metro.Index().Host) if err != nil { return nil, fmt.Errorf("could not construct platform image ref: %w", err) } - var tagged []reference.NamedTagged + var tagged []reference.Reference for _, tag := range image.Tags { if strings.HasPrefix(tag, "sha256:") { // Digest entry, not a tag. @@ -617,7 +650,7 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ if tagVal == "" { continue } - ref, err := reference.WithTag(base, tagVal) + ref, err := base.WithTag(tagVal) if err != nil { return nil, fmt.Errorf("could not parse platform image tag %q: %w", tag, err) } @@ -625,7 +658,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 reference.Reference) bool { return t.Tag() == "latest" }); idx > 0 { latest := tagged[idx] @@ -644,14 +677,14 @@ func (ImageEntry) loadFromPlatform(image platform.Image, metro *config.Metro) ([ Digest: baseDigest, } if baseDigest != "" { - canonical, err := reference.WithDigest(tag, baseDigest) + canonical, err := tag.WithDigest(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(tag.Path(), "/"); ok { result.Namespace = ns } results = append(results, result) @@ -743,11 +776,11 @@ func (cmd ImagesCopyCmd) Run(ctx context.Context, partition *resource.Partition) return err } - src, err := imagespec.GuessURI(cmd.Source) + src, err := imagespec.GuessLocation(cmd.Source) if err != nil { return fmt.Errorf("parsing source image reference: %w", err) } - dest, err := imagespec.GuessURI(cmd.Dest) + dest, err := imagespec.GuessLocation(cmd.Dest) if err != nil { return fmt.Errorf("parsing destination image reference: %w", err) } @@ -767,7 +800,7 @@ func (cmd ImagesCopyCmd) Run(ctx context.Context, partition *resource.Partition) return fmt.Errorf("saving image to destination: %w", err) } - if partition != nil && dest.Scheme == imagespec.URISchemeOCI { + if partition != nil && dest.Scheme == schemes.OCI { if err := addImageToPartition(ctx, partition, dest.Path); err != nil { return fmt.Errorf("adding copied image to partition: %w", err) } @@ -789,14 +822,12 @@ func (c *ImagesListCmd) Run(ctx context.Context, stdio config.Stdio, partition * // addImageToPartition registers an image reference with the partition so it gets // cleaned up during teardown. func addImageToPartition(ctx context.Context, partition *resource.Partition, ref string) error { - named, err := images.ParseNormalizedNamed(ref) + parsed, err := images.ParseRef(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(parsed), } return partition.Add(ctx, img) } diff --git a/internal/cmd/instance_checkpoints.go b/internal/cmd/instance_checkpoints.go index 3015cee5..f6431e17 100644 --- a/internal/cmd/instance_checkpoints.go +++ b/internal/cmd/instance_checkpoints.go @@ -12,8 +12,6 @@ import ( "fmt" "io" - "github.com/distribution/reference" - "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/x/kingkong" @@ -52,9 +50,9 @@ type InstanceCheckpoint struct { Autokill Autokill `field:",embed" mirror:"instance.checkpoint_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time without a restore before the checkpoint is deleted" placeholder:"=" example:"time=24h"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef[reference.Named] `mirror:"instance.image" field:",short"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` + State types.InstanceState `mirror:"instance.state" field:",short"` + Image types.ImageRef `mirror:"instance.image" field:",short"` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short"` diff --git a/internal/cmd/instance_templates.go b/internal/cmd/instance_templates.go index 97a41f52..53f3a508 100644 --- a/internal/cmd/instance_templates.go +++ b/internal/cmd/instance_templates.go @@ -11,8 +11,6 @@ import ( "errors" "fmt" - "github.com/distribution/reference" - "unikraft.com/cloud/sdk/platform" "unikraft.com/cloud/sdk/platform/group" "unikraft.com/x/kingkong" @@ -49,9 +47,9 @@ type InstanceTemplate struct { Autokill Autokill `field:",embed" mirror:"instance.template_autokill" create:"set" edit:"set" flag:"autokill" help:"Autokill options.\n time: time without a clone before the template is deleted" placeholder:"=" example:"time=24h"` - State types.InstanceState `mirror:"instance.state" field:",short"` - Image types.ImageRef[reference.Named] `mirror:"instance.image" field:",short"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` + State types.InstanceState `mirror:"instance.state" field:",short"` + Image types.ImageRef `mirror:"instance.image" field:",short"` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short"` diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index a18a2ae0..b8e5ff3b 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -22,7 +22,6 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/distribution/reference" "github.com/go-json-experiment/json/jsontext" "mvdan.cc/sh/v3/shell" @@ -108,9 +107,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" flag:"image" help:"Image to deploy." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` - PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set" flag:"pull-policy" help:"Image pull policy." placeholder:"policy" example:"always,never,if_not_present"` - Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set" flag:"type" help:"Type of virtual machine to run. \"full\" requires a plan with full VM support." placeholder:"type" example:"micro,full"` + Image types.ImageRef `mirror:"instance.image" field:",short" create:"set" edit:"set" flag:"image" help:"Image to deploy." placeholder:":" example:"nginx:latest,my-app:v1.2.3"` + PullPolicy *platform.PullPolicy `field:"pull-policy,invisible,valueless" create:"set" flag:"pull-policy" help:"Image pull policy." placeholder:"policy" example:"always,never,if_not_present"` + Type_ *platform.InstanceType `mirror:"instance.type" field:"type,long" create:"set" flag:"type" help:"Type of virtual machine to run. \"full\" requires a plan with full VM support." placeholder:"type" example:"micro,full"` Runtime struct { Args InstanceArgs `mirror:"instance.args" field:",short" create:"set" edit:"set" flag:"args" help:"Arguments to pass to the instance." placeholder:"arg"` @@ -1093,8 +1092,8 @@ func instancePatchSpec(path string, op patchOp, value any) (platform.MutableInst } return platform.MutableInstancePropertyAnnotations, value.(map[string]string), nil case "image": - ref := value.(types.ImageRef[reference.Named]).Reference - if ref == nil { + ref := value.(types.ImageRef) + if ref.Reference().IsZero() { return zero, nil, fmt.Errorf("image cannot be empty") } return platform.MutableInstancePropertyImage, ref.String(), nil @@ -1218,7 +1217,7 @@ func (Instance) Create(ctx context.Context, fields []resource.Field) ([]resource case "metro": metro = string(field.Create.Set.(LinkName[Metro])) case "image": - if ref := field.Create.Set.(types.ImageRef[reference.Named]).Reference; ref != nil { + if ref := field.Create.Set.(types.ImageRef); !ref.Reference().IsZero() { imageURL = ref.String() } case "pull-policy": diff --git a/internal/cmd/output_test.go b/internal/cmd/output_test.go index a61da71f..f30c5324 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) { @@ -164,6 +165,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..72574ab7 --- /dev/null +++ b/internal/cmd/testdata/TestOutput/instances-http-oci @@ -0,0 +1,687 @@ +====================================== 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: +annotations: +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: +plugins: +networks: +gpus: +timestamps: + created: never + started: never + stopped: never +scale-to-zero: + enabled: false + policy: + stateful: false + cooldown-time: 0s + notify-time: 0s +autokill: + time: 0s + num-requests: 0 +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": "annotations", + "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": "plugins", + "elem": { + "name": "", + "subfields": [ + { + "name": "name", + "value": "", + "verbosity": "long" + }, + { + "name": "image", + "value": "", + "verbosity": "long" + }, + { + "name": "config", + "value": null, + "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": "autokill", + "subfields": [ + { + "name": "time", + "value": "0s", + "verbosity": "long" + }, + { + "name": "num-requests", + "value": 0, + "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": "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..f1e81dc7 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -7,14 +7,12 @@ package images import ( "context" - "fmt" "github.com/containerd/containerd/v2/core/remotes/docker" - "github.com/distribution/reference" imagespec "unikraft.com/x/image-spec" + "unikraft.com/x/image-spec/reference" "unikraft.com/cli/internal/config" - xreference "unikraft.com/cli/internal/x/reference" ) const DefaultRegistry = "unikraft.io" @@ -56,7 +54,6 @@ func Accessor(ctx context.Context, opts ...AccessorOpt) (*imagespec.Accessor, er imagespec.WithResolver(resolver), imagespec.WithRegistryHosts(options.Hosts), imagespec.WithRegistryHeaders(options.Headers), - imagespec.WithReferenceParser(ParseNormalizedNamed), ), nil } @@ -80,33 +77,27 @@ func WithInsecureRegistries() AccessorOpt { } } -func ParseNormalizedNamed(key string) (reference.Named, error) { - return ParseNormalizedNamedMetro(nil, key) +// ParseRef parses an image identifier without metro context. +func ParseRef(key string) (reference.Reference, error) { + return ParseRefMetro(nil, key) } -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 - } - - index := DefaultRegistry +// ParseRefMetro parses an image identifier exchanged with metro, applying its +// index as the default registry domain. +func ParseRefMetro(metro *config.Metro, key string) (reference.Reference, error) { + domain := DefaultRegistry if metro != nil { - index = metro.Index().Host + domain = metro.Index().Host } - return xreference.ParseNormalizedNamed( - key, - xreference.WithDefaultDomain(index), - xreference.WithDefaultPrefix("official/"), - ) + return reference.Parse(key, reference.WithDefaultDomain(domain)) +} + +// Format renders ref for display, in the short form a user types. +func Format(ref reference.Reference) string { + return ref.WithoutDefaultTag().Format(reference.FormatOpts{}) } -func FamiliarString(ref reference.Reference) string { - return xreference.FamiliarString( - ref, - xreference.WithDefaultDomain(DefaultRegistry), - xreference.WithDefaultPrefix("official/"), - ) +// FormatShort renders ref for concise display, eliding the digest. +func FormatShort(ref reference.Reference) string { + return ref.WithoutDefaultTag().Format(reference.FormatOpts{OmitDigest: true}) } diff --git a/internal/integration/image.go b/internal/integration/image.go index 2d13f3c1..e817ef01 100644 --- a/internal/integration/image.go +++ b/internal/integration/image.go @@ -163,7 +163,7 @@ func cleanupSharedImages() { } for _, ref := range refs { - uri, err := imagespec.GuessURI(ref) + uri, err := imagespec.GuessLocation(ref) if err != nil { fmt.Fprintf(os.Stderr, "failed to parse shared image %s: %v\n", ref, err) continue diff --git a/internal/types/image.go b/internal/types/image.go index 33069426..79763ad7 100644 --- a/internal/types/image.go +++ b/internal/types/image.go @@ -6,9 +6,7 @@ package types import ( - "strings" - - "github.com/distribution/reference" + "unikraft.com/x/image-spec/reference" "unikraft.com/cli/internal/images" "unikraft.com/cli/internal/multimetro" @@ -16,64 +14,64 @@ 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 reference.Reference } -func (ir ImageRef[T]) MarshalText() ([]byte, error) { - var zero T - if ir.Reference == zero { - return []byte{}, nil - } - s := images.FamiliarString(ir.Reference) - return []byte(s), nil +// NewImageRef returns an ImageRef for an already-parsed reference. +func NewImageRef(ref reference.Reference) ImageRef { + 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) +// Reference returns the parsed reference, which is the zero Reference when the +// field is unset. +func (ir ImageRef) Reference() reference.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 { +func (ir *ImageRef) UnmarshalText(text []byte) error { if len(text) == 0 { - var zero T - ir.Reference = zero + ir.ref = reference.Reference{} return nil } - ref, err := images.ParseNormalizedNamed(string(text)) + 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 { +// String returns the fully qualified reference, as the platform API expects it. +func (ir ImageRef) String() 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.String(), }, false } 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 -}