From a8befbbf64306503e7c79766a3fe97392015ff39 Mon Sep 17 00:00:00 2001 From: Bryce Groff Date: Mon, 23 Feb 2026 20:49:31 -0800 Subject: [PATCH 1/3] add the first pass of layer annotations. --- docs/apko_file.md | 24 ++++++++ internal/cli/build.go | 11 +++- internal/cli/publish.go | 7 +++ pkg/build/build.go | 7 ++- pkg/build/build_test.go | 4 +- pkg/build/layers.go | 44 +++++++++++--- pkg/build/layers_test.go | 26 +++++++++ pkg/build/oci/image.go | 19 ++++++- pkg/build/oci/image_test.go | 79 ++++++++++++++++++++++++++ pkg/build/options.go | 12 ++++ pkg/build/types/image_configuration.go | 9 +++ pkg/build/types/schema.json | 10 ++++ pkg/build/types/types.go | 7 ++- 13 files changed, 240 insertions(+), 19 deletions(-) diff --git a/docs/apko_file.md b/docs/apko_file.md index 728788bee..0dad7e6b2 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -92,10 +92,15 @@ annotations: foo: bar bar: baz +# optional layer descriptor annotations +layer-annotations: + dev.chainguard.layer.source: apko + # optional layering strategy layering: strategy: origin budget: 10 + auto-annotate: true ``` Details of each field can be found below. @@ -247,6 +252,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. @@ -255,5 +275,9 @@ It contains the following children: - `strategy`: The strategy to employ (currently, only "origin" is valid). - `budget`: The number of additional layers apko will use for layering. + - `auto-annotate`: When set to `true`, automatically generates per-layer annotations with package + metadata using the `dev.chainguard.layer.packages` key. Each layer's annotation lists the + `name=version` pairs of all packages in that layer. Only meaningful when a layering strategy is + configured. This is opt-in because it changes the manifest digest. See [layering.md](layering.md) for more information. diff --git a/internal/cli/build.go b/internal/cli/build.go index 946623846..a053b6acd 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)") @@ -267,7 +274,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc if err != nil { return fmt.Errorf("new build for arch %s: %w", arch, err) } - layers, err := bc.BuildLayers(ctx) + layers, perLayerAnnotations, err := bc.BuildLayers(ctx) if err != nil { return fmt.Errorf("building %q layer: %w", arch, err) } @@ -284,7 +291,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc return fmt.Errorf("failed to determine build date epoch: %w", err) } - img, err := oci.BuildImageFromLayers(ctx, bc.BaseImage(), layers, bc.ImageConfiguration(), bde, bc.Arch()) + img, err := oci.BuildImageFromLayers(ctx, bc.BaseImage(), layers, perLayerAnnotations, bc.ImageConfiguration(), bde, bc.Arch()) if err != nil { return fmt.Errorf("failed to build OCI image for %q: %w", arch, err) } 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/build.go b/pkg/build/build.go index 007004540..715ad84e3 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -141,7 +141,8 @@ func (bc *Context) BuildLayer(ctx context.Context) (string, v1.Layer, error) { } // BuildLayers is like BuildLayer but has the potential to return multiple layers. -func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, error) { +// The second return value contains per-layer annotations (may be nil). +func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, []map[string]string, error) { ctx, span := otel.Tracer("apko").Start(ctx, "BuildLayers") defer span.End() @@ -151,10 +152,10 @@ func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, error) { if bc.ic.Layering == nil || (bc.ic.Layering.Strategy == "" && bc.ic.Layering.Budget == 0) { _, layer, err := bc.BuildLayer(ctx) if err != nil { - return nil, err + return nil, nil, err } - return []v1.Layer{layer}, nil + return []v1.Layer{layer}, nil, nil } return bc.buildLayers(ctx) diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go index dc916099c..6d8fc56d0 100644 --- a/pkg/build/build_test.go +++ b/pkg/build/build_test.go @@ -42,7 +42,7 @@ func TestBuildLayers(t *testing.T) { t.Fatal(err) } - layers, err := bc.BuildLayers(ctx) + layers, _, err := bc.BuildLayers(ctx) if err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestBuildLayersWithEmptyLayering(t *testing.T) { } // Should build successfully and return a single layer - layers, err := bc.BuildLayers(ctx) + layers, _, err := bc.BuildLayers(ctx) if err != nil { t.Fatal(err) } diff --git a/pkg/build/layers.go b/pkg/build/layers.go index f0716616a..65892c47c 100644 --- a/pkg/build/layers.go +++ b/pkg/build/layers.go @@ -25,6 +25,7 @@ import ( "os" "path" "slices" + "strings" "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" @@ -33,21 +34,21 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" ) -func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { +func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, []map[string]string, error) { log := clog.FromContext(ctx) if strategy := bc.ic.Layering.Strategy; strategy != "origin" { - return nil, fmt.Errorf("unrecognized layering strategy %q", strategy) + return nil, nil, fmt.Errorf("unrecognized layering strategy %q", strategy) } if bc.ic.Contents.BaseImage != nil { - return nil, fmt.Errorf("layering with %q is unsupported", "baseimage") + return nil, nil, fmt.Errorf("layering with %q is unsupported", "baseimage") } // Build a single fs.FS, the normal way (this writes to bc.fs). diffs, err := bc.buildImage(ctx) if err != nil { - return nil, fmt.Errorf("building filesystem: %w", err) + return nil, nil, fmt.Errorf("building filesystem: %w", err) } pkgs := make([]*apk.Package, 0, len(diffs)) @@ -66,13 +67,13 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { // // TODO: Clean this up when time permits. if err := bc.postBuildSetApk(ctx); err != nil { - return nil, err + return nil, nil, err } // Use our layering strategy to partition packages into a set of Budget groups. groups, err := groupByOriginAndSize(pkgs, bc.ic.Layering.Budget) if err != nil { - return nil, fmt.Errorf("grouping packages: %w", err) + return nil, nil, fmt.Errorf("grouping packages: %w", err) } log.Infof("Building %d layers with budget %d", len(groups), bc.ic.Layering.Budget) @@ -85,7 +86,36 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { } // Then partition that single fs.FS into multiple layers based on our layering strategy. - return splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) + layers, err := splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) + if err != nil { + return nil, nil, err + } + + // Generate per-layer annotations when auto-annotate is enabled. + var perLayerAnnotations []map[string]string + if bc.ic.Layering.AutoAnnotate { + perLayerAnnotations = autoAnnotateLayers(groups) + } + + return layers, perLayerAnnotations, nil +} + +// autoAnnotateLayers generates per-layer annotation maps from package groups. +// The returned slice has one entry per group (package layer); the top layer +// (appended by splitLayers) is not included and gets no auto-annotations. +func autoAnnotateLayers(groups []*group) []map[string]string { + annotations := make([]map[string]string, len(groups)) + for i, g := range groups { + names := make([]string, 0, len(g.pkgs)) + for _, pkg := range g.pkgs { + names = append(names, pkg.Name+"="+pkg.Version) + } + slices.Sort(names) + annotations[i] = map[string]string{ + "dev.chainguard.layer.packages": strings.Join(names, ","), + } + } + return annotations } func replacesGroup(rep string, g *group) (bool, error) { diff --git a/pkg/build/layers_test.go b/pkg/build/layers_test.go index bd5d00243..82b36f3c2 100644 --- a/pkg/build/layers_test.go +++ b/pkg/build/layers_test.go @@ -215,6 +215,32 @@ func TestAlignStacks(t *testing.T) { } } +func TestAutoAnnotateLayers(t *testing.T) { + pkg1 := &apk.Package{Name: "glibc", Version: "2.38-r14"} + pkg2 := &apk.Package{Name: "glibc-locale-posix", Version: "2.38-r14"} + pkg3 := &apk.Package{Name: "crane", Version: "0.19.0-r1"} + + groups := []*group{ + {pkgs: []*apk.Package{pkg1, pkg2}}, + {pkgs: []*apk.Package{pkg3}}, + } + + got := autoAnnotateLayers(groups) + if len(got) != 2 { + t.Fatalf("expected 2 annotation maps, got %d", len(got)) + } + + want0 := "glibc-locale-posix=2.38-r14,glibc=2.38-r14" + if got[0]["dev.chainguard.layer.packages"] != want0 { + t.Errorf("layer 0 packages: got %q, want %q", got[0]["dev.chainguard.layer.packages"], want0) + } + + want1 := "crane=0.19.0-r1" + if got[1]["dev.chainguard.layer.packages"] != want1 { + t.Errorf("layer 1 packages: got %q, want %q", got[1]["dev.chainguard.layer.packages"], want1) + } +} + // NB: this only cares about path func compareStacks(a, b []*file) error { if len(a) != len(b) { diff --git a/pkg/build/oci/image.go b/pkg/build/oci/image.go index 7a1583e51..3cbb912eb 100644 --- a/pkg/build/oci/image.go +++ b/pkg/build/oci/image.go @@ -38,10 +38,10 @@ import ( ) func BuildImageFromLayer(ctx context.Context, baseImage v1.Image, layer v1.Layer, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { - return BuildImageFromLayers(ctx, baseImage, []v1.Layer{layer}, oic, created, arch) + return BuildImageFromLayers(ctx, baseImage, []v1.Layer{layer}, nil, oic, created, arch) } -func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.Layer, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { +func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.Layer, perLayerAnnotations []map[string]string, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { log := clog.FromContext(ctx) // Create a copy to avoid modifying the original ImageConfiguration. @@ -62,7 +62,7 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L } adds := make([]mutate.Addendum, 0, len(layers)) - for _, layer := range layers { + for i, layer := range layers { digest, err := layer.Digest() if err != nil { return nil, fmt.Errorf("could not calculate layer digest: %w", err) @@ -76,6 +76,18 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L log.Infof("layer digest: %v", digest) log.Infof("layer diffID: %v", diffid) + // Build layer descriptor annotations from uniform + per-layer sources. + var layerAnns map[string]string + if len(ic.LayerAnnotations) > 0 || (i < len(perLayerAnnotations) && len(perLayerAnnotations[i]) > 0) { + layerAnns = make(map[string]string) + maps.Copy(layerAnns, ic.LayerAnnotations) + if i < len(perLayerAnnotations) { + maps.Copy(layerAnns, perLayerAnnotations[i]) + // Include layer digest only when auto-annotate is active (per-layer annotations present). + layerAnns["dev.chainguard.layer.digest"] = digest.String() + } + } + adds = append(adds, mutate.Addendum{ Layer: layer, History: v1.History{ @@ -84,6 +96,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..b731c3df9 100644 --- a/pkg/build/oci/image_test.go +++ b/pkg/build/oci/image_test.go @@ -169,3 +169,82 @@ func TestBuildImageFromLayer(t *testing.T) { }) } } + +func TestBuildImageFromLayersWithAnnotations(t *testing.T) { + layer1 := static.NewLayer([]byte("layer1"), ggcrtypes.OCILayer) + layer2 := static.NewLayer([]byte("layer2"), ggcrtypes.OCILayer) + + digest1, err := layer1.Digest() + require.NoError(t, err) + digest2, err := layer2.Digest() + require.NoError(t, err) + + now := time.Now() + ctx := context.Background() + + for _, tc := range []struct { + desc string + cfg types.ImageConfiguration + perLayerAnnotations []map[string]string + wantLayerAnns []map[string]string + }{{ + desc: "uniform layer annotations only", + 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: "per-layer annotations only", + cfg: types.ImageConfiguration{}, + perLayerAnnotations: []map[string]string{ + {"dev.chainguard.layer.packages": "glibc"}, + {"dev.chainguard.layer.packages": "crane"}, + }, + wantLayerAnns: []map[string]string{ + {"dev.chainguard.layer.packages": "glibc", "dev.chainguard.layer.digest": digest1.String()}, + {"dev.chainguard.layer.packages": "crane", "dev.chainguard.layer.digest": digest2.String()}, + }, + }, { + desc: "uniform and per-layer merged", + cfg: types.ImageConfiguration{ + LayerAnnotations: map[string]string{ + "dev.chainguard.layer.source": "apko", + }, + }, + perLayerAnnotations: []map[string]string{ + {"dev.chainguard.layer.packages": "glibc"}, + {"dev.chainguard.layer.packages": "crane"}, + }, + wantLayerAnns: []map[string]string{ + {"dev.chainguard.layer.source": "apko", "dev.chainguard.layer.packages": "glibc", "dev.chainguard.layer.digest": digest1.String()}, + {"dev.chainguard.layer.source": "apko", "dev.chainguard.layer.packages": "crane", "dev.chainguard.layer.digest": digest2.String()}, + }, + }, { + 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.perLayerAnnotations, 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..4134cb41e 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." @@ -246,6 +253,9 @@ }, "budget": { "type": "integer" + }, + "auto-annotate": { + "type": "boolean" } }, "additionalProperties": false, diff --git a/pkg/build/types/types.go b/pkg/build/types/types.go index 0555406d7..2e230d00e 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 @@ -437,8 +439,9 @@ type SBOM struct { } type Layering struct { - Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` - Budget int `json:"budget,omitempty" yaml:"budget,omitempty"` + Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` + Budget int `json:"budget,omitempty" yaml:"budget,omitempty"` + AutoAnnotate bool `json:"auto-annotate,omitempty" yaml:"auto-annotate,omitempty"` } type AdditionalCertificateEntry struct { From 766cc5aad8fa3fde024e6d14da47e18b92059108 Mon Sep 17 00:00:00 2001 From: Bryce Groff Date: Mon, 23 Feb 2026 20:59:53 -0800 Subject: [PATCH 2/3] remove experimental features. --- docs/apko_file.md | 5 ----- internal/cli/build.go | 4 ++-- pkg/build/build.go | 7 +++---- pkg/build/build_test.go | 4 ++-- pkg/build/layers.go | 41 +++++++----------------------------- pkg/build/layers_test.go | 26 ----------------------- pkg/build/oci/image.go | 18 ++++++---------- pkg/build/oci/image_test.go | 42 +++++-------------------------------- pkg/build/types/schema.json | 3 --- pkg/build/types/types.go | 5 ++--- 10 files changed, 28 insertions(+), 127 deletions(-) diff --git a/docs/apko_file.md b/docs/apko_file.md index 0dad7e6b2..fd8a57eaa 100644 --- a/docs/apko_file.md +++ b/docs/apko_file.md @@ -100,7 +100,6 @@ layer-annotations: layering: strategy: origin budget: 10 - auto-annotate: true ``` Details of each field can be found below. @@ -275,9 +274,5 @@ It contains the following children: - `strategy`: The strategy to employ (currently, only "origin" is valid). - `budget`: The number of additional layers apko will use for layering. - - `auto-annotate`: When set to `true`, automatically generates per-layer annotations with package - metadata using the `dev.chainguard.layer.packages` key. Each layer's annotation lists the - `name=version` pairs of all packages in that layer. Only meaningful when a layering strategy is - configured. This is opt-in because it changes the manifest digest. See [layering.md](layering.md) for more information. diff --git a/internal/cli/build.go b/internal/cli/build.go index a053b6acd..668da58c3 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -274,7 +274,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc if err != nil { return fmt.Errorf("new build for arch %s: %w", arch, err) } - layers, perLayerAnnotations, err := bc.BuildLayers(ctx) + layers, err := bc.BuildLayers(ctx) if err != nil { return fmt.Errorf("building %q layer: %w", arch, err) } @@ -291,7 +291,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc return fmt.Errorf("failed to determine build date epoch: %w", err) } - img, err := oci.BuildImageFromLayers(ctx, bc.BaseImage(), layers, perLayerAnnotations, bc.ImageConfiguration(), bde, bc.Arch()) + img, err := oci.BuildImageFromLayers(ctx, bc.BaseImage(), layers, bc.ImageConfiguration(), bde, bc.Arch()) if err != nil { return fmt.Errorf("failed to build OCI image for %q: %w", arch, err) } diff --git a/pkg/build/build.go b/pkg/build/build.go index 715ad84e3..007004540 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -141,8 +141,7 @@ func (bc *Context) BuildLayer(ctx context.Context) (string, v1.Layer, error) { } // BuildLayers is like BuildLayer but has the potential to return multiple layers. -// The second return value contains per-layer annotations (may be nil). -func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, []map[string]string, error) { +func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, error) { ctx, span := otel.Tracer("apko").Start(ctx, "BuildLayers") defer span.End() @@ -152,10 +151,10 @@ func (bc *Context) BuildLayers(ctx context.Context) ([]v1.Layer, []map[string]st if bc.ic.Layering == nil || (bc.ic.Layering.Strategy == "" && bc.ic.Layering.Budget == 0) { _, layer, err := bc.BuildLayer(ctx) if err != nil { - return nil, nil, err + return nil, err } - return []v1.Layer{layer}, nil, nil + return []v1.Layer{layer}, nil } return bc.buildLayers(ctx) diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go index 6d8fc56d0..dc916099c 100644 --- a/pkg/build/build_test.go +++ b/pkg/build/build_test.go @@ -42,7 +42,7 @@ func TestBuildLayers(t *testing.T) { t.Fatal(err) } - layers, _, err := bc.BuildLayers(ctx) + layers, err := bc.BuildLayers(ctx) if err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestBuildLayersWithEmptyLayering(t *testing.T) { } // Should build successfully and return a single layer - layers, _, err := bc.BuildLayers(ctx) + layers, err := bc.BuildLayers(ctx) if err != nil { t.Fatal(err) } diff --git a/pkg/build/layers.go b/pkg/build/layers.go index 65892c47c..8a503f8dc 100644 --- a/pkg/build/layers.go +++ b/pkg/build/layers.go @@ -25,7 +25,6 @@ import ( "os" "path" "slices" - "strings" "chainguard.dev/apko/pkg/apk/apk" apkfs "chainguard.dev/apko/pkg/apk/fs" @@ -34,21 +33,21 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" ) -func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, []map[string]string, error) { +func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { log := clog.FromContext(ctx) if strategy := bc.ic.Layering.Strategy; strategy != "origin" { - return nil, nil, fmt.Errorf("unrecognized layering strategy %q", strategy) + return nil, fmt.Errorf("unrecognized layering strategy %q", strategy) } if bc.ic.Contents.BaseImage != nil { - return nil, nil, fmt.Errorf("layering with %q is unsupported", "baseimage") + return nil, fmt.Errorf("layering with %q is unsupported", "baseimage") } // Build a single fs.FS, the normal way (this writes to bc.fs). diffs, err := bc.buildImage(ctx) if err != nil { - return nil, nil, fmt.Errorf("building filesystem: %w", err) + return nil, fmt.Errorf("building filesystem: %w", err) } pkgs := make([]*apk.Package, 0, len(diffs)) @@ -67,13 +66,13 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, []map[string]st // // TODO: Clean this up when time permits. if err := bc.postBuildSetApk(ctx); err != nil { - return nil, nil, err + return nil, err } // Use our layering strategy to partition packages into a set of Budget groups. groups, err := groupByOriginAndSize(pkgs, bc.ic.Layering.Budget) if err != nil { - return nil, nil, fmt.Errorf("grouping packages: %w", err) + return nil, fmt.Errorf("grouping packages: %w", err) } log.Infof("Building %d layers with budget %d", len(groups), bc.ic.Layering.Budget) @@ -88,34 +87,10 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, []map[string]st // Then partition that single fs.FS into multiple layers based on our layering strategy. layers, err := splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) if err != nil { - return nil, nil, err - } - - // Generate per-layer annotations when auto-annotate is enabled. - var perLayerAnnotations []map[string]string - if bc.ic.Layering.AutoAnnotate { - perLayerAnnotations = autoAnnotateLayers(groups) + return nil, err } - return layers, perLayerAnnotations, nil -} - -// autoAnnotateLayers generates per-layer annotation maps from package groups. -// The returned slice has one entry per group (package layer); the top layer -// (appended by splitLayers) is not included and gets no auto-annotations. -func autoAnnotateLayers(groups []*group) []map[string]string { - annotations := make([]map[string]string, len(groups)) - for i, g := range groups { - names := make([]string, 0, len(g.pkgs)) - for _, pkg := range g.pkgs { - names = append(names, pkg.Name+"="+pkg.Version) - } - slices.Sort(names) - annotations[i] = map[string]string{ - "dev.chainguard.layer.packages": strings.Join(names, ","), - } - } - return annotations + return layers, nil } func replacesGroup(rep string, g *group) (bool, error) { diff --git a/pkg/build/layers_test.go b/pkg/build/layers_test.go index 82b36f3c2..bd5d00243 100644 --- a/pkg/build/layers_test.go +++ b/pkg/build/layers_test.go @@ -215,32 +215,6 @@ func TestAlignStacks(t *testing.T) { } } -func TestAutoAnnotateLayers(t *testing.T) { - pkg1 := &apk.Package{Name: "glibc", Version: "2.38-r14"} - pkg2 := &apk.Package{Name: "glibc-locale-posix", Version: "2.38-r14"} - pkg3 := &apk.Package{Name: "crane", Version: "0.19.0-r1"} - - groups := []*group{ - {pkgs: []*apk.Package{pkg1, pkg2}}, - {pkgs: []*apk.Package{pkg3}}, - } - - got := autoAnnotateLayers(groups) - if len(got) != 2 { - t.Fatalf("expected 2 annotation maps, got %d", len(got)) - } - - want0 := "glibc-locale-posix=2.38-r14,glibc=2.38-r14" - if got[0]["dev.chainguard.layer.packages"] != want0 { - t.Errorf("layer 0 packages: got %q, want %q", got[0]["dev.chainguard.layer.packages"], want0) - } - - want1 := "crane=0.19.0-r1" - if got[1]["dev.chainguard.layer.packages"] != want1 { - t.Errorf("layer 1 packages: got %q, want %q", got[1]["dev.chainguard.layer.packages"], want1) - } -} - // NB: this only cares about path func compareStacks(a, b []*file) error { if len(a) != len(b) { diff --git a/pkg/build/oci/image.go b/pkg/build/oci/image.go index 3cbb912eb..547957ac4 100644 --- a/pkg/build/oci/image.go +++ b/pkg/build/oci/image.go @@ -38,10 +38,10 @@ import ( ) func BuildImageFromLayer(ctx context.Context, baseImage v1.Image, layer v1.Layer, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { - return BuildImageFromLayers(ctx, baseImage, []v1.Layer{layer}, nil, oic, created, arch) + return BuildImageFromLayers(ctx, baseImage, []v1.Layer{layer}, oic, created, arch) } -func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.Layer, perLayerAnnotations []map[string]string, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { +func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.Layer, oic types.ImageConfiguration, created time.Time, arch types.Architecture) (v1.Image, error) { log := clog.FromContext(ctx) // Create a copy to avoid modifying the original ImageConfiguration. @@ -62,7 +62,7 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L } adds := make([]mutate.Addendum, 0, len(layers)) - for i, layer := range layers { + for _, layer := range layers { digest, err := layer.Digest() if err != nil { return nil, fmt.Errorf("could not calculate layer digest: %w", err) @@ -76,16 +76,10 @@ func BuildImageFromLayers(ctx context.Context, baseImage v1.Image, layers []v1.L log.Infof("layer digest: %v", digest) log.Infof("layer diffID: %v", diffid) - // Build layer descriptor annotations from uniform + per-layer sources. + // Apply uniform layer annotations if configured. var layerAnns map[string]string - if len(ic.LayerAnnotations) > 0 || (i < len(perLayerAnnotations) && len(perLayerAnnotations[i]) > 0) { - layerAnns = make(map[string]string) - maps.Copy(layerAnns, ic.LayerAnnotations) - if i < len(perLayerAnnotations) { - maps.Copy(layerAnns, perLayerAnnotations[i]) - // Include layer digest only when auto-annotate is active (per-layer annotations present). - layerAnns["dev.chainguard.layer.digest"] = digest.String() - } + if len(ic.LayerAnnotations) > 0 { + layerAnns = maps.Clone(ic.LayerAnnotations) } adds = append(adds, mutate.Addendum{ diff --git a/pkg/build/oci/image_test.go b/pkg/build/oci/image_test.go index b731c3df9..4726c444b 100644 --- a/pkg/build/oci/image_test.go +++ b/pkg/build/oci/image_test.go @@ -174,21 +174,15 @@ func TestBuildImageFromLayersWithAnnotations(t *testing.T) { layer1 := static.NewLayer([]byte("layer1"), ggcrtypes.OCILayer) layer2 := static.NewLayer([]byte("layer2"), ggcrtypes.OCILayer) - digest1, err := layer1.Digest() - require.NoError(t, err) - digest2, err := layer2.Digest() - require.NoError(t, err) - now := time.Now() ctx := context.Background() for _, tc := range []struct { - desc string - cfg types.ImageConfiguration - perLayerAnnotations []map[string]string - wantLayerAnns []map[string]string + desc string + cfg types.ImageConfiguration + wantLayerAnns []map[string]string }{{ - desc: "uniform layer annotations only", + desc: "uniform layer annotations", cfg: types.ImageConfiguration{ LayerAnnotations: map[string]string{ "dev.chainguard.layer.source": "apko", @@ -198,32 +192,6 @@ func TestBuildImageFromLayersWithAnnotations(t *testing.T) { {"dev.chainguard.layer.source": "apko"}, {"dev.chainguard.layer.source": "apko"}, }, - }, { - desc: "per-layer annotations only", - cfg: types.ImageConfiguration{}, - perLayerAnnotations: []map[string]string{ - {"dev.chainguard.layer.packages": "glibc"}, - {"dev.chainguard.layer.packages": "crane"}, - }, - wantLayerAnns: []map[string]string{ - {"dev.chainguard.layer.packages": "glibc", "dev.chainguard.layer.digest": digest1.String()}, - {"dev.chainguard.layer.packages": "crane", "dev.chainguard.layer.digest": digest2.String()}, - }, - }, { - desc: "uniform and per-layer merged", - cfg: types.ImageConfiguration{ - LayerAnnotations: map[string]string{ - "dev.chainguard.layer.source": "apko", - }, - }, - perLayerAnnotations: []map[string]string{ - {"dev.chainguard.layer.packages": "glibc"}, - {"dev.chainguard.layer.packages": "crane"}, - }, - wantLayerAnns: []map[string]string{ - {"dev.chainguard.layer.source": "apko", "dev.chainguard.layer.packages": "glibc", "dev.chainguard.layer.digest": digest1.String()}, - {"dev.chainguard.layer.source": "apko", "dev.chainguard.layer.packages": "crane", "dev.chainguard.layer.digest": digest2.String()}, - }, }, { desc: "no annotations", cfg: types.ImageConfiguration{}, @@ -231,7 +199,7 @@ func TestBuildImageFromLayersWithAnnotations(t *testing.T) { }} { t.Run(tc.desc, func(t *testing.T) { layers := []v1.Layer{layer1, layer2} - img, err := BuildImageFromLayers(ctx, empty.Image, layers, tc.perLayerAnnotations, tc.cfg, now, types.ParseArchitecture("")) + img, err := BuildImageFromLayers(ctx, empty.Image, layers, tc.cfg, now, types.ParseArchitecture("")) require.NoError(t, err) manifest, err := img.Manifest() diff --git a/pkg/build/types/schema.json b/pkg/build/types/schema.json index 4134cb41e..0d6b98dbd 100644 --- a/pkg/build/types/schema.json +++ b/pkg/build/types/schema.json @@ -253,9 +253,6 @@ }, "budget": { "type": "integer" - }, - "auto-annotate": { - "type": "boolean" } }, "additionalProperties": false, diff --git a/pkg/build/types/types.go b/pkg/build/types/types.go index 2e230d00e..e0344b86f 100644 --- a/pkg/build/types/types.go +++ b/pkg/build/types/types.go @@ -439,9 +439,8 @@ type SBOM struct { } type Layering struct { - Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` - Budget int `json:"budget,omitempty" yaml:"budget,omitempty"` - AutoAnnotate bool `json:"auto-annotate,omitempty" yaml:"auto-annotate,omitempty"` + Strategy string `json:"strategy,omitempty" yaml:"strategy,omitempty"` + Budget int `json:"budget,omitempty" yaml:"budget,omitempty"` } type AdditionalCertificateEntry struct { From a3635b686e3d8b343907b077bea333bb1223e8c5 Mon Sep 17 00:00:00 2001 From: Bryce Groff Date: Mon, 23 Feb 2026 21:25:48 -0800 Subject: [PATCH 3/3] revert the change here as it is not needed. --- pkg/build/layers.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/build/layers.go b/pkg/build/layers.go index 8a503f8dc..f0716616a 100644 --- a/pkg/build/layers.go +++ b/pkg/build/layers.go @@ -85,12 +85,7 @@ func (bc *Context) buildLayers(ctx context.Context) ([]v1.Layer, error) { } // Then partition that single fs.FS into multiple layers based on our layering strategy. - layers, err := splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) - if err != nil { - return nil, err - } - - return layers, nil + return splitLayers(ctx, bc.fs, groups, pkgToDiff, bc.o.TempDir()) } func replacesGroup(rep string, g *group) (bool, error) {