From 18894a8e3ac4fa4c4e47e905e0617cc289cfdab7 Mon Sep 17 00:00:00 2001 From: Qasim Sarfraz Date: Thu, 20 Jul 2023 16:28:52 +0200 Subject: [PATCH 1/2] allow annotations for OCI image index Signed-off-by: Qasim Sarfraz --- build/build.go | 2 +- commands/imagetools/create.go | 21 +++++- docs/reference/buildx_imagetools_create.md | 1 + util/imagetools/create.go | 84 +++++++++++++++++++++- 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/build/build.go b/build/build.go index 1cd20ee243c5..8812718ea3ad 100644 --- a/build/build.go +++ b/build/build.go @@ -1101,7 +1101,7 @@ func BuildWithResultHandler(ctx context.Context, nodes []builder.Node, opt map[s } } - dt, desc, err := itpull.Combine(ctx, srcs) + dt, desc, err := itpull.Combine(ctx, srcs, nil) if err != nil { return err } diff --git a/commands/imagetools/create.go b/commands/imagetools/create.go index 15ecd8895207..0e8e9d4914a7 100644 --- a/commands/imagetools/create.go +++ b/commands/imagetools/create.go @@ -25,6 +25,7 @@ type createOptions struct { builder string files []string tags []string + annotations []string dryrun bool actionAppend bool progress string @@ -82,6 +83,11 @@ func runCreate(dockerCli command.Cli, in createOptions, args []string) error { return errors.Errorf("no repositories specified, please set a reference in tag or source") } + ann, err := parseAnnotations(in.annotations) + if err != nil { + return err + } + var defaultRepo *string if len(repos) == 1 { for repo := range repos { @@ -154,7 +160,7 @@ func runCreate(dockerCli command.Cli, in createOptions, args []string) error { } } - dt, desc, err := r.Combine(ctx, srcs) + dt, desc, err := r.Combine(ctx, srcs, ann) if err != nil { return err } @@ -264,6 +270,18 @@ func parseSource(in string) (*imagetools.Source, error) { return &s, nil } +func parseAnnotations(in []string) (map[string]string, error) { + out := make(map[string]string) + for _, i := range in { + kv := strings.SplitN(i, "=", 2) + if len(kv) != 2 { + return nil, errors.Errorf("invalid annotation %q, expected key=value", in) + } + out[kv[0]] = kv[1] + } + return out, nil +} + func createCmd(dockerCli command.Cli, opts RootOptions) *cobra.Command { var options createOptions @@ -283,6 +301,7 @@ func createCmd(dockerCli command.Cli, opts RootOptions) *cobra.Command { flags.BoolVar(&options.dryrun, "dry-run", false, "Show final image instead of pushing") flags.BoolVar(&options.actionAppend, "append", false, "Append to existing manifest") flags.StringVar(&options.progress, "progress", "auto", `Set type of progress output ("auto", "plain", "tty"). Use plain to show container output`) + flags.StringArrayVarP(&options.annotations, "annotation", "", []string{}, "Add annotation to the image") return cmd } diff --git a/docs/reference/buildx_imagetools_create.md b/docs/reference/buildx_imagetools_create.md index 3b2f648744a6..e5671f0fba1e 100644 --- a/docs/reference/buildx_imagetools_create.md +++ b/docs/reference/buildx_imagetools_create.md @@ -11,6 +11,7 @@ Create a new image based on source images | Name | Type | Default | Description | |:---------------------------------|:--------------|:--------|:-----------------------------------------------------------------------------------------| +| `--annotation` | `stringArray` | | Add annotation to the image | | [`--append`](#append) | | | Append to existing manifest | | [`--builder`](#builder) | `string` | | Override the configured builder instance | | [`--dry-run`](#dry-run) | | | Show final image instead of pushing | diff --git a/util/imagetools/create.go b/util/imagetools/create.go index 3b1057bc4416..dca0ae1f7f32 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "net/url" + "regexp" "strings" "github.com/containerd/containerd/content" @@ -13,6 +14,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/containerd/containerd/remotes" "github.com/docker/distribution/reference" + "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/util/contentutil" "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/specs-go" @@ -26,7 +28,7 @@ type Source struct { Ref reference.Named } -func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec.Descriptor, error) { +func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]string) ([]byte, ocispec.Descriptor, error) { eg, ctx := errgroup.WithContext(ctx) dts := make([][]byte, len(srcs)) @@ -75,7 +77,7 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec } // on single source, return original bytes - if len(srcs) == 1 { + if len(srcs) == 1 && len(ann) == 0 { if mt := srcs[0].Desc.MediaType; mt == images.MediaTypeDockerSchema2ManifestList || mt == ocispec.MediaTypeImageIndex { return dts[0], srcs[0].Desc, nil } @@ -138,12 +140,39 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source) ([]byte, ocispec mt = ocispec.MediaTypeImageIndex } + // annotations are only allowed on OCI indexes + indexAnnotation := make(map[string]string) + if mt == ocispec.MediaTypeImageIndex { + annotations, err := parseAnnotations(ann) + if err != nil { + return nil, ocispec.Descriptor{}, err + } + if len(annotations[exptypes.AnnotationIndex]) > 0 { + for k, v := range annotations[exptypes.AnnotationIndex] { + indexAnnotation[k.Key] = v + } + } + if len(annotations[exptypes.AnnotationManifestDescriptor]) > 0 { + for i := 0; i < len(newDescs); i++ { + if newDescs[i].Annotations == nil { + newDescs[i].Annotations = map[string]string{} + } + for k, v := range annotations[exptypes.AnnotationManifestDescriptor] { + if k.Platform == nil || k.PlatformString() == platforms.Format(*newDescs[i].Platform) { + newDescs[i].Annotations[k.Key] = v + } + } + } + } + } + idxBytes, err := json.MarshalIndent(ocispec.Index{ MediaType: mt, Versioned: specs.Versioned{ SchemaVersion: 2, }, - Manifests: newDescs, + Manifests: newDescs, + Annotations: indexAnnotation, }, "", " ") if err != nil { return nil, ocispec.Descriptor{}, errors.Wrap(err, "failed to marshal index") @@ -266,3 +295,52 @@ func detectMediaType(dt []byte) (string, error) { return images.MediaTypeDockerSchema2ManifestList, nil } + +func parseAnnotations(ann map[string]string) (map[string]map[exptypes.AnnotationKey]string, error) { + // TODO: use buildkit's annotation parser once it supports setting custom prefix and ":" separator + annotationRegexp := regexp.MustCompile(`^([a-z-]+)(?:\[([A-Za-z0-9_/-]+)\])?:(\S+)$`) + indexAnnotations := make(map[exptypes.AnnotationKey]string) + manifestDescriptorAnnotations := make(map[exptypes.AnnotationKey]string) + for k, v := range ann { + groups := annotationRegexp.FindStringSubmatch(k) + if groups == nil { + return nil, errors.Errorf("invalid annotation format, expected :=, got %q", k) + } + + typ, platform, key := groups[1], groups[2], groups[3] + var ociPlatform *ocispec.Platform + if platform != "" { + p, err := platforms.Parse(platform) + if err != nil { + return nil, errors.Wrapf(err, "invalid platform %q", platform) + } + ociPlatform = &p + } + switch typ { + case exptypes.AnnotationIndex: + ak := exptypes.AnnotationKey{ + Type: typ, + Platform: ociPlatform, + Key: key, + } + indexAnnotations[ak] = v + case exptypes.AnnotationManifestDescriptor: + ak := exptypes.AnnotationKey{ + Type: typ, + Platform: ociPlatform, + Key: key, + } + manifestDescriptorAnnotations[ak] = v + case exptypes.AnnotationManifest: + return nil, errors.Errorf("%q annotations are not supported yet", typ) + case exptypes.AnnotationIndexDescriptor: + return nil, errors.Errorf("%q annotations are invalid while creating an image", typ) + default: + return nil, errors.Errorf("unknown annotation type %q", typ) + } + } + return map[string]map[exptypes.AnnotationKey]string{ + exptypes.AnnotationIndex: indexAnnotations, + exptypes.AnnotationManifestDescriptor: manifestDescriptorAnnotations, + }, nil +} From 3ef93e081c7f1bbd8552234bf62c4227ab25838d Mon Sep 17 00:00:00 2001 From: Qasim Sarfraz Date: Tue, 1 Aug 2023 14:24:59 +0200 Subject: [PATCH 2/2] tests: add testImagetoolsAnnotation integration test Signed-off-by: Qasim Sarfraz --- tests/imagetools.go | 101 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 12 deletions(-) diff --git a/tests/imagetools.go b/tests/imagetools.go index 890edcd17d63..26d5216f787a 100644 --- a/tests/imagetools.go +++ b/tests/imagetools.go @@ -2,6 +2,7 @@ package tests import ( "encoding/json" + "os/exec" "testing" "github.com/containerd/containerd/platforms" @@ -14,6 +15,7 @@ import ( var imagetoolsTests = []func(t *testing.T, sb integration.Sandbox){ testImagetoolsInspectAndFilter, + testImagetoolsAnnotation, } func testImagetoolsInspectAndFilter(t *testing.T, sb integration.Sandbox) { @@ -21,18 +23,7 @@ func testImagetoolsInspectAndFilter(t *testing.T, sb integration.Sandbox) { t.Skip("imagetools tests are not driver specific and only run on docker-container") } - dockerfile := []byte(` - FROM scratch - ARG TARGETARCH - COPY foo-${TARGETARCH} /foo - `) - dir := tmpdir( - t, - fstest.CreateFile("Dockerfile", dockerfile, 0600), - fstest.CreateFile("foo-amd64", []byte("foo-amd64"), 0600), - fstest.CreateFile("foo-arm64", []byte("foo-arm64"), 0600), - ) - + dir := createDockerfile(t) registry, err := sb.NewRegistry() if errors.Is(err, integration.ErrRequirements) { t.Skip(err.Error()) @@ -77,3 +68,89 @@ func testImagetoolsInspectAndFilter(t *testing.T, sb integration.Sandbox) { require.Equal(t, idx.Manifests[1].Digest, idx2.Manifests[0].Digest) require.Equal(t, platforms.Format(*idx.Manifests[1].Platform), platforms.Format(*idx2.Manifests[0].Platform)) } + +func testImagetoolsAnnotation(t *testing.T, sb integration.Sandbox) { + if sb.Name() != "docker-container" { + t.Skip("imagetools tests are not driver specific and only run on docker-container") + } + + dir := createDockerfile(t) + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + target := registry + "/buildx/imtools:latest" + + out, err := buildCmd(sb, withArgs("--output", "type=registry,oci-mediatypes=true,name="+target, "--platform=linux/amd64,linux/arm64", "--provenance=false", dir)) + require.NoError(t, err, string(out)) + + cmd := buildxCmd(sb, withArgs("imagetools", "inspect", target, "--raw")) + dt, err := cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + var idx ocispecs.Index + err = json.Unmarshal(dt, &idx) + require.NoError(t, err) + require.Empty(t, idx.Annotations) + + imagetoolsCmd := func(source []string) *exec.Cmd { + args := []string{"imagetools", "create", "-t", target, "--annotation", "index:foo=bar", "--annotation", "index:bar=baz", + "--annotation", "manifest-descriptor:foo=bar", "--annotation", "manifest-descriptor[linux/amd64]:bar=baz"} + args = append(args, source...) + return buildxCmd(sb, withArgs(args...)) + } + sources := [][]string{ + { + target, + }, + { + target + "@" + string(idx.Manifests[0].Digest), + target + "@" + string(idx.Manifests[1].Digest), + }, + } + for _, source := range sources { + cmd = imagetoolsCmd(source) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + newTarget := registry + "/buildx/imtools:annotations" + cmd = buildxCmd(sb, withArgs("imagetools", "create", "-t", newTarget, target)) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + cmd = buildxCmd(sb, withArgs("imagetools", "inspect", newTarget, "--raw")) + dt, err = cmd.CombinedOutput() + require.NoError(t, err, string(dt)) + + err = json.Unmarshal(dt, &idx) + require.NoError(t, err) + require.Len(t, idx.Annotations, 2) + require.Equal(t, "bar", idx.Annotations["foo"]) + require.Equal(t, "baz", idx.Annotations["bar"]) + require.Len(t, idx.Manifests, 2) + for _, mfst := range idx.Manifests { + require.Equal(t, "bar", mfst.Annotations["foo"]) + if platforms.Format(*mfst.Platform) == "linux/amd64" { + require.Equal(t, "baz", mfst.Annotations["bar"]) + } else { + require.Empty(t, mfst.Annotations["bar"]) + } + } + } +} + +func createDockerfile(t *testing.T) string { + dockerfile := []byte(` + FROM scratch + ARG TARGETARCH + COPY foo-${TARGETARCH} /foo + `) + dir := tmpdir( + t, + fstest.CreateFile("Dockerfile", dockerfile, 0600), + fstest.CreateFile("foo-amd64", []byte("foo-amd64"), 0600), + fstest.CreateFile("foo-arm64", []byte("foo-arm64"), 0600), + ) + return dir +}