diff --git a/acceptance/rebaser_test.go b/acceptance/rebaser_test.go index 1b03bf67e..98038f20f 100644 --- a/acceptance/rebaser_test.go +++ b/acceptance/rebaser_test.go @@ -54,5 +54,60 @@ func testRebaser(platformAPI string) func(t *testing.T, when spec.G, it spec.S) h.AssertStringContains(t, err.Error(), "http://host.docker.internal") }) }) + + when("called with layer-patches flag", func() { + it.Before(func() { + h.SkipIf(t, api.MustParse(platformAPI).LessThan("0.13"), "layer-patches requires platform API >= 0.13") + }) + + when("experimental mode is not enabled", func() { + it("errors with experimental feature message", func() { + rebaserOutputImageName := "some-image:tag" + _, _, err := h.DockerRunWithError(t, + rebaserImage, + h.WithFlags( + "--env", "CNB_PLATFORM_API="+platformAPI, + ), + h.WithArgs( + ctrPath(rebaserPath), + "-layer-patches", "/patches.json", + rebaserOutputImageName, + ), + ) + + h.AssertNotNil(t, err) + h.AssertStringContains(t, err.Error(), "Layer Patches") + h.AssertStringContains(t, err.Error(), "experimental") + }) + }) + + when("experimental mode is warn", func() { + it("accepts the flag and warns about experimental feature", func() { + rebaserOutputImageName := "some-image:tag" + // This will fail because the image doesn't exist, but we're testing + // that the experimental flag is accepted + _, _, err := h.DockerRunWithError(t, + rebaserImage, + h.WithFlags( + "--env", "CNB_PLATFORM_API="+platformAPI, + "--env", "CNB_EXPERIMENTAL_MODE=warn", + ), + h.WithArgs( + ctrPath(rebaserPath), + "-layer-patches", "/patches.json", + rebaserOutputImageName, + ), + ) + // we don't actually rebase here - this is expected to fail but we are testing that the experimental behavior is working + + // Should not error on the experimental feature itself + // Error should be about something else (like image not found), + // not about experimental mode being disabled + h.AssertStringDoesNotContain(t, err.Error(), "experimental features are disabled") + // Should warn about experimental feature in the error message + h.AssertStringContains(t, err.Error(), "Layer Patches") + }) + }) + }) } } diff --git a/acceptance/testdata/rebaser/app_image_with_layer_metadata.json b/acceptance/testdata/rebaser/app_image_with_layer_metadata.json new file mode 100644 index 000000000..52fca3ac6 --- /dev/null +++ b/acceptance/testdata/rebaser/app_image_with_layer_metadata.json @@ -0,0 +1,22 @@ +{ + "buildpacks": [ + { + "key": "example/java-buildpack", + "layers": { + "jre": { + "data": { + "artifact": { + "version": "17.0.1" + } + }, + "launch": true, + "sha": "sha256:original-jre-layer-sha" + } + } + } + ], + "runImage": { + "topLayer": "sha256:run-image-top-layer", + "reference": "some-run-image-digest" + } +} diff --git a/acceptance/testdata/rebaser/container/patches.json b/acceptance/testdata/rebaser/container/patches.json new file mode 100644 index 000000000..7e8b03310 --- /dev/null +++ b/acceptance/testdata/rebaser/container/patches.json @@ -0,0 +1,12 @@ +{ + "patches": [ + { + "buildpack": "example/java-buildpack", + "layer": "jre", + "data": { + "artifact.version": "17.0.*" + }, + "patch-image": "PATCH_IMAGE_PLACEHOLDER" + } + ] +} diff --git a/acceptance/testdata/rebaser/patch_image_metadata.json b/acceptance/testdata/rebaser/patch_image_metadata.json new file mode 100644 index 000000000..314bd4529 --- /dev/null +++ b/acceptance/testdata/rebaser/patch_image_metadata.json @@ -0,0 +1,22 @@ +{ + "buildpacks": [ + { + "key": "example/java-buildpack", + "layers": { + "jre": { + "data": { + "artifact": { + "version": "17.0.2" + } + }, + "launch": true, + "sha": "sha256:patched-jre-layer-sha" + } + } + } + ], + "runImage": { + "topLayer": "sha256:patch-run-image-top-layer", + "reference": "patch-run-image-digest" + } +} diff --git a/cmd/lifecycle/cli/flags.go b/cmd/lifecycle/cli/flags.go index 29458e42c..1ed6f5767 100644 --- a/cmd/lifecycle/cli/flags.go +++ b/cmd/lifecycle/cli/flags.go @@ -180,6 +180,11 @@ func FlagInsecureRegistries(insecureRegistries *str.Slice) { flagSet.Var(insecureRegistries, "insecure-registry", "insecure registries") } +// FlagLayerPatches sets the path to a JSON file describing layer patches to apply during rebase. +func FlagLayerPatches(layerPatchesPath *string) { + flagSet.StringVar(layerPatchesPath, "layer-patches", *layerPatchesPath, "path to layer patches JSON file") +} + // deprecated // DeprecatedFlagRunImage sets the run image diff --git a/cmd/lifecycle/rebaser.go b/cmd/lifecycle/rebaser.go index 23f6e9e1d..6bd17c610 100644 --- a/cmd/lifecycle/rebaser.go +++ b/cmd/lifecycle/rebaser.go @@ -34,6 +34,7 @@ type rebaseCmd struct { func (r *rebaseCmd) DefineFlags() { if r.PlatformAPI.AtLeast("0.13") { cli.FlagInsecureRegistries(&r.InsecureRegistries) + cli.FlagLayerPatches(&r.LayerPatchesPath) } if r.PlatformAPI.AtLeast("0.12") { cli.FlagForceRebase(&r.ForceRebase) @@ -61,6 +62,14 @@ func (r *rebaseCmd) Args(nargs int, args []string) error { if err := platform.ResolveInputs(platform.Rebase, r.LifecycleInputs, cmd.DefaultLogger); err != nil { return cmd.FailErrCode(err, cmd.CodeForInvalidArgs, "resolve inputs") } + + // Layer patches is an experimental feature + if r.LayerPatchesPath != "" { + if err := platform.GuardExperimental(platform.FeatureLayerPatches, cmd.DefaultLogger); err != nil { + return cmd.FailErrCode(err, cmd.CodeForInvalidArgs, "experimental feature") + } + } + var err error if !r.UseDaemon { // We may need to read the application image in order to know the run image, so @@ -126,12 +135,30 @@ func (r *rebaseCmd) Exec() error { return cmd.FailErr(err, "access run image") } + // Load layer patches if specified + var layerPatches files.LayerPatchesFile + if r.LayerPatchesPath != "" { + layerPatches, err = files.Handler.ReadLayerPatches(r.LayerPatchesPath) + if err != nil { + return cmd.FailErrCode(err, r.CodeFor(platform.RebaseError), "read layer patches") + } + } + rebaser := &phase.Rebaser{ Logger: cmd.DefaultLogger, PlatformAPI: r.PlatformAPI, Force: r.ForceRebase, } - report, err := rebaser.Rebase(r.appImage, newBaseImage, r.OutputImageRef, r.AdditionalTags) + + opts := phase.RebaseOpts{ + LayerPatches: layerPatches, + Keychain: r.keychain, + InsecureRegistries: r.InsecureRegistries, + UseDaemon: r.UseDaemon, + DockerClient: r.docker, + } + + report, err := rebaser.Rebase(r.appImage, newBaseImage, r.OutputImageRef, r.AdditionalTags, opts) if err != nil { return cmd.FailErrCode(err, r.CodeFor(platform.RebaseError), "rebase") } diff --git a/internal/patch/image_loader.go b/internal/patch/image_loader.go new file mode 100644 index 000000000..24681c2cc --- /dev/null +++ b/internal/patch/image_loader.go @@ -0,0 +1,139 @@ +// Package patch provides functionality for patching buildpack-contributed layers +// in OCI images during the rebase phase of the Cloud Native Buildpacks lifecycle. +package patch + +import ( + "fmt" + + "github.com/buildpacks/imgutil" + "github.com/buildpacks/imgutil/local" + "github.com/buildpacks/imgutil/remote" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/moby/moby/client" + + "github.com/buildpacks/lifecycle/image" + "github.com/buildpacks/lifecycle/log" + "github.com/buildpacks/lifecycle/platform" + "github.com/buildpacks/lifecycle/platform/files" +) + +// ImageLoader handles loading patch images from registries or the local Docker daemon. +type ImageLoader struct { + Keychain authn.Keychain + InsecureRegistries []string + Logger log.Logger + UseDaemon bool + DockerClient client.APIClient +} + +// NewImageLoader creates a new ImageLoader. +func NewImageLoader(keychain authn.Keychain, insecureRegistries []string, logger log.Logger, useDaemon bool, dockerClient client.APIClient) *ImageLoader { + return &ImageLoader{ + Keychain: keychain, + InsecureRegistries: insecureRegistries, + Logger: logger, + UseDaemon: useDaemon, + DockerClient: dockerClient, + } +} + +// LoadPatchImage loads a patch image from the registry. +// It tries the primary image first, then falls back to mirrors if the primary fails. +// For multi-arch images, it selects the matching OS/arch variant. +// Returns nil without error if no matching variant is found (skip with warning). +func (l *ImageLoader) LoadPatchImage(patch files.LayerPatch, targetOS, targetArch, targetVariant string) (imgutil.Image, files.LayersMetadataCompat, error) { + // Try primary image first + img, md, err := l.tryLoadImage(patch.PatchImage, targetOS, targetArch, targetVariant) + if err == nil && img != nil { + return img, md, nil + } + + primaryErr := err + if primaryErr == nil { + primaryErr = fmt.Errorf("no matching variant found") + } + + // Try mirrors as fallback + for _, mirror := range patch.PatchImageMirrors { + l.Logger.Debugf("Primary patch image %s failed, trying mirror: %s", patch.PatchImage, mirror) + img, md, err = l.tryLoadImage(mirror, targetOS, targetArch, targetVariant) + if err == nil && img != nil { + return img, md, nil + } + } + + // All attempts failed + return nil, files.LayersMetadataCompat{}, fmt.Errorf("failed to load patch image %s (and %d mirrors): %w", + patch.PatchImage, len(patch.PatchImageMirrors), primaryErr) +} + +// tryLoadImage attempts to load a single image reference. +func (l *ImageLoader) tryLoadImage(imageRef, targetOS, targetArch, targetVariant string) (imgutil.Image, files.LayersMetadataCompat, error) { + var img imgutil.Image + var err error + + if l.UseDaemon { + img, err = local.NewImage( + imageRef, + l.DockerClient, + local.FromBaseImage(imageRef), + ) + } else { + opts := []imgutil.ImageOption{ + remote.FromBaseImage(imageRef), + } + opts = append(opts, image.GetInsecureOptions(l.InsecureRegistries)...) + img, err = remote.NewImage(imageRef, l.Keychain, opts...) + } + if err != nil { + return nil, files.LayersMetadataCompat{}, fmt.Errorf("failed to access patch image %s: %w", imageRef, err) + } + + if !img.Found() { + return nil, files.LayersMetadataCompat{}, fmt.Errorf("patch image %s not found", imageRef) + } + + // Check OS/arch compatibility + imgOS, err := img.OS() + if err != nil { + return nil, files.LayersMetadataCompat{}, fmt.Errorf("failed to get OS from patch image: %w", err) + } + + imgArch, err := img.Architecture() + if err != nil { + return nil, files.LayersMetadataCompat{}, fmt.Errorf("failed to get architecture from patch image: %w", err) + } + + imgVariant, _ := img.Variant() // Variant may not be set + + // Check if OS/arch matches + if imgOS != targetOS || imgArch != targetArch { + l.Logger.Warnf("Patch image %s has OS/arch %s/%s, but target is %s/%s; skipping", + imageRef, imgOS, imgArch, targetOS, targetArch) + return nil, files.LayersMetadataCompat{}, nil + } + + // Check variant if both are specified + if targetVariant != "" && imgVariant != "" && imgVariant != targetVariant { + l.Logger.Warnf("Patch image %s has variant %s, but target is %s; skipping", + imageRef, imgVariant, targetVariant) + return nil, files.LayersMetadataCompat{}, nil + } + + // Extract metadata from the patch image + md, err := l.extractLayerMetadata(img) + if err != nil { + return nil, files.LayersMetadataCompat{}, fmt.Errorf("failed to extract metadata from patch image: %w", err) + } + + return img, md, nil +} + +// extractLayerMetadata extracts the lifecycle layers metadata from the image label. +func (l *ImageLoader) extractLayerMetadata(img imgutil.Image) (files.LayersMetadataCompat, error) { + var md files.LayersMetadataCompat + if err := image.DecodeLabel(img, platform.LifecycleMetadataLabel, &md); err != nil { + return files.LayersMetadataCompat{}, fmt.Errorf("failed to decode lifecycle metadata label: %w", err) + } + return md, nil +} diff --git a/internal/patch/matcher.go b/internal/patch/matcher.go new file mode 100644 index 000000000..bc6a708c4 --- /dev/null +++ b/internal/patch/matcher.go @@ -0,0 +1,139 @@ +package patch + +import ( + "path/filepath" + "strings" + + "github.com/buildpacks/lifecycle/buildpack" + "github.com/buildpacks/lifecycle/platform/files" +) + +// LayerMatcher provides methods for matching buildpack layers against patch selectors. +type LayerMatcher struct{} + +// NewLayerMatcher creates a new LayerMatcher. +func NewLayerMatcher() *LayerMatcher { + return &LayerMatcher{} +} + +// MatchResult represents a matched layer within the metadata. +type MatchResult struct { + BuildpackIndex int + BuildpackID string + LayerName string + LayerMetadata buildpack.LayerMetadata +} + +// FindMatchingLayers finds all layers in the metadata that match the given patch. +// A patch can match multiple layers if the buildpack appears multiple times or +// if glob patterns match multiple layer names/data values. +func (m *LayerMatcher) FindMatchingLayers(metadata files.LayersMetadataCompat, patch files.LayerPatch) []MatchResult { + var results []MatchResult + + for bpIdx, bp := range metadata.Buildpacks { + // Check if buildpack matches (using glob pattern) + if !matchGlob(patch.Buildpack, bp.ID) { + continue + } + + for layerName, layerMD := range bp.Layers { + // Check if layer name matches (using glob pattern) + if !matchGlob(patch.Layer, layerName) { + continue + } + + // Check if data selectors match + if !m.matchData(layerMD.Data, patch.Data) { + continue + } + + results = append(results, MatchResult{ + BuildpackIndex: bpIdx, + BuildpackID: bp.ID, + LayerName: layerName, + LayerMetadata: layerMD, + }) + } + } + + return results +} + +// matchData checks if the layer data matches all the selectors in the patch. +// Selectors use dot-notation to navigate nested structures and support glob patterns. +func (m *LayerMatcher) matchData(layerData interface{}, selectors map[string]string) bool { + if len(selectors) == 0 { + return true + } + + if layerData == nil { + return false + } + + for path, pattern := range selectors { + value, found := getNestedValue(layerData, path) + if !found { + return false + } + if !matchGlob(pattern, value) { + return false + } + } + + return true +} + +// getNestedValue traverses a nested map structure using dot-notation path. +// For example, "artifact.version" will navigate data["artifact"]["version"]. +func getNestedValue(data interface{}, path string) (string, bool) { + parts := strings.Split(path, ".") + current := data + + for _, part := range parts { + switch v := current.(type) { + case map[string]interface{}: + next, ok := v[part] + if !ok { + return "", false + } + current = next + case map[interface{}]interface{}: + next, ok := v[part] + if !ok { + return "", false + } + current = next + default: + return "", false + } + } + + // Convert final value to string + switch v := current.(type) { + case string: + return v, true + case int: + return strings.TrimSpace(strings.Repeat(" ", v)), false // fallback for non-string + case float64: + // Handle numeric values + return "", false + default: + return "", false + } +} + +// matchGlob performs glob pattern matching. +// Supports patterns like "1.2.*" matching "1.2.0", "1.2.3", etc. +func matchGlob(pattern, value string) bool { + if pattern == "" { + return value == "" + } + + // Use filepath.Match for glob support + matched, err := filepath.Match(pattern, value) + if err != nil { + // If the pattern is invalid, fall back to exact match + return pattern == value + } + return matched +} diff --git a/internal/patch/matcher_test.go b/internal/patch/matcher_test.go new file mode 100644 index 000000000..b8ba739dc --- /dev/null +++ b/internal/patch/matcher_test.go @@ -0,0 +1,267 @@ +package patch_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/buildpack" + "github.com/buildpacks/lifecycle/internal/patch" + "github.com/buildpacks/lifecycle/platform/files" + h "github.com/buildpacks/lifecycle/testhelpers" +) + +func TestMatcher(t *testing.T) { + spec.Run(t, "Matcher", testMatcher, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testMatcher(t *testing.T, when spec.G, it spec.S) { + var matcher *patch.LayerMatcher + + it.Before(func() { + matcher = patch.NewLayerMatcher() + }) + + when("FindMatchingLayers", func() { + when("exact buildpack and layer match", func() { + it("finds the matching layer", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "layer1": {SHA: "sha1"}, + "layer2": {SHA: "sha2"}, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "layer1", + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 1) + h.AssertEq(t, results[0].BuildpackID, "buildpack-a") + h.AssertEq(t, results[0].LayerName, "layer1") + h.AssertEq(t, results[0].LayerMetadata.SHA, "sha1") + }) + }) + + when("glob pattern matches buildpack", func() { + it("finds all matching buildpacks", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "org/buildpack-java", + Layers: map[string]buildpack.LayerMetadata{ + "jre": {SHA: "sha1"}, + }, + }, + { + ID: "org/buildpack-python", + Layers: map[string]buildpack.LayerMetadata{ + "python": {SHA: "sha2"}, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "org/buildpack-*", + Layer: "*", + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 2) + }) + }) + + when("glob pattern matches layer name", func() { + it("finds all matching layers", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "jre-11": {SHA: "sha1"}, + "jre-17": {SHA: "sha2"}, + "other": {SHA: "sha3"}, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "jre-*", + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 2) + }) + }) + + when("data selectors are provided", func() { + when("data matches", func() { + it("includes the layer", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "jre": { + SHA: "sha1", + LayerMetadataFile: buildpack.LayerMetadataFile{ + Data: map[string]interface{}{ + "artifact": map[string]interface{}{ + "version": "17.0.1", + }, + }, + }, + }, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "jre", + Data: map[string]string{ + "artifact.version": "17.*", + }, + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 1) + }) + }) + + when("data does not match", func() { + it("excludes the layer", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "jre": { + SHA: "sha1", + LayerMetadataFile: buildpack.LayerMetadataFile{ + Data: map[string]interface{}{ + "artifact": map[string]interface{}{ + "version": "11.0.1", + }, + }, + }, + }, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "jre", + Data: map[string]string{ + "artifact.version": "17.*", + }, + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 0) + }) + }) + + when("data path does not exist", func() { + it("excludes the layer", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "jre": { + SHA: "sha1", + LayerMetadataFile: buildpack.LayerMetadataFile{ + Data: map[string]interface{}{ + "other": "value", + }, + }, + }, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "jre", + Data: map[string]string{ + "artifact.version": "17.*", + }, + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 0) + }) + }) + }) + + when("no layers match", func() { + it("returns empty results", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "layer1": {SHA: "sha1"}, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-b", + Layer: "layer1", + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 0) + }) + }) + + when("multiple buildpacks have the same ID", func() { + it("matches all occurrences", func() { + metadata := files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "layer1": {SHA: "sha1"}, + }, + }, + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "layer1": {SHA: "sha2"}, + }, + }, + }, + } + layerPatch := files.LayerPatch{ + Buildpack: "buildpack-a", + Layer: "layer1", + } + + results := matcher.FindMatchingLayers(metadata, layerPatch) + + h.AssertEq(t, len(results), 2) + h.AssertEq(t, results[0].BuildpackIndex, 0) + h.AssertEq(t, results[1].BuildpackIndex, 1) + }) + }) + }) +} diff --git a/internal/patch/patcher.go b/internal/patch/patcher.go new file mode 100644 index 000000000..610c58dd5 --- /dev/null +++ b/internal/patch/patcher.go @@ -0,0 +1,264 @@ +package patch + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/buildpacks/imgutil" + "github.com/google/go-containerregistry/pkg/authn" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/moby/moby/client" + + "github.com/buildpacks/lifecycle/buildpack" + "github.com/buildpacks/lifecycle/log" + "github.com/buildpacks/lifecycle/platform/files" +) + +// Result represents the result of applying a single layer patch. +type Result struct { + BuildpackID string + BuildpackIdx int + LayerName string + OriginalSHA string + NewSHA string + NewLayerData interface{} + NewLayerFlags buildpack.LayerMetadataFile +} + +// LayerPatcher handles the patching of buildpack-contributed layers. +type LayerPatcher struct { + Logger log.Logger + Keychain authn.Keychain + InsecureRegistries []string + UseDaemon bool + DockerClient client.APIClient +} + +// NewLayerPatcher creates a new LayerPatcher. +func NewLayerPatcher(logger log.Logger, keychain authn.Keychain, insecureRegistries []string, useDaemon bool, dockerClient client.APIClient) *LayerPatcher { + return &LayerPatcher{ + Logger: logger, + Keychain: keychain, + InsecureRegistries: insecureRegistries, + UseDaemon: useDaemon, + DockerClient: dockerClient, + } +} + +// ApplyPatches applies all patches to the working image. +// This uses all-or-nothing semantics: all patches are validated first, +// and only applied if all validations pass. +// Returns the results, a cleanup function that MUST be called after the image is saved, +// and any error that occurred. +func (p *LayerPatcher) ApplyPatches( + workingImage imgutil.Image, + metadata *files.LayersMetadataCompat, + patches files.LayerPatchesFile, + targetOS, targetArch, targetVariant string, +) ([]Result, func(), error) { + noopCleanup := func() {} + + if len(patches.Patches) == 0 { + return nil, noopCleanup, nil + } + + p.Logger.Infof("Applying %d layer patch(es)", len(patches.Patches)) + + // Create a temp directory for all layer patches + tmpDir, err := os.MkdirTemp("", "layer-patches-") + if err != nil { + return nil, noopCleanup, fmt.Errorf("failed to create temp directory for patches: %w", err) + } + cleanup := func() { + _ = os.RemoveAll(tmpDir) + } + + matcher := NewLayerMatcher() + loader := NewImageLoader(p.Keychain, p.InsecureRegistries, p.Logger, p.UseDaemon, p.DockerClient) + + // Phase 1: Validate all patches and collect the operations to perform + var operations []patchOperation + for i, patch := range patches.Patches { + ops, err := p.validatePatch(patch, i, metadata, matcher, loader, targetOS, targetArch, targetVariant) + if err != nil { + cleanup() + return nil, noopCleanup, fmt.Errorf("validation failed for patch %d (buildpack=%s, layer=%s): %w", + i, patch.Buildpack, patch.Layer, err) + } + operations = append(operations, ops...) + } + + if len(operations) == 0 { + cleanup() + p.Logger.Infof("No layers in this image matched any patch selectors") + return nil, noopCleanup, nil + } + + // Phase 2: Apply all validated operations + var results []Result + for _, op := range operations { + result, err := p.applyOperation(workingImage, metadata, op, tmpDir) + if err != nil { + cleanup() + return nil, noopCleanup, fmt.Errorf("failed to apply patch for %s:%s: %w", op.match.BuildpackID, op.match.LayerName, err) + } + results = append(results, result) + } + + p.Logger.Infof("Successfully applied %d layer patch(es)", len(results)) + return results, cleanup, nil +} + +// patchOperation represents a validated patch operation ready to be applied. +type patchOperation struct { + patchIndex int + match MatchResult + patchImage imgutil.Image + patchMetadata files.LayersMetadataCompat + patchLayerMD buildpack.LayerMetadata +} + +// validatePatch validates a single patch and returns the operations to perform. +// Returns nil operations (not an error) if no layers match - this allows patch files +// to contain many potential patches that may or may not apply to a given app. +func (p *LayerPatcher) validatePatch( + patch files.LayerPatch, + patchIndex int, + metadata *files.LayersMetadataCompat, + matcher *LayerMatcher, + loader *ImageLoader, + targetOS, targetArch, targetVariant string, +) ([]patchOperation, error) { + // Find matching layers in the working image + matches := matcher.FindMatchingLayers(*metadata, patch) + if len(matches) == 0 { + // No matching layers - this is not an error, just skip this patch + p.Logger.Debugf("Skipping patch %d: no matching layers for buildpack=%s, layer=%s", + patchIndex, patch.Buildpack, patch.Layer) + return nil, nil + } + + // Load the patch image + patchImage, patchMD, err := loader.LoadPatchImage(patch, targetOS, targetArch, targetVariant) + if err != nil { + return nil, fmt.Errorf("failed to load patch image: %w", err) + } + if patchImage == nil { + // No matching variant found, skip + p.Logger.Warnf("Skipping patch %d: no matching OS/arch variant in patch image", patchIndex) + return nil, nil + } + + var operations []patchOperation + for _, match := range matches { + // Find the corresponding layer in the patch image metadata + patchLayerMD, err := findPatchLayer(patchMD, match.BuildpackID, match.LayerName) + if err != nil { + return nil, fmt.Errorf("patch image does not contain layer %s for buildpack %s: %w", + match.LayerName, match.BuildpackID, err) + } + + operations = append(operations, patchOperation{ + patchIndex: patchIndex, + match: match, + patchImage: patchImage, + patchMetadata: patchMD, + patchLayerMD: patchLayerMD, + }) + + p.Logger.Debugf("Validated patch for %s:%s (original SHA: %s, new SHA: %s)", + match.BuildpackID, match.LayerName, match.LayerMetadata.SHA, patchLayerMD.SHA) + } + + return operations, nil +} + +// findPatchLayer finds a layer in the patch image metadata. +func findPatchLayer(patchMD files.LayersMetadataCompat, buildpackID, layerName string) (buildpack.LayerMetadata, error) { + for _, bp := range patchMD.Buildpacks { + if bp.ID == buildpackID { + if layerMD, ok := bp.Layers[layerName]; ok { + return layerMD, nil + } + } + } + return buildpack.LayerMetadata{}, fmt.Errorf("layer not found") +} + +// applyOperation applies a single validated patch operation. +// The tmpDir parameter is a directory where temp files will be stored; these files +// must persist until the image is saved, so cleanup is handled by the caller. +func (p *LayerPatcher) applyOperation( + workingImage imgutil.Image, + metadata *files.LayersMetadataCompat, + op patchOperation, + tmpDir string, +) (Result, error) { + // Get the layer from the patch image + // Note: imgutil doesn't support removing individual layers, so the old layer blob + // remains but metadata will point to the new layer + diffID := op.patchLayerMD.SHA + if diffID == "" { + return Result{}, fmt.Errorf("patch layer has no SHA") + } + + // Get the layer reader from the patch image + layerReader, err := op.patchImage.GetLayer(diffID) + if err != nil { + return Result{}, fmt.Errorf("failed to get layer %s from patch image: %w", diffID, err) + } + defer func() { _ = layerReader.Close() }() + + // Write the layer to a temporary file since imgutil requires a file path + // Files are stored in tmpDir and cleaned up by the caller after the image is saved + tmpFile, err := os.CreateTemp(tmpDir, "layer-patch-*.tar") + if err != nil { + return Result{}, fmt.Errorf("failed to create temp file for layer: %w", err) + } + tmpPath := tmpFile.Name() + + _, err = io.Copy(tmpFile, layerReader) + _ = tmpFile.Close() + if err != nil { + return Result{}, fmt.Errorf("failed to write layer to temp file: %w", err) + } + + // Make the path absolute + absPath, err := filepath.Abs(tmpPath) + if err != nil { + return Result{}, fmt.Errorf("failed to get absolute path: %w", err) + } + + // Add the new layer to the working image + // Using AddLayerWithDiffIDAndHistory to preserve the layer's identity + err = workingImage.AddLayerWithDiffIDAndHistory(absPath, diffID, v1.History{}) + if err != nil { + return Result{}, fmt.Errorf("failed to add layer to working image: %w", err) + } + + // Update the metadata for this layer + bpIdx := op.match.BuildpackIndex + layerName := op.match.LayerName + originalSHA := metadata.Buildpacks[bpIdx].Layers[layerName].SHA + + // Update the layer metadata + updatedLayerMD := metadata.Buildpacks[bpIdx].Layers[layerName] + updatedLayerMD.SHA = op.patchLayerMD.SHA + updatedLayerMD.Data = op.patchLayerMD.Data + metadata.Buildpacks[bpIdx].Layers[layerName] = updatedLayerMD + + p.Logger.Infof("Patched layer %s:%s (SHA: %s -> %s)", + op.match.BuildpackID, layerName, originalSHA, op.patchLayerMD.SHA) + + return Result{ + BuildpackID: op.match.BuildpackID, + BuildpackIdx: bpIdx, + LayerName: layerName, + OriginalSHA: originalSHA, + NewSHA: op.patchLayerMD.SHA, + NewLayerData: op.patchLayerMD.Data, + NewLayerFlags: op.patchLayerMD.LayerMetadataFile, + }, nil +} diff --git a/internal/patch/patcher_test.go b/internal/patch/patcher_test.go new file mode 100644 index 000000000..56e8e063e --- /dev/null +++ b/internal/patch/patcher_test.go @@ -0,0 +1,78 @@ +package patch_test + +import ( + "testing" + + "github.com/apex/log" + "github.com/apex/log/handlers/memory" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/buildpack" + "github.com/buildpacks/lifecycle/internal/patch" + "github.com/buildpacks/lifecycle/platform/files" + h "github.com/buildpacks/lifecycle/testhelpers" +) + +func TestPatcher(t *testing.T) { + spec.Run(t, "Patcher", testPatcher, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testPatcher(t *testing.T, when spec.G, it spec.S) { + var ( + patcher *patch.LayerPatcher + logHandler *memory.Handler + ) + + it.Before(func() { + logHandler = memory.New() + patcher = patch.NewLayerPatcher(&log.Logger{Handler: logHandler}, nil, nil, false, nil) + }) + + when("ApplyPatches", func() { + when("no patches provided", func() { + it("returns nil without error", func() { + metadata := &files.LayersMetadataCompat{} + patches := files.LayerPatchesFile{} + + results, cleanup, err := patcher.ApplyPatches(nil, metadata, patches, "linux", "amd64", "") + h.AssertNil(t, err) + h.AssertEq(t, len(results), 0) + if cleanup != nil { + cleanup() + } + }) + }) + + when("patch references non-existent layer", func() { + it("skips the patch without error", func() { + metadata := &files.LayersMetadataCompat{ + Buildpacks: []buildpack.LayersMetadata{ + { + ID: "buildpack-a", + Layers: map[string]buildpack.LayerMetadata{ + "layer1": {SHA: "sha1"}, + }, + }, + }, + } + patches := files.LayerPatchesFile{ + Patches: []files.LayerPatch{ + { + Buildpack: "buildpack-nonexistent", + Layer: "layer1", + PatchImage: "patch-image:latest", + }, + }, + } + + results, cleanup, err := patcher.ApplyPatches(nil, metadata, patches, "linux", "amd64", "") + h.AssertNil(t, err) + h.AssertEq(t, len(results), 0) + if cleanup != nil { + cleanup() + } + }) + }) + }) +} diff --git a/phase/rebaser.go b/phase/rebaser.go index 947cdf251..4be967082 100644 --- a/phase/rebaser.go +++ b/phase/rebaser.go @@ -7,11 +7,14 @@ import ( "strings" "github.com/buildpacks/imgutil" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/moby/moby/client" "github.com/pkg/errors" "github.com/buildpacks/lifecycle/api" "github.com/buildpacks/lifecycle/image" "github.com/buildpacks/lifecycle/internal/encoding" + "github.com/buildpacks/lifecycle/internal/patch" "github.com/buildpacks/lifecycle/internal/str" "github.com/buildpacks/lifecycle/log" "github.com/buildpacks/lifecycle/platform" @@ -25,14 +28,29 @@ var ( msgUnableToSatisfyTargetConstraints = "unable to satisfy target os/arch constraints; new run image: %s, old run image: %s" ) +// Rebaser performs the rebase operation, replacing the base image layers of an application image. type Rebaser struct { Logger log.Logger PlatformAPI *api.Version Force bool } +// RebaseOpts contains optional configuration for the Rebase operation. +type RebaseOpts struct { + // LayerPatches contains configuration for patching buildpack-contributed layers. + LayerPatches files.LayerPatchesFile + // Keychain is used for authenticating to registries when loading patch images. + Keychain authn.Keychain + // InsecureRegistries is a list of registries to access without TLS verification. + InsecureRegistries []string + // UseDaemon indicates whether to load patch images from the local Docker daemon. + UseDaemon bool + // DockerClient is the Docker client for loading images from the daemon. + DockerClient client.APIClient +} + // Rebase changes the underlying base image for an application image. -func (r *Rebaser) Rebase(workingImage imgutil.Image, newBaseImage imgutil.Image, outputImageRef string, additionalNames []string) (files.RebaseReport, error) { +func (r *Rebaser) Rebase(workingImage imgutil.Image, newBaseImage imgutil.Image, outputImageRef string, additionalNames []string, opts RebaseOpts) (files.RebaseReport, error) { defer log.NewMeasurement("Rebaser", r.Logger)() appPlatformAPI, err := workingImage.Env(platform.EnvPlatformAPI) if err != nil { @@ -106,6 +124,19 @@ func (r *Rebaser) Rebase(workingImage imgutil.Image, newBaseImage imgutil.Image, } } + // Apply layer patches if configured (experimental feature) + var patchCleanup func() + if len(opts.LayerPatches.Patches) > 0 { + patchCleanup, err = r.applyLayerPatches(workingImage, &origMetadata, opts) + if err != nil { + return files.RebaseReport{}, fmt.Errorf("apply layer patches: %w", err) + } + } + // Defer cleanup of patch temp files until after image is saved + if patchCleanup != nil { + defer patchCleanup() + } + // set metadata label data, err := json.Marshal(origMetadata) if err != nil { @@ -135,6 +166,25 @@ func (r *Rebaser) Rebase(workingImage imgutil.Image, newBaseImage imgutil.Image, return report, err } +// applyLayerPatches applies buildpack layer patches to the working image. +// Returns a cleanup function that must be called after the image is saved to clean up temp files. +func (r *Rebaser) applyLayerPatches(workingImage imgutil.Image, metadata *files.LayersMetadataCompat, opts RebaseOpts) (func(), error) { + // Get target OS/arch from the working image + targetOS, err := workingImage.OS() + if err != nil { + return nil, fmt.Errorf("failed to get working image OS: %w", err) + } + targetArch, err := workingImage.Architecture() + if err != nil { + return nil, fmt.Errorf("failed to get working image architecture: %w", err) + } + targetVariant, _ := workingImage.Variant() // May not be set + + patcher := patch.NewLayerPatcher(r.Logger, opts.Keychain, opts.InsecureRegistries, opts.UseDaemon, opts.DockerClient) + _, cleanup, err := patcher.ApplyPatches(workingImage, metadata, opts.LayerPatches, targetOS, targetArch, targetVariant) + return cleanup, err +} + func containsName(origMetadata files.LayersMetadataCompat, newBaseName string) bool { if origMetadata.RunImage.Contains(newBaseName) { return true diff --git a/phase/rebaser_test.go b/phase/rebaser_test.go index 0931b8fa7..c52fe1062 100644 --- a/phase/rebaser_test.go +++ b/phase/rebaser_test.go @@ -92,25 +92,25 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { when("#Rebase", func() { when("app image and run image exist", func() { it("updates the base image of the app image", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) it("saves to all names", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertContains(t, fakeAppImage.SavedNames(), "some-repo/app-image", "some-repo/app-image:foo", "some-repo/app-image:bar") }) it("adds all names to report", func() { - report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertContains(t, report.Image.Tags, "some-repo/app-image", "some-repo/app-image:foo", "some-repo/app-image:bar") }) it("sets the top layer in the metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -118,7 +118,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("sets the run image reference in the metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -131,7 +131,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { `{"app": [{"sha": "123456"}], "buildpacks":[{"key": "buildpack.id", "layers": {}}]}`, )) rebaser.Force = true // skip run image validations - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -170,7 +170,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("warns and overrides the existing metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) assertLogEntry(t, logHandler, `new base image 'some-repo/new-base-image' not found in existing run image metadata: {"topLayer":"new-top-layer-sha","reference":"new-run-id","image":"some-run-image-tag-reference","mirrors":["some-run-image-mirror"]}`) @@ -192,7 +192,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("errors", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `rebase app image: new base image 'some-repo/new-base-image' not found in existing run image metadata: {"topLayer":"new-top-layer-sha","reference":"new-run-id","image":"some-run-image-tag-reference","mirrors":["some-run-image-mirror"]}`) }) @@ -209,7 +209,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("doesn't match", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `rebase app image: new base image 'some-run-image-mirror:new-tag' not found in existing run image metadata: {"topLayer":"new-top-layer-sha","reference":"new-run-id","image":"some-run-image-tag-reference","mirrors":["some-run-image-mirror"]}`) }) }) @@ -220,7 +220,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("preserves the existing metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -249,7 +249,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("preserves the existing metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -274,7 +274,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("still matches", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -301,7 +301,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("preserves the existing metadata", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertNil(t, image.DecodeLabel(fakeAppImage, platform.LifecycleMetadataLabel, &md)) @@ -343,7 +343,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("syncs matching labels", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) for _, test := range tests { @@ -381,7 +381,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("syncs matching labels", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) for _, test := range tests { @@ -408,7 +408,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("add the digest to the report", func() { - report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, report.Image.Digest, fakeRemoteDigest) @@ -425,7 +425,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("add the manifest size to the report", func() { - report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, report.Image.ManifestSize, fakeRemoteManifestSize) @@ -439,7 +439,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("doesn't set the manifest size in the report.toml", func() { - report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, report.Image.ManifestSize, int64(0)) @@ -449,7 +449,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { when("image has an ID identifier", func() { it("add the imageID to the report", func() { - report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + report, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, report.Image.ImageID, "some-image-id") @@ -471,7 +471,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("errors", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "app image is not marked as rebasable") }) }) @@ -482,7 +482,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("warns and allows rebase", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) assertLogEntry(t, logHandler, "app image is not marked as rebasable") @@ -497,7 +497,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("allows rebase", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) }) }) @@ -508,7 +508,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("allows rebase", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) }) }) @@ -524,7 +524,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { }) it("allows rebase", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) }) }) @@ -536,7 +536,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"run:mixin-2\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -549,7 +549,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { when("there are no mixin labels", func() { it("allows rebase", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -558,7 +558,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { when("there are invalid mixin labels", func() { it("errors", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "thisisn'tvalid!")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "get app image mixins: failed to unmarshal context of label 'io.buildpacks.stack.mixins': invalid character 'h' in literal true (expecting 'r')") }) }) @@ -567,7 +567,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "null")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "null")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -577,7 +577,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "null")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -587,7 +587,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -597,7 +597,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\", \"mixin-3\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -607,7 +607,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"mixin-2\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -617,7 +617,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"run:mixin-1\", \"run:mixin-2\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -627,7 +627,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("does not allow rebase", func() { h.AssertNil(t, fakeAppImage.SetLabel(platform.MixinsLabel, "[\"mixin-1\", \"run:mixin-2\"]")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.MixinsLabel, "[\"run:mixin-2\"]")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "missing required mixin(s): mixin-1") }) }) @@ -648,7 +648,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase with missing labels", func() { h.AssertNil(t, fakeAppImage.SetOS("")) h.AssertNil(t, fakeNewBaseImage.SetOS("linux")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -656,7 +656,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase with mismatched variants", func() { h.AssertNil(t, fakeAppImage.SetVariant("variant1")) h.AssertNil(t, fakeNewBaseImage.SetVariant("variant2")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -667,7 +667,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "incompatible stack: 'io.buildpacks.stacks.cflinuxfs3' is not compatible with 'io.buildpacks.stacks.bionic'") }) @@ -675,7 +675,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "stack not defined on new base image") }) @@ -683,7 +683,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "stack not defined on app image") }) }) @@ -698,7 +698,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase with missing labels", func() { h.AssertNil(t, fakeAppImage.SetOS("")) h.AssertNil(t, fakeNewBaseImage.SetOS("linux")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -706,7 +706,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { it("allows rebase with mismatched variants", func() { h.AssertNil(t, fakeAppImage.SetVariant("variant1")) h.AssertNil(t, fakeNewBaseImage.SetVariant("variant2")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -717,7 +717,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "incompatible stack: 'io.buildpacks.stacks.cflinuxfs3' is not compatible with 'io.buildpacks.stacks.bionic'") }) @@ -725,7 +725,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "stack not defined on new base image") }) @@ -733,7 +733,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, "stack not defined on app image") }) }) @@ -751,7 +751,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetOS("linux")) h.AssertNil(t, fakeNewBaseImage.SetOS("notlinux")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `unable to satisfy target os/arch constraints; new run image: {"os":"notlinux","arch":"amd64"}, old run image: {"os":"linux","arch":"amd64"}`) }) @@ -759,7 +759,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetArchitecture("amd64")) h.AssertNil(t, fakeNewBaseImage.SetArchitecture("arm64")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `unable to satisfy target os/arch constraints; new run image: {"os":"linux","arch":"arm64"}, old run image: {"os":"linux","arch":"amd64"}`) }) @@ -767,7 +767,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetVariant("variant1")) h.AssertNil(t, fakeNewBaseImage.SetVariant("variant2")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `unable to satisfy target os/arch constraints; new run image: {"os":"linux","arch":"amd64","arch-variant":"variant2"}, old run image: {"os":"linux","arch":"amd64","arch-variant":"variant1"}`) }) @@ -775,7 +775,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel("io.buildpacks.base.distro.name", "distro1")) h.AssertNil(t, fakeNewBaseImage.SetLabel("io.buildpacks.base.distro.name", "distro2")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `unable to satisfy target os/arch constraints; new run image: {"os":"linux","arch":"amd64","distro":{"name":"distro2","version":""}}, old run image: {"os":"linux","arch":"amd64","distro":{"name":"distro1","version":""}}`) }) @@ -783,7 +783,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel("io.buildpacks.base.distro.version", "version1")) h.AssertNil(t, fakeNewBaseImage.SetLabel("io.buildpacks.base.distro.version", "version2")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertError(t, err, `unable to satisfy target os/arch constraints; new run image: {"os":"linux","arch":"amd64","distro":{"name":"","version":"version2"}}, old run image: {"os":"linux","arch":"amd64","distro":{"name":"","version":"version1"}}`) }) }) @@ -798,7 +798,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetOS("linux")) h.AssertNil(t, fakeNewBaseImage.SetOS("notlinux")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) assertLogEntry(t, logHandler, `unable to satisfy target os/arch constraints; new run image: {"os":"notlinux","arch":"amd64"}, old run image: {"os":"linux","arch":"amd64"}`) @@ -811,7 +811,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -820,7 +820,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.bionic")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -829,7 +829,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { h.AssertNil(t, fakeAppImage.SetLabel(platform.StackIDLabel, "")) h.AssertNil(t, fakeNewBaseImage.SetLabel(platform.StackIDLabel, "io.buildpacks.stacks.cflinuxfs3")) - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, fakeAppImage.Name(), additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertEq(t, fakeAppImage.Base(), "some-repo/new-base-image") }) @@ -841,7 +841,7 @@ func testRebaser(t *testing.T, when spec.G, it spec.S) { var outputImageRef = "fizz" it("saves using outputImageRef, not the app image name", func() { - _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, outputImageRef, additionalNames) + _, err := rebaser.Rebase(fakeAppImage, fakeNewBaseImage, outputImageRef, additionalNames, phase.RebaseOpts{}) h.AssertNil(t, err) h.AssertContains(t, fakeAppImage.SavedNames(), append(additionalNames, outputImageRef)...) h.AssertDoesNotContain(t, fakeAppImage.SavedNames(), fakePreviousImage.Name()) diff --git a/platform/defaults.go b/platform/defaults.go index 55ff2407b..c1466ba72 100644 --- a/platform/defaults.go +++ b/platform/defaults.go @@ -237,6 +237,10 @@ const ( const ( // EnvForceRebase is used to force the rebaser to rebase the app image even if the operation is unsafe. EnvForceRebase = "CNB_FORCE_REBASE" + + // EnvLayerPatchesFile is the location of the layer patches JSON file for selective layer patching during rebase. + // This is an experimental feature. + EnvLayerPatchesFile = "CNB_LAYER_PATCHES_FILE" ) var ( diff --git a/platform/experimental_features.go b/platform/experimental_features.go index 59f5c522b..40b185554 100644 --- a/platform/experimental_features.go +++ b/platform/experimental_features.go @@ -6,9 +6,11 @@ import ( "github.com/buildpacks/lifecycle/log" ) +// Experimental feature names for use with GuardExperimental. const ( - FeatureDockerfiles = "Dockerfiles" - LayoutFormat = "export to OCI layout format" + FeatureDockerfiles = "Dockerfiles" + FeatureLayerPatches = "Layer Patches" + LayoutFormat = "export to OCI layout format" ) var ExperimentalMode = envOrDefault(EnvExperimentalMode, DefaultExperimentalMode) diff --git a/platform/files/handle_toml.go b/platform/files/handle_toml.go index 2885a46cb..1a1a28fb3 100644 --- a/platform/files/handle_toml.go +++ b/platform/files/handle_toml.go @@ -2,6 +2,7 @@ package files import ( + "encoding/json" "fmt" "os" @@ -211,3 +212,22 @@ func (h *TOMLHandler) ReadSystem(path string, logger log.Logger) (System, error) } return system.System, nil } + +// ReadLayerPatches reads the provided layer patches JSON file. +// It returns an error if the file does not exist or cannot be parsed. +func (h *TOMLHandler) ReadLayerPatches(path string) (LayerPatchesFile, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is provided by platform operator + if err != nil { + if os.IsNotExist(err) { + return LayerPatchesFile{}, fmt.Errorf("layer patches file not found at path %q", path) + } + return LayerPatchesFile{}, fmt.Errorf("failed to read layer patches file: %w", err) + } + + var patches LayerPatchesFile + if err := json.Unmarshal(data, &patches); err != nil { + return LayerPatchesFile{}, fmt.Errorf("failed to parse layer patches file: %w", err) + } + + return patches, nil +} diff --git a/platform/files/layer_patches.go b/platform/files/layer_patches.go new file mode 100644 index 000000000..f39502379 --- /dev/null +++ b/platform/files/layer_patches.go @@ -0,0 +1,29 @@ +package files + +// LayerPatchesFile contains the configuration for patching buildpack-contributed layers during rebase. +// This is an experimental feature that allows selective patching of layers from an OCI patch image. +type LayerPatchesFile struct { + Patches []LayerPatch `json:"patches"` +} + +// LayerPatch describes a single layer patch operation. +type LayerPatch struct { + // Buildpack matches the buildpack key in io.buildpacks.lifecycle.metadata label. + // This corresponds to buildpacks[].key in the metadata. + Buildpack string `json:"buildpack"` + + // Layer matches a layer name within the buildpack's layers. + // This corresponds to buildpacks[].layers key in the metadata. + Layer string `json:"layer"` + + // Data contains dot-notation selectors with optional glob support for matching layer data. + // For example, {"artifact.version": "1.2.*"} will match layers where the nested + // artifact.version field matches the glob pattern "1.2.*". + Data map[string]string `json:"data,omitempty"` + + // PatchImage is the primary OCI image reference containing the patch layers. + PatchImage string `json:"patch-image"` + + // PatchImageMirrors are fallback image references to try if the primary fails. + PatchImageMirrors []string `json:"patch-image.mirrors,omitempty"` +} diff --git a/platform/files/layer_patches_test.go b/platform/files/layer_patches_test.go new file mode 100644 index 000000000..da4daa8c4 --- /dev/null +++ b/platform/files/layer_patches_test.go @@ -0,0 +1,149 @@ +package files_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/platform/files" + h "github.com/buildpacks/lifecycle/testhelpers" +) + +func TestLayerPatches(t *testing.T) { + spec.Run(t, "LayerPatches", testLayerPatches, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testLayerPatches(t *testing.T, when spec.G, it spec.S) { + var ( + tmpDir string + ) + + it.Before(func() { + var err error + tmpDir, err = os.MkdirTemp("", "layer-patches-test") + h.AssertNil(t, err) + }) + + it.After(func() { + _ = os.RemoveAll(tmpDir) + }) + + when("ReadLayerPatches", func() { + when("file exists with valid JSON", func() { + it("parses the patches file", func() { + content := `{ + "patches": [ + { + "buildpack": "org/buildpack-java", + "layer": "jre", + "data": {"artifact.version": "17.*"}, + "patch-image": "registry.example.com/patches/java:17", + "patch-image.mirrors": ["mirror.example.com/patches/java:17"] + } + ] + }` + path := filepath.Join(tmpDir, "patches.json") + h.AssertNil(t, os.WriteFile(path, []byte(content), 0600)) + + patches, err := files.Handler.ReadLayerPatches(path) + h.AssertNil(t, err) + + h.AssertEq(t, len(patches.Patches), 1) + h.AssertEq(t, patches.Patches[0].Buildpack, "org/buildpack-java") + h.AssertEq(t, patches.Patches[0].Layer, "jre") + h.AssertEq(t, patches.Patches[0].Data["artifact.version"], "17.*") + h.AssertEq(t, patches.Patches[0].PatchImage, "registry.example.com/patches/java:17") + h.AssertEq(t, len(patches.Patches[0].PatchImageMirrors), 1) + h.AssertEq(t, patches.Patches[0].PatchImageMirrors[0], "mirror.example.com/patches/java:17") + }) + }) + + when("file exists with multiple patches", func() { + it("parses all patches", func() { + content := `{ + "patches": [ + { + "buildpack": "buildpack-a", + "layer": "layer1", + "patch-image": "image1" + }, + { + "buildpack": "buildpack-b", + "layer": "layer2", + "patch-image": "image2" + } + ] + }` + path := filepath.Join(tmpDir, "patches.json") + h.AssertNil(t, os.WriteFile(path, []byte(content), 0600)) + + patches, err := files.Handler.ReadLayerPatches(path) + h.AssertNil(t, err) + + h.AssertEq(t, len(patches.Patches), 2) + }) + }) + + when("file exists with empty patches", func() { + it("returns empty patches", func() { + content := `{"patches": []}` + path := filepath.Join(tmpDir, "patches.json") + h.AssertNil(t, os.WriteFile(path, []byte(content), 0600)) + + patches, err := files.Handler.ReadLayerPatches(path) + h.AssertNil(t, err) + + h.AssertEq(t, len(patches.Patches), 0) + }) + }) + + when("file does not exist", func() { + it("returns an error", func() { + path := filepath.Join(tmpDir, "nonexistent.json") + + _, err := files.Handler.ReadLayerPatches(path) + + h.AssertNotNil(t, err) + h.AssertStringContains(t, err.Error(), "layer patches file not found") + }) + }) + + when("file contains invalid JSON", func() { + it("returns an error", func() { + content := `{"patches": invalid}` + path := filepath.Join(tmpDir, "patches.json") + h.AssertNil(t, os.WriteFile(path, []byte(content), 0600)) + + _, err := files.Handler.ReadLayerPatches(path) + + h.AssertNotNil(t, err) + h.AssertStringContains(t, err.Error(), "failed to parse layer patches file") + }) + }) + + when("file contains patch without data", func() { + it("parses successfully with nil data", func() { + content := `{ + "patches": [ + { + "buildpack": "buildpack-a", + "layer": "layer1", + "patch-image": "image1" + } + ] + }` + path := filepath.Join(tmpDir, "patches.json") + h.AssertNil(t, os.WriteFile(path, []byte(content), 0600)) + + patches, err := files.Handler.ReadLayerPatches(path) + h.AssertNil(t, err) + + h.AssertEq(t, len(patches.Patches), 1) + h.AssertEq(t, len(patches.Patches[0].Data), 0) + }) + }) + }) +} diff --git a/platform/lifecycle_inputs.go b/platform/lifecycle_inputs.go index bc3835c87..8bfab1926 100644 --- a/platform/lifecycle_inputs.go +++ b/platform/lifecycle_inputs.go @@ -41,6 +41,7 @@ type LifecycleInputs struct { LauncherSBOMDir string LayersDir string LayoutDir string + LayerPatchesPath string LogLevel string OrderPath string OutputImageRef string @@ -157,7 +158,8 @@ func NewLifecycleInputs(platformAPI *api.Version) *LifecycleInputs { ProjectMetadataPath: envOrDefault(EnvProjectMetadataPath, filepath.Join(PlaceholderLayers, DefaultProjectMetadataFile)), // Configuration options for rebasing - ForceRebase: boolEnv(EnvForceRebase), + ForceRebase: boolEnv(EnvForceRebase), + LayerPatchesPath: os.Getenv(EnvLayerPatchesFile), } return inputs