Skip to content

Commit e45b434

Browse files
vky5jjbustamanteclaude
authored
fix: update lifecycle API validation for experimental flag solve issue #2414 (#2432)
* fix: allow image extensions based on buildpack API Signed-off-by: vky5 <vky05@proton.me> * Move extension validation to client layer and check Platform API version - Move validation from command layer (builder_create.go, create_builder.go) to client layer (pkg/client/create_builder.go) - Check Platform API version instead of lifecycle version to determine if extensions are stable (>= 0.13) or experimental (< 0.13) - Use lifecycle's LessThan() method for version comparison - Add comprehensive tests for Platform API validation scenarios: * Platform API >= 0.13 allows extensions without experimental flag * Platform API < 0.13 requires experimental flag for extensions * Builders without extensions work regardless of Platform API version - Create platform-0.13 test lifecycle data with Platform API 0.3-0.13 - Add prepareExtensions() test helper that configures both extensions and appropriate lifecycle for testing This fixes the issue where users get experimental extension errors even when using lifecycle with Platform API 0.13 where extensions are stable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Juan Bustamante <bustamantejj@gmail.com> * Run make format to remove extra blank lines 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Juan Bustamante <bustamantejj@gmail.com> * Fix linting errors by removing deprecated API usage - Remove fallback to deprecated descriptor.API.PlatformVersion - Use only descriptor.APIs.Platform.Supported (new API) - Skip validation if Platform API information is unavailable - Remove unused github.com/buildpacks/lifecycle/api import This fixes staticcheck SA1019 warnings about using deprecated API fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Juan Bustamante <bustamantejj@gmail.com> --------- Signed-off-by: vky5 <vky05@proton.me> Signed-off-by: Juan Bustamante <bustamantejj@gmail.com> Co-authored-by: Juan Bustamante <bustamantejj@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent b604f01 commit e45b434

File tree

14 files changed

+162
-34
lines changed

14 files changed

+162
-34
lines changed

internal/commands/builder_create.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,6 @@ Creating a custom builder allows you to control what buildpacks are used and wha
6767
logger.Warnf("builder configuration: %s", w)
6868
}
6969

70-
if hasExtensions(builderConfig) {
71-
if !cfg.Experimental {
72-
return errors.New("builder config contains image extensions; support for image extensions is currently experimental")
73-
}
74-
}
75-
7670
relativeBaseDir, err := filepath.Abs(filepath.Dir(flags.BuilderTomlPath))
7771
if err != nil {
7872
return errors.Wrap(err, "getting absolute path for config")
@@ -164,10 +158,6 @@ Creating a custom builder allows you to control what buildpacks are used and wha
164158
return cmd
165159
}
166160

167-
func hasExtensions(builderConfig builder.Config) bool {
168-
return len(builderConfig.Extensions) > 0 || len(builderConfig.OrderExtensions) > 0
169-
}
170-
171161
func hasDockerLifecycle(builderConfig builder.Config) bool {
172162
return buildpack.HasDockerLocator(builderConfig.Lifecycle.URI)
173163
}

internal/commands/builder_create_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package commands_test
22

33
import (
44
"bytes"
5+
"errors"
56
"fmt"
67
"os"
78
"path/filepath"
@@ -420,11 +421,13 @@ func testCreateCommand(t *testing.T, when spec.G, it spec.S) {
420421
})
421422

422423
it("errors", func() {
424+
mockClient.EXPECT().CreateBuilder(gomock.Any(), gomock.Any()).Return(errors.New("builder config contains image extensions, but the lifecycle Platform API version (0.12) is older than 0.13; support for image extensions with Platform API < 0.13 is currently experimental"))
425+
423426
command.SetArgs([]string{
424427
"some/builder",
425428
"--config", builderConfigPath,
426429
})
427-
h.AssertError(t, command.Execute(), "builder config contains image extensions; support for image extensions is currently experimental")
430+
h.AssertError(t, command.Execute(), "support for image extensions with Platform API < 0.13 is currently experimental")
428431
})
429432
})
430433

internal/commands/create_builder.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,6 @@ Creating a custom builder allows you to control what buildpacks are used and wha
5656
logger.Warnf("builder configuration: %s", w)
5757
}
5858

59-
if hasExtensions(builderConfig) {
60-
if !cfg.Experimental {
61-
return errors.New("builder config contains image extensions; support for image extensions is currently experimental")
62-
}
63-
}
64-
6559
relativeBaseDir, err := filepath.Abs(filepath.Dir(flags.BuilderTomlPath))
6660
if err != nil {
6761
return errors.Wrap(err, "getting absolute path for config")

internal/commands/create_builder_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package commands_test
22

33
import (
44
"bytes"
5+
"errors"
56
"os"
67
"path/filepath"
78
"testing"
@@ -171,11 +172,13 @@ func testCreateBuilderCommand(t *testing.T, when spec.G, it spec.S) {
171172
})
172173

173174
it("errors", func() {
175+
mockClient.EXPECT().CreateBuilder(gomock.Any(), gomock.Any()).Return(errors.New("builder config contains image extensions, but the lifecycle Platform API version (0.12) is older than 0.13; support for image extensions with Platform API < 0.13 is currently experimental"))
176+
174177
command.SetArgs([]string{
175178
"some/builder",
176179
"--config", builderConfigPath,
177180
})
178-
h.AssertError(t, command.Execute(), "builder config contains image extensions; support for image extensions is currently experimental")
181+
h.AssertError(t, command.Execute(), "support for image extensions with Platform API < 0.13 is currently experimental")
179182
})
180183
})
181184
})

pkg/client/create_builder.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ func (c *Client) createBaseBuilder(ctx context.Context, opts CreateBuilderOption
272272
return nil, errors.Wrap(err, "fetch lifecycle")
273273
}
274274

275+
// Validate lifecycle version for image extensions
276+
if err := c.validateLifecycleVersion(opts.Config, lifecycle); err != nil {
277+
return nil, err
278+
}
275279
bldr.SetLifecycle(lifecycle)
276280
bldr.SetBuildConfigEnv(opts.BuildConfigEnv)
277281

@@ -551,3 +555,35 @@ func (c *Client) uriFromLifecycleImage(ctx context.Context, basePath string, con
551555
}
552556
return uri, err
553557
}
558+
559+
func hasExtensions(builderConfig pubbldr.Config) bool {
560+
return len(builderConfig.Extensions) > 0 || len(builderConfig.OrderExtensions) > 0
561+
}
562+
563+
func (c *Client) validateLifecycleVersion(builderConfig pubbldr.Config, lifecycle builder.Lifecycle) error {
564+
if !hasExtensions(builderConfig) {
565+
return nil
566+
}
567+
568+
descriptor := lifecycle.Descriptor()
569+
570+
// Extensions are stable starting from Platform API 0.13
571+
// Check the latest supported Platform API version
572+
if len(descriptor.APIs.Platform.Supported) == 0 {
573+
// No Platform API information available, skip validation
574+
return nil
575+
}
576+
577+
platformAPI := descriptor.APIs.Platform.Supported.Latest()
578+
if platformAPI.LessThan("0.13") {
579+
if !c.experimental {
580+
return errors.Errorf(
581+
"builder config contains image extensions, but the lifecycle Platform API version (%s) is older than 0.13; "+
582+
"support for image extensions with Platform API < 0.13 is currently experimental",
583+
platformAPI.String(),
584+
)
585+
}
586+
}
587+
588+
return nil
589+
}

pkg/client/create_builder_test.go

Lines changed: 100 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
7272
mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/build-image", gomock.Any()).Return(fakeBuildImage, nil)
7373
}
7474

75+
var prepareExtensions = func() {
76+
// Extensions require Platform API >= 0.13
77+
opts.Config.Lifecycle.URI = "file:///some-lifecycle-platform-0-13"
78+
opts.Config.Extensions = []pubbldr.ModuleConfig{
79+
{
80+
ModuleInfo: dist.ModuleInfo{ID: "ext.one", Version: "1.2.3", Homepage: "http://one.extension"},
81+
ImageOrURI: dist.ImageOrURI{
82+
BuildpackURI: dist.BuildpackURI{
83+
URI: "https://example.fake/ext-one.tgz",
84+
},
85+
},
86+
},
87+
}
88+
opts.Config.OrderExtensions = []dist.OrderEntry{{
89+
Group: []dist.ModuleRef{
90+
{ModuleInfo: dist.ModuleInfo{ID: "ext.one", Version: "1.2.3"}, Optional: true},
91+
}},
92+
}
93+
}
94+
7595
var createBuildpack = func(descriptor dist.BuildpackDescriptor) buildpack.BuildModule {
7696
buildpack, err := ifakes.NewFakeBuildpack(descriptor, 0644)
7797
h.AssertNil(t, err)
@@ -114,7 +134,8 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
114134
mockDownloader.EXPECT().Download(gomock.Any(), "https://example.fake/ext-one.tgz").Return(exampleExtensionBlob, nil).AnyTimes()
115135
mockDownloader.EXPECT().Download(gomock.Any(), "some/buildpack/dir").Return(blob.NewBlob(filepath.Join("testdata", "buildpack")), nil).AnyTimes()
116136
mockDownloader.EXPECT().Download(gomock.Any(), "file:///some-lifecycle").Return(blob.NewBlob(filepath.Join("testdata", "lifecycle", "platform-0.4")), nil).AnyTimes()
117-
mockDownloader.EXPECT().Download(gomock.Any(), "file:///some-lifecycle-platform-0-1").Return(blob.NewBlob(filepath.Join("testdata", "lifecycle-platform-0.1")), nil).AnyTimes()
137+
mockDownloader.EXPECT().Download(gomock.Any(), "file:///some-lifecycle-platform-0-1").Return(blob.NewBlob(filepath.Join("testdata", "lifecycle", "platform-0.3")), nil).AnyTimes()
138+
mockDownloader.EXPECT().Download(gomock.Any(), "file:///some-lifecycle-platform-0-13").Return(blob.NewBlob(filepath.Join("testdata", "lifecycle", "platform-0.13")), nil).AnyTimes()
118139

119140
bp, err := buildpack.FromBuildpackRootBlob(exampleBuildpackBlob, archive.DefaultTarWriterFactory(), nil)
120141
h.AssertNil(t, err)
@@ -150,26 +171,11 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
150171
},
151172
},
152173
},
153-
Extensions: []pubbldr.ModuleConfig{
154-
{
155-
ModuleInfo: dist.ModuleInfo{ID: "ext.one", Version: "1.2.3", Homepage: "http://one.extension"},
156-
ImageOrURI: dist.ImageOrURI{
157-
BuildpackURI: dist.BuildpackURI{
158-
URI: "https://example.fake/ext-one.tgz",
159-
},
160-
},
161-
},
162-
},
163174
Order: []dist.OrderEntry{{
164175
Group: []dist.ModuleRef{
165176
{ModuleInfo: dist.ModuleInfo{ID: "bp.one", Version: "1.2.3"}, Optional: false},
166177
}},
167178
},
168-
OrderExtensions: []dist.OrderEntry{{
169-
Group: []dist.ModuleRef{
170-
{ModuleInfo: dist.ModuleInfo{ID: "ext.one", Version: "1.2.3"}, Optional: true},
171-
}},
172-
},
173179
Stack: pubbldr.StackConfig{
174180
ID: "some.stack.id",
175181
},
@@ -328,6 +334,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
328334
it("should fail when extension ID does not match downloaded extension", func() {
329335
prepareFetcherWithBuildImage()
330336
prepareFetcherWithRunImages()
337+
prepareExtensions()
331338
opts.Config.Extensions[0].ID = "does.not.match"
332339

333340
err := subject.CreateBuilder(context.TODO(), opts)
@@ -338,6 +345,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
338345
it("should fail when extension version does not match downloaded extension", func() {
339346
prepareFetcherWithBuildImage()
340347
prepareFetcherWithRunImages()
348+
prepareExtensions()
341349
opts.Config.Extensions[0].Version = "0.0.0"
342350

343351
err := subject.CreateBuilder(context.TODO(), opts)
@@ -516,6 +524,77 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
516524
})
517525
})
518526

527+
when("validating lifecycle Platform API for extensions", func() {
528+
when("lifecycle supports Platform API >= 0.13", func() {
529+
it("should allow extensions without experimental flag", func() {
530+
// Uses default lifecycle which has Platform API 0.13
531+
prepareFetcherWithBuildImage()
532+
prepareFetcherWithRunImages()
533+
opts.Config.Lifecycle.URI = "file:///some-lifecycle-platform-0-13"
534+
535+
err := subject.CreateBuilder(context.TODO(), opts)
536+
h.AssertNil(t, err)
537+
})
538+
})
539+
540+
when("lifecycle supports Platform API < 0.13", func() {
541+
when("experimental flag is not set", func() {
542+
it("should fail when builder has extensions", func() {
543+
prepareFetcherWithBuildImage()
544+
prepareFetcherWithRunImages()
545+
prepareExtensions()
546+
// Override to use lifecycle with Platform API 0.3 (< 0.13) for this test
547+
opts.Config.Lifecycle.URI = "file:///some-lifecycle"
548+
549+
err := subject.CreateBuilder(context.TODO(), opts)
550+
h.AssertError(t, err, "support for image extensions with Platform API < 0.13 is currently experimental")
551+
})
552+
})
553+
554+
when("experimental flag is set", func() {
555+
it("should succeed when builder has extensions", func() {
556+
packClientWithExperimental, err := client.NewClient(
557+
client.WithLogger(logger),
558+
client.WithDownloader(mockDownloader),
559+
client.WithImageFactory(mockImageFactory),
560+
client.WithFetcher(mockImageFetcher),
561+
client.WithDockerClient(mockDockerClient),
562+
client.WithBuildpackDownloader(mockBuildpackDownloader),
563+
client.WithExperimental(true),
564+
)
565+
h.AssertNil(t, err)
566+
567+
prepareFetcherWithBuildImage()
568+
prepareFetcherWithRunImages()
569+
prepareExtensions()
570+
// Remove buildpacks to avoid API compatibility issues
571+
opts.Config.Buildpacks = nil
572+
opts.Config.Order = nil
573+
// Override to use lifecycle with Platform API 0.3 (< 0.13) for this test
574+
opts.Config.Lifecycle.URI = "file:///some-lifecycle"
575+
576+
err = packClientWithExperimental.CreateBuilder(context.TODO(), opts)
577+
h.AssertNil(t, err)
578+
})
579+
})
580+
})
581+
582+
when("builder has no extensions", func() {
583+
it("should succeed regardless of Platform API version", func() {
584+
prepareFetcherWithBuildImage()
585+
prepareFetcherWithRunImages()
586+
// Remove extensions from config
587+
opts.Config.Extensions = nil
588+
opts.Config.OrderExtensions = nil
589+
// Use lifecycle with Platform API 0.3 (< 0.13)
590+
opts.Config.Lifecycle.URI = "file:///some-lifecycle"
591+
592+
err := subject.CreateBuilder(context.TODO(), opts)
593+
h.AssertNil(t, err)
594+
})
595+
})
596+
})
597+
519598
when("only lifecycle version is provided", func() {
520599
it("should download from predetermined uri", func() {
521600
prepareFetcherWithBuildImage()
@@ -837,6 +916,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
837916
it("should set extensions and order-extensions metadata", func() {
838917
prepareFetcherWithBuildImage()
839918
prepareFetcherWithRunImages()
919+
prepareExtensions()
840920

841921
bldr := successfullyCreateBuilder()
842922

@@ -889,6 +969,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
889969
it("should warn when deprecated Buildpack API version is used", func() {
890970
prepareFetcherWithBuildImage()
891971
prepareFetcherWithRunImages()
972+
prepareExtensions()
892973
bldr := successfullyCreateBuilder()
893974

894975
h.AssertEq(t, bldr.LifecycleDescriptor().APIs.Buildpack.Deprecated.AsStrings(), []string{"0.2", "0.3"})
@@ -899,6 +980,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
899980
it("shouldn't warn when Buildpack API version used isn't deprecated", func() {
900981
prepareFetcherWithBuildImage()
901982
prepareFetcherWithRunImages()
983+
prepareExtensions()
902984
opts.Config.Buildpacks[0].URI = "https://example.fake/bp-one-with-api-4.tgz"
903985
opts.Config.Extensions[0].URI = "https://example.fake/ext-one-with-api-9.tgz"
904986

@@ -977,6 +1059,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
9771059
it("should add dependencies buildpacks layers order by ID and version", func() {
9781060
mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/build-image", gomock.Any()).Return(fakeLayerImage, nil)
9791061
prepareFetcherWithRunImages()
1062+
prepareExtensions()
9801063
opts.Config.Buildpacks[0].URI = "https://example.fake/bp-one-with-api-4.tgz"
9811064
opts.Config.Extensions[0].URI = "https://example.fake/ext-one-with-api-9.tgz"
9821065
bpDependencies := prepareBuildpackDependencies()
@@ -1036,6 +1119,7 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) {
10361119
it("supports directory extensions", func() {
10371120
prepareFetcherWithBuildImage()
10381121
prepareFetcherWithRunImages()
1122+
prepareExtensions()
10391123
opts.RelativeBaseDir = ""
10401124
directoryPath := "testdata/extension"
10411125
opts.Config.Extensions[0].URI = directoryPath
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
analyzer
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
builder
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
creator
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
detector

0 commit comments

Comments
 (0)