Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions acceptance/rebaser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
})
}
}
22 changes: 22 additions & 0 deletions acceptance/testdata/rebaser/app_image_with_layer_metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions acceptance/testdata/rebaser/container/patches.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"patches": [
{
"buildpack": "example/java-buildpack",
"layer": "jre",
"data": {
"artifact.version": "17.0.*"
},
"patch-image": "PATCH_IMAGE_PLACEHOLDER"
}
]
}
22 changes: 22 additions & 0 deletions acceptance/testdata/rebaser/patch_image_metadata.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
5 changes: 5 additions & 0 deletions cmd/lifecycle/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion cmd/lifecycle/rebaser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
139 changes: 139 additions & 0 deletions internal/patch/image_loader.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading