diff --git a/docs/apko_file.md b/docs/apko_file.md index 728788bee..fd8a57eaa 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -92,6 +92,10 @@ annotations: foo: bar bar: baz +# optional layer descriptor annotations +layer-annotations: + dev.chainguard.layer.source: apko + # optional layering strategy layering: strategy: origin @@ -247,6 +251,21 @@ Patches to improve the parsing to make it more flexible are welcome. `annotations` defines the set of annotations that should be applied to images and indexes. +### Layer Annotations + +`layer-annotations` defines the set of annotations that should be applied to every layer descriptor +in the image manifest. This is useful for attaching metadata to layers for provenance tracking or +tooling integration. + +Example: + +```yaml +layer-annotations: + dev.chainguard.layer.source: apko +``` + +Layer annotations can also be set via the `--layer-annotations` CLI flag using `key:value` format. + ### Layering `layering` defines a strategy for splitting the filesystem contents into layers. diff --git a/internal/cli/build.go b/internal/cli/build.go index 946623846..668da58c3 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -54,6 +54,7 @@ func buildCmd() *cobra.Command { var extraRepos []string var extraPackages []string var rawAnnotations []string + var rawLayerAnnotations []string var cacheDir string var offline bool var lockfile string @@ -86,6 +87,10 @@ Along the image, apko will generate SBOMs (software bill of materials) describin if err != nil { return fmt.Errorf("parsing annotations from command line: %w", err) } + layerAnnotations, err := parseAnnotations(rawLayerAnnotations) + if err != nil { + return fmt.Errorf("parsing layer annotations from command line: %w", err) + } var sbomGenerators []generator.Generator if writeSBOM && len(sbomFormats) > 0 { @@ -113,6 +118,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin build.WithTags(args[1]), build.WithVCS(withVCS), build.WithAnnotations(annotations), + build.WithLayerAnnotations(layerAnnotations), build.WithCache(cacheDir, offline, apk.NewCache(true)), build.WithLockFile(lockfile), build.WithTempDir(tmp), @@ -134,6 +140,7 @@ Along the image, apko will generate SBOMs (software bill of materials) describin cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include") cmd.Flags().StringSliceVar(&rawAnnotations, "annotations", []string{}, "OCI annotations to add. Separate with colon (key:value)") + cmd.Flags().StringSliceVar(&rawLayerAnnotations, "layer-annotations", []string{}, "OCI annotations to add to every layer descriptor. Separate with colon (key:value)") cmd.Flags().StringVar(&cacheDir, "cache-dir", "", "directory to use for caching apk packages and indexes (default '' means to use system-defined cache directory)") cmd.Flags().BoolVar(&offline, "offline", false, "do not use network to fetch packages (cache must be pre-populated)") cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)") diff --git a/internal/cli/publish.go b/internal/cli/publish.go index 71cbd0ee2..b9fd1a02f 100644 --- a/internal/cli/publish.go +++ b/internal/cli/publish.go @@ -49,6 +49,7 @@ func publish() *cobra.Command { var extraRepos []string var extraPackages []string var rawAnnotations []string + var rawLayerAnnotations []string var withVCS bool var writeSBOM bool var local bool @@ -79,6 +80,10 @@ in a keychain.`, if err != nil { return fmt.Errorf("parsing annotations from command line: %w", err) } + layerAnnotations, err := parseAnnotations(rawLayerAnnotations) + if err != nil { + return fmt.Errorf("parsing layer annotations from command line: %w", err) + } keychain := authn.NewMultiKeychain( authn.DefaultKeychain, @@ -118,6 +123,7 @@ in a keychain.`, build.WithTags(args[1:]...), build.WithVCS(withVCS), build.WithAnnotations(annotations), + build.WithLayerAnnotations(layerAnnotations), build.WithCache(cacheDir, offline, apk.NewCache(true)), build.WithLockFile(lockfile), build.WithTempDir(tmp), @@ -146,6 +152,7 @@ in a keychain.`, cmd.Flags().StringSliceVarP(&extraRepos, "repository-append", "r", []string{}, "path to extra repositories to include") cmd.Flags().StringSliceVarP(&extraPackages, "package-append", "p", []string{}, "extra packages to include") cmd.Flags().StringSliceVar(&rawAnnotations, "annotations", []string{}, "OCI annotations to add. Separate with colon (key:value)") + cmd.Flags().StringSliceVar(&rawLayerAnnotations, "layer-annotations", []string{}, "OCI annotations to add to every layer descriptor. Separate with colon (key:value)") cmd.Flags().StringVar(&cacheDir, "cache-dir", "", "directory to use for caching apk packages and indexes (default '' means to use system-defined cache directory)") cmd.Flags().BoolVar(&offline, "offline", false, "do not use network to fetch packages (cache must be pre-populated)") cmd.Flags().StringVar(&lockfile, "lockfile", "", "a path to .lock.json file (e.g. produced by apko lock) that constraints versions of packages to the listed ones (default '' means no additional constraints)") diff --git a/pkg/build/oci/image.go b/pkg/build/oci/image.go index 7a1583e51..547957ac4 100644 --- a/pkg/build/oci/image.go +++ b/pkg/build/oci/image.go @@ -76,6 +76,12 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L log.Infof("layer digest: %v", digest) log.Infof("layer diffID: %v", diffid) + // Apply uniform layer annotations if configured. + var layerAnns map[string]string + if len(ic.LayerAnnotations) > 0 { + layerAnns = maps.Clone(ic.LayerAnnotations) + } + adds = append(adds, mutate.Addendum{ Layer: layer, History: v1.History{ @@ -84,6 +90,7 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L CreatedBy: "apko", Created: v1.Time{Time: created}, // TODO: Consider per-layer creation time? }, + Annotations: layerAnns, }) } diff --git a/pkg/build/oci/image_test.go b/pkg/build/oci/image_test.go index 3d25aa7ee..4726c444b 100644 --- a/pkg/build/oci/image_test.go +++ b/pkg/build/oci/image_test.go @@ -169,3 +169,50 @@ func TestBuildImageFromLayer(t *testing.T) { }) } } + +func TestBuildImageFromLayersWithAnnotations(t *testing.T) { + layer1 := static.NewLayer([]byte("layer1"), ggcrtypes.OCILayer) + layer2 := static.NewLayer([]byte("layer2"), ggcrtypes.OCILayer) + + now := time.Now() + ctx := context.Background() + + for _, tc := range []struct { + desc string + cfg types.ImageConfiguration + wantLayerAnns []map[string]string + }{{ + desc: "uniform layer annotations", + cfg: types.ImageConfiguration{ + LayerAnnotations: map[string]string{ + "dev.chainguard.layer.source": "apko", + }, + }, + wantLayerAnns: []map[string]string{ + {"dev.chainguard.layer.source": "apko"}, + {"dev.chainguard.layer.source": "apko"}, + }, + }, { + desc: "no annotations", + cfg: types.ImageConfiguration{}, + wantLayerAnns: []map[string]string{nil, nil}, + }} { + t.Run(tc.desc, func(t *testing.T) { + layers := []v1.Layer{layer1, layer2} + img, err := BuildImageFromLayers(ctx, empty.Image, layers, tc.cfg, now, types.ParseArchitecture("")) + require.NoError(t, err) + + manifest, err := img.Manifest() + require.NoError(t, err) + + require.Len(t, manifest.Layers, 2) + for i, desc := range manifest.Layers { + if tc.wantLayerAnns[i] == nil { + require.Empty(t, desc.Annotations, "layer %d should have no annotations", i) + } else { + require.Equal(t, tc.wantLayerAnns[i], desc.Annotations, "layer %d annotations mismatch", i) + } + } + }) + } +} diff --git a/pkg/build/options.go b/pkg/build/options.go index 0e25edd0c..a0d60ecdc 100644 --- a/pkg/build/options.go +++ b/pkg/build/options.go @@ -202,6 +202,18 @@ func WithAnnotations(annotations map[string]string) Option { } } +// WithLayerAnnotations adds layer annotations from commandline to those in the config. +// Commandline layer annotations take precedence. +func WithLayerAnnotations(annotations map[string]string) Option { + return func(bc *Context) error { + if bc.ic.LayerAnnotations == nil { + bc.ic.LayerAnnotations = make(map[string]string) + } + maps.Copy(bc.ic.LayerAnnotations, annotations) + return nil + } +} + // WithCache set the cache directory to use func WithCache(cacheDir string, offline bool, shared *apk.Cache) Option { return func(bc *Context) error { diff --git a/pkg/build/types/image_configuration.go b/pkg/build/types/image_configuration.go index 9675317b4..6f883d1e0 100644 --- a/pkg/build/types/image_configuration.go +++ b/pkg/build/types/image_configuration.go @@ -154,6 +154,15 @@ func (ic *ImageConfiguration) MergeInto(target *ImageConfiguration) error { } } } + if target.LayerAnnotations == nil && ic.LayerAnnotations != nil { + target.LayerAnnotations = maps.Clone(ic.LayerAnnotations) + } else { + for k, v := range ic.LayerAnnotations { + if _, ok := target.LayerAnnotations[k]; !ok { + target.LayerAnnotations[k] = v + } + } + } target.Volumes = slices.Concat(ic.Volumes, target.Volumes) diff --git a/pkg/build/types/schema.json b/pkg/build/types/schema.json index 3572830b0..0d6b98dbd 100644 --- a/pkg/build/types/schema.json +++ b/pkg/build/types/schema.json @@ -147,6 +147,13 @@ "type": "object", "description": "Optional: Annotations to apply to the images manifests" }, + "layer-annotations": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "description": "Optional: Annotations to apply to every layer descriptor in the image manifest" + }, "include": { "type": "string", "description": "Optional: Path to a local file containing additional image configuration\n\nThe included configuration is deep merged with the parent configuration\n\nDeprecated: This will be removed in a future release." diff --git a/pkg/build/types/types.go b/pkg/build/types/types.go index 0555406d7..e0344b86f 100644 --- a/pkg/build/types/types.go +++ b/pkg/build/types/types.go @@ -201,6 +201,8 @@ type ImageConfiguration struct { VCSUrl string `json:"vcs-url,omitempty" yaml:"vcs-url,omitempty"` // Optional: Annotations to apply to the images manifests Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` + // Optional: Annotations to apply to every layer descriptor in the image manifest + LayerAnnotations map[string]string `json:"layer-annotations,omitempty" yaml:"layer-annotations,omitempty"` // Optional: Path to a local file containing additional image configuration // // The included configuration is deep merged with the parent configuration