From 54bb799d1533559f8544654d7012e53c6aff58c3 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Thu, 24 Aug 2023 13:00:25 +0100 Subject: [PATCH 1/4] imagetools: simplify return type of annotation parser Signed-off-by: Justin Chadwell --- util/imagetools/create.go | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/util/imagetools/create.go b/util/imagetools/create.go index e58ed5d4..80f09cfb 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -147,17 +147,15 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]s if err != nil { return nil, ocispec.Descriptor{}, err } - if len(annotations[exptypes.AnnotationIndex]) > 0 { - for k, v := range annotations[exptypes.AnnotationIndex] { + for k, v := range annotations { + switch k.Type { + case 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] { + case exptypes.AnnotationManifestDescriptor: + for i := 0; i < len(newDescs); i++ { + if newDescs[i].Annotations == nil { + newDescs[i].Annotations = map[string]string{} + } if k.Platform == nil || k.PlatformString() == platforms.Format(*newDescs[i].Platform) { newDescs[i].Annotations[k.Key] = v } @@ -296,11 +294,10 @@ func detectMediaType(dt []byte) (string, error) { return images.MediaTypeDockerSchema2ManifestList, nil } -func parseAnnotations(ann map[string]string) (map[string]map[exptypes.AnnotationKey]string, error) { +func parseAnnotations(ann map[string]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) + annotations := make(map[exptypes.AnnotationKey]string) for k, v := range ann { groups := annotationRegexp.FindStringSubmatch(k) if groups == nil { @@ -323,14 +320,14 @@ func parseAnnotations(ann map[string]string) (map[string]map[exptypes.Annotation Platform: ociPlatform, Key: key, } - indexAnnotations[ak] = v + annotations[ak] = v case exptypes.AnnotationManifestDescriptor: ak := exptypes.AnnotationKey{ Type: typ, Platform: ociPlatform, Key: key, } - manifestDescriptorAnnotations[ak] = v + annotations[ak] = v case exptypes.AnnotationManifest: return nil, errors.Errorf("%q annotations are not supported yet", typ) case exptypes.AnnotationIndexDescriptor: @@ -339,8 +336,5 @@ func parseAnnotations(ann map[string]string) (map[string]map[exptypes.Annotation return nil, errors.Errorf("unknown annotation type %q", typ) } } - return map[string]map[exptypes.AnnotationKey]string{ - exptypes.AnnotationIndex: indexAnnotations, - exptypes.AnnotationManifestDescriptor: manifestDescriptorAnnotations, - }, nil + return annotations, nil } From 8fe2070d10387203528a848826877f2c0cd55ab0 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Thu, 24 Aug 2023 13:52:01 +0100 Subject: [PATCH 2/4] imagetools: make annotation parser more generic Signed-off-by: Justin Chadwell --- util/imagetools/create.go | 40 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/util/imagetools/create.go b/util/imagetools/create.go index 80f09cfb..f46aa9e9 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -160,6 +160,10 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]s newDescs[i].Annotations[k.Key] = v } } + case exptypes.AnnotationManifest, "": + return nil, ocispec.Descriptor{}, errors.Errorf("%q annotations are not supported yet", k.Type) + case exptypes.AnnotationIndexDescriptor: + return nil, ocispec.Descriptor{}, errors.Errorf("%q annotations are invalid while creating an image", k.Type) } } } @@ -296,7 +300,7 @@ func detectMediaType(dt []byte) (string, error) { func parseAnnotations(ann map[string]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+)$`) + annotationRegexp := regexp.MustCompile(`^(?:([a-z-]+)(?:\[([A-Za-z0-9_/-]+)\])?:)?(\S+)$`) annotations := make(map[exptypes.AnnotationKey]string) for k, v := range ann { groups := annotationRegexp.FindStringSubmatch(k) @@ -305,6 +309,13 @@ func parseAnnotations(ann map[string]string) (map[exptypes.AnnotationKey]string, } typ, platform, key := groups[1], groups[2], groups[3] + switch typ { + case "": + case exptypes.AnnotationIndex, exptypes.AnnotationIndexDescriptor, exptypes.AnnotationManifest, exptypes.AnnotationManifestDescriptor: + default: + return nil, errors.Errorf("unknown annotation type %q", typ) + } + var ociPlatform *ocispec.Platform if platform != "" { p, err := platforms.Parse(platform) @@ -313,28 +324,13 @@ func parseAnnotations(ann map[string]string) (map[exptypes.AnnotationKey]string, } ociPlatform = &p } - switch typ { - case exptypes.AnnotationIndex: - ak := exptypes.AnnotationKey{ - Type: typ, - Platform: ociPlatform, - Key: key, - } - annotations[ak] = v - case exptypes.AnnotationManifestDescriptor: - ak := exptypes.AnnotationKey{ - Type: typ, - Platform: ociPlatform, - Key: key, - } - annotations[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) + + ak := exptypes.AnnotationKey{ + Type: typ, + Platform: ociPlatform, + Key: key, } + annotations[ak] = v } return annotations, nil } From a59058e8a555700dfabc6e4801529d7609c1f76e Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Thu, 24 Aug 2023 13:55:21 +0100 Subject: [PATCH 3/4] build: add --annotation shortcut flag This extracts the same logic for parsing annotations from the imagetools create command, and allows the same flags to be attached to the build command. These annotations are then merged into all provided exporters. Signed-off-by: Justin Chadwell --- commands/build.go | 13 ++++++++++ commands/imagetools/create.go | 19 +------------- docs/reference/buildx_build.md | 1 + tests/build.go | 45 +++++++++++++++++++++++++++++++++ util/buildflags/export.go | 46 ++++++++++++++++++++++++++++++++++ util/imagetools/create.go | 43 +++---------------------------- 6 files changed, 109 insertions(+), 58 deletions(-) diff --git a/commands/build.go b/commands/build.go index 07ac7d8c..99c00ab9 100644 --- a/commands/build.go +++ b/commands/build.go @@ -54,6 +54,7 @@ import ( type buildOptions struct { allow []string + annotations []string buildArgs []string cacheFrom []string cacheTo []string @@ -159,6 +160,16 @@ func (o *buildOptions) toControllerOptions() (*controllerapi.BuildOptions, error } } + annotations, err := buildflags.ParseAnnotations(o.annotations) + if err != nil { + return nil, err + } + for _, e := range opts.Exports { + for k, v := range annotations { + e.Attrs[k.String()] = v + } + } + opts.CacheFrom, err = buildflags.ParseCacheEntry(o.cacheFrom) if err != nil { return nil, err @@ -458,6 +469,8 @@ func buildCmd(dockerCli command.Cli, rootOpts *rootOptions) *cobra.Command { flags.StringSliceVar(&options.allow, "allow", []string{}, `Allow extra privileged entitlement (e.g., "network.host", "security.insecure")`) + flags.StringArrayVarP(&options.annotations, "annotation", "", []string{}, "Add annotation to the image") + flags.StringArrayVar(&options.buildArgs, "build-arg", []string{}, "Set build-time variables") flags.StringArrayVar(&options.cacheFrom, "cache-from", []string{}, `External cache sources (e.g., "user/app:cache", "type=local,src=path/to/dir")`) diff --git a/commands/imagetools/create.go b/commands/imagetools/create.go index 0307f4b4..48727fb2 100644 --- a/commands/imagetools/create.go +++ b/commands/imagetools/create.go @@ -83,11 +83,6 @@ 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 { @@ -160,7 +155,7 @@ func runCreate(dockerCli command.Cli, in createOptions, args []string) error { } } - dt, desc, err := r.Combine(ctx, srcs, ann) + dt, desc, err := r.Combine(ctx, srcs, in.annotations) if err != nil { return err } @@ -270,18 +265,6 @@ 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 diff --git a/docs/reference/buildx_build.md b/docs/reference/buildx_build.md index bb31cb82..4cb1577d 100644 --- a/docs/reference/buildx_build.md +++ b/docs/reference/buildx_build.md @@ -17,6 +17,7 @@ Start a build |:-------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------|:----------|:----------------------------------------------------------------------------------------------------| | [`--add-host`](https://docs.docker.com/engine/reference/commandline/build/#add-host) | `stringSlice` | | Add a custom host-to-IP mapping (format: `host:ip`) | | [`--allow`](#allow) | `stringSlice` | | Allow extra privileged entitlement (e.g., `network.host`, `security.insecure`) | +| `--annotation` | `stringArray` | | Add annotation to the image | | [`--attest`](#attest) | `stringArray` | | Attestation parameters (format: `type=sbom,generator=image`) | | [`--build-arg`](#build-arg) | `stringArray` | | Set build-time variables | | [`--build-context`](#build-context) | `stringArray` | | Additional build contexts (e.g., name=path) | diff --git a/tests/build.go b/tests/build.go index 34ad5920..8c621b58 100644 --- a/tests/build.go +++ b/tests/build.go @@ -20,6 +20,7 @@ import ( "github.com/moby/buildkit/util/testutil/integration" "github.com/opencontainers/go-digest" "github.com/pkg/errors" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -40,6 +41,7 @@ var buildTests = []func(t *testing.T, sb integration.Sandbox){ testBuildMobyFromLocalImage, testBuildDetailsLink, testBuildProgress, + testBuildAnnotations, } func testBuild(t *testing.T, sb integration.Sandbox) { @@ -313,3 +315,46 @@ func testBuildProgress(t *testing.T, sb integration.Sandbox) { require.Contains(t, string(plainOutput), "[internal] load build definition from Dockerfile") require.Contains(t, string(plainOutput), "[base 1/3] FROM docker.io/library/busybox:latest") } + +func testBuildAnnotations(t *testing.T, sb integration.Sandbox) { + if sb.Name() == "docker" { + t.Skip("annotations not supported on docker worker") + } + + dir := createTestProject(t) + + registry, err := sb.NewRegistry() + if errors.Is(err, integration.ErrRequirements) { + t.Skip(err.Error()) + } + require.NoError(t, err) + target := registry + "/buildx/registry:latest" + + annotations := []string{ + "--annotation", "example1=www", + "--annotation", "index:example2=xxx", + "--annotation", "manifest:example3=yyy", + "--annotation", "manifest-descriptor[" + platforms.DefaultString() + "]:example4=zzz", + } + out, err := buildCmd(sb, withArgs(annotations...), withArgs(fmt.Sprintf("--output=type=image,name=%s,push=true", target), dir)) + require.NoError(t, err, string(out)) + + desc, provider, err := contentutil.ProviderFromRef(target) + require.NoError(t, err) + imgs, err := testutil.ReadImages(sb.Context(), provider, desc) + require.NoError(t, err) + + pk := platforms.Format(platforms.Normalize(platforms.DefaultSpec())) + img := imgs.Find(pk) + require.NotNil(t, img) + + require.NotNil(t, imgs.Index) + assert.Equal(t, "xxx", imgs.Index.Annotations["example2"]) + + require.NotNil(t, img.Manifest) + assert.Equal(t, "www", img.Manifest.Annotations["example1"]) + assert.Equal(t, "yyy", img.Manifest.Annotations["example3"]) + + require.NotNil(t, img.Desc) + assert.Equal(t, "zzz", img.Desc.Annotations["example4"]) +} diff --git a/util/buildflags/export.go b/util/buildflags/export.go index 8f1b73cf..fb66d2a6 100644 --- a/util/buildflags/export.go +++ b/util/buildflags/export.go @@ -2,10 +2,14 @@ package buildflags import ( "encoding/csv" + "regexp" "strings" + "github.com/containerd/containerd/platforms" controllerapi "github.com/docker/buildx/controller/pb" "github.com/moby/buildkit/client" + "github.com/moby/buildkit/exporter/containerimage/exptypes" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" ) @@ -74,3 +78,45 @@ func ParseExports(inp []string) ([]*controllerapi.ExportEntry, error) { } return outs, nil } + +func ParseAnnotations(inp []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+)$`) + annotations := make(map[exptypes.AnnotationKey]string) + for _, inp := range inp { + k, v, ok := strings.Cut(inp, "=") + if !ok { + return nil, errors.Errorf("invalid annotation %q, expected key=value", inp) + } + + groups := annotationRegexp.FindStringSubmatch(k) + if groups == nil { + return nil, errors.Errorf("invalid annotation format, expected :=, got %q", inp) + } + + typ, platform, key := groups[1], groups[2], groups[3] + switch typ { + case "": + case exptypes.AnnotationIndex, exptypes.AnnotationIndexDescriptor, exptypes.AnnotationManifest, exptypes.AnnotationManifestDescriptor: + default: + return nil, errors.Errorf("unknown annotation type %q", typ) + } + + var ociPlatform *ocispecs.Platform + if platform != "" { + p, err := platforms.Parse(platform) + if err != nil { + return nil, errors.Wrapf(err, "invalid platform %q", platform) + } + ociPlatform = &p + } + + ak := exptypes.AnnotationKey{ + Type: typ, + Platform: ociPlatform, + Key: key, + } + annotations[ak] = v + } + return annotations, nil +} diff --git a/util/imagetools/create.go b/util/imagetools/create.go index f46aa9e9..1b55f083 100644 --- a/util/imagetools/create.go +++ b/util/imagetools/create.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "net/url" - "regexp" "strings" "github.com/containerd/containerd/content" @@ -14,6 +13,7 @@ import ( "github.com/containerd/containerd/platforms" "github.com/containerd/containerd/remotes" "github.com/distribution/reference" + "github.com/docker/buildx/util/buildflags" "github.com/moby/buildkit/exporter/containerimage/exptypes" "github.com/moby/buildkit/util/contentutil" "github.com/opencontainers/go-digest" @@ -28,7 +28,7 @@ type Source struct { Ref reference.Named } -func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]string) ([]byte, ocispec.Descriptor, error) { +func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann []string) ([]byte, ocispec.Descriptor, error) { eg, ctx := errgroup.WithContext(ctx) dts := make([][]byte, len(srcs)) @@ -143,7 +143,7 @@ func (r *Resolver) Combine(ctx context.Context, srcs []*Source, ann map[string]s // annotations are only allowed on OCI indexes indexAnnotation := make(map[string]string) if mt == ocispec.MediaTypeImageIndex { - annotations, err := parseAnnotations(ann) + annotations, err := buildflags.ParseAnnotations(ann) if err != nil { return nil, ocispec.Descriptor{}, err } @@ -297,40 +297,3 @@ func detectMediaType(dt []byte) (string, error) { return images.MediaTypeDockerSchema2ManifestList, nil } - -func parseAnnotations(ann map[string]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+)$`) - annotations := 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] - switch typ { - case "": - case exptypes.AnnotationIndex, exptypes.AnnotationIndexDescriptor, exptypes.AnnotationManifest, exptypes.AnnotationManifestDescriptor: - default: - return nil, errors.Errorf("unknown annotation type %q", typ) - } - - 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 - } - - ak := exptypes.AnnotationKey{ - Type: typ, - Platform: ociPlatform, - Key: key, - } - annotations[ak] = v - } - return annotations, nil -} From 0138f2a00f34341bef03f218715e8d84b1461071 Mon Sep 17 00:00:00 2001 From: Justin Chadwell Date: Thu, 24 Aug 2023 14:15:25 +0100 Subject: [PATCH 4/4] bake: add annotations field Signed-off-by: Justin Chadwell --- bake/bake.go | 17 +++++++++++++++++ bake/bake_test.go | 28 ++++++++++++++++++++++++++++ docs/bake-reference.md | 21 +++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/bake/bake.go b/bake/bake.go index 72ca30ec..e3622e2c 100644 --- a/bake/bake.go +++ b/bake/bake.go @@ -589,6 +589,7 @@ type Target struct { // Inherits is the only field that cannot be overridden with --set Inherits []string `json:"inherits,omitempty" hcl:"inherits,optional" cty:"inherits"` + Annotations []string `json:"annotations,omitempty" hcl:"annotations,optional" cty:"annotations"` Attest []string `json:"attest,omitempty" hcl:"attest,optional" cty:"attest"` Context *string `json:"context,omitempty" hcl:"context,optional" cty:"context"` Contexts map[string]string `json:"contexts,omitempty" hcl:"contexts,optional" cty:"contexts"` @@ -620,6 +621,7 @@ var _ hclparser.WithEvalContexts = &Group{} var _ hclparser.WithGetName = &Group{} func (t *Target) normalize() { + t.Annotations = removeDupes(t.Annotations) t.Attest = removeAttestDupes(t.Attest) t.Tags = removeDupes(t.Tags) t.Secrets = removeDupes(t.Secrets) @@ -680,6 +682,9 @@ func (t *Target) Merge(t2 *Target) { if t2.Target != nil { t.Target = t2.Target } + if t2.Annotations != nil { // merge + t.Annotations = append(t.Annotations, t2.Annotations...) + } if t2.Attest != nil { // merge t.Attest = append(t.Attest, t2.Attest...) t.Attest = removeAttestDupes(t.Attest) @@ -766,6 +771,8 @@ func (t *Target) AddOverrides(overrides map[string]Override) error { t.Platforms = o.ArrValue case "output": t.Outputs = o.ArrValue + case "annotations": + t.Annotations = append(t.Annotations, o.ArrValue...) case "attest": t.Attest = append(t.Attest, o.ArrValue...) case "no-cache": @@ -1164,6 +1171,16 @@ func toBuildOpt(t *Target, inp *Input) (*build.Options, error) { return nil, err } + annotations, err := buildflags.ParseAnnotations(t.Annotations) + if err != nil { + return nil, err + } + for _, e := range bo.Exports { + for k, v := range annotations { + e.Attrs[k.String()] = v + } + } + attests, err := buildflags.ParseAttests(t.Attest) if err != nil { return nil, err diff --git a/bake/bake_test.go b/bake/bake_test.go index 32a21bc2..a54e66c9 100644 --- a/bake/bake_test.go +++ b/bake/bake_test.go @@ -1460,3 +1460,31 @@ func TestAttestDuplicates(t *testing.T) { "provenance": ptrstr("type=provenance,mode=max"), }, opts["default"].Attests) } + +func TestAnnotations(t *testing.T) { + fp := File{ + Name: "docker-bake.hcl", + Data: []byte( + `target "app" { + output = ["type=image,name=foo"] + annotations = ["manifest[linux/amd64]:foo=bar"] + }`), + } + ctx := context.TODO() + m, g, err := ReadTargets(ctx, []File{fp}, []string{"app"}, nil, nil) + require.NoError(t, err) + + bo, err := TargetsToBuildOpt(m, &Input{}) + require.NoError(t, err) + + require.Equal(t, 1, len(g)) + require.Equal(t, []string{"app"}, g["default"].Targets) + + require.Equal(t, 1, len(m)) + require.Contains(t, m, "app") + require.Equal(t, "type=image,name=foo", m["app"].Outputs[0]) + require.Equal(t, "manifest[linux/amd64]:foo=bar", m["app"].Annotations[0]) + + require.Len(t, bo["app"].Exports, 1) + require.Equal(t, "bar", bo["app"].Exports[0].Attrs["annotation-manifest[linux/amd64].foo"]) +} diff --git a/docs/bake-reference.md b/docs/bake-reference.md index d767504e..9a9104fa 100644 --- a/docs/bake-reference.md +++ b/docs/bake-reference.md @@ -115,6 +115,7 @@ The following table shows the complete list of attributes that you can assign to | Name | Type | Description | | ----------------------------------------------- | ------- | -------------------------------------------------------------------- | | [`args`](#targetargs) | Map | Build arguments | +| [`annotations`](#targetannotations) | List | Exporter annotations | | [`attest`](#targetattest) | List | Build attestations | | [`cache-from`](#targetcache-from) | List | External cache sources | | [`cache-to`](#targetcache-to) | List | External cache destinations | @@ -171,6 +172,26 @@ target "db" { } ``` +### `target.annotations` + +The `annotations` attribute is a shortcut to allow you to easily set a list of +annotations on the target. + +```hcl +target "default" { + output = ["type=image,name=foo"] + annotations = ["key=value"] +} +``` + +is the same as + +```hcl +target "default" { + output = ["type=image,name=foo,annotation.key=value"] +} +``` + ### `target.attest` The `attest` attribute lets you apply [build attestations][attestations] to the target.