diff --git a/bake/bake.go b/bake/bake.go index 72ca30ec270..e3622e2c292 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 32a21bc2732..a54e66c9230 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/commands/build.go b/commands/build.go index 07ac7d8c86a..99c00ab9b0f 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 0307f4b4738..48727fb2307 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/bake-reference.md b/docs/bake-reference.md index d767504efdd..9a9104faf2a 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. diff --git a/docs/reference/buildx_build.md b/docs/reference/buildx_build.md index bb31cb827da..4cb1577dc84 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 34ad5920e3c..8c621b5801a 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 8f1b73cf717..fb66d2a6efb 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 e58ed5d4749..1b55f083357 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,25 +143,27 @@ 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 } - 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 } } + 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) } } } @@ -295,52 +297,3 @@ 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 -}