From 5c072f7a159c74c86d328797ebf9d6c3a133483e Mon Sep 17 00:00:00 2001 From: Joe Kutner Date: Sat, 4 Mar 2023 16:35:12 -0600 Subject: [PATCH] Implement RFC-0096 Remove Stacks & Mixins: phase 1 --- builder/config_reader.go | 61 +++++++++++++++-- builder/config_reader_test.go | 44 ++++++++++-- internal/build/container_ops.go | 31 +++++++++ internal/build/container_ops_test.go | 96 +++++++++++++++++++++++++++ internal/build/fakes/fake_builder.go | 5 ++ internal/build/lifecycle_execution.go | 4 ++ internal/build/lifecycle_executor.go | 1 + internal/build/mount_paths.go | 4 ++ internal/builder/builder.go | 5 ++ internal/builder/metadata.go | 17 +++-- pkg/client/create_builder.go | 63 ++++++++++-------- pkg/client/create_builder_test.go | 64 +++++++++++++++--- 12 files changed, 341 insertions(+), 54 deletions(-) diff --git a/builder/config_reader.go b/builder/config_reader.go index 921d3dd292..c4637dd34f 100644 --- a/builder/config_reader.go +++ b/builder/config_reader.go @@ -22,6 +22,8 @@ type Config struct { OrderExtensions dist.Order `toml:"order-extensions"` Stack StackConfig `toml:"stack"` Lifecycle LifecycleConfig `toml:"lifecycle"` + Run RunConfig `toml:"run"` + Build BuildConfig `toml:"build"` } // ModuleCollection is a list of ModuleConfigs @@ -55,6 +57,22 @@ type LifecycleConfig struct { Version string `toml:"version"` } +// RunConfig set of run image configuration +type RunConfig struct { + Images []RunImageConfig `toml:"images"` +} + +// RunImageConfig run image id and mirrors +type RunImageConfig struct { + Image string `toml:"image"` + Mirrors []string `toml:"run-image-mirrors,omitempty"` +} + +// BuildConfig build image configuration +type BuildConfig struct { + Image string `toml:"image"` +} + // ReadConfig reads a builder configuration from the file path provided and returns the // configuration along with any warnings encountered while parsing func ReadConfig(path string) (config Config, warnings []string, err error) { @@ -73,26 +91,57 @@ func ReadConfig(path string) (config Config, warnings []string, err error) { warnings = append(warnings, fmt.Sprintf("empty %s definition", style.Symbol("order"))) } + config.mergeStackWithImages() + return config, warnings, nil } // ValidateConfig validates the config func ValidateConfig(c Config) error { - if c.Stack.ID == "" { - return errors.New("stack.id is required") + if c.Build.Image == "" && c.Stack.BuildImage == "" { + return errors.New("build.image is required") + } else if c.Build.Image != "" && c.Stack.BuildImage != "" && c.Build.Image != c.Stack.BuildImage { + return errors.New("build.image and stack.build-image do not match") } - if c.Stack.BuildImage == "" { - return errors.New("stack.build-image is required") + if len(c.Run.Images) == 0 && (c.Stack.RunImage == "" || c.Stack.ID == "") { + return errors.New("run.images are required") } - if c.Stack.RunImage == "" { - return errors.New("stack.run-image is required") + for _, runImage := range c.Run.Images { + if runImage.Image == "" { + return errors.New("run.images.image is required") + } + } + + if c.Stack.RunImage != "" && c.Run.Images[0].Image != c.Stack.RunImage { + return errors.New("run.images and stack.run-image do not match") } return nil } +func (c *Config) mergeStackWithImages() { + // RFC-0096 + if c.Build.Image != "" { + c.Stack.BuildImage = c.Build.Image + } else if c.Build.Image == "" && c.Stack.BuildImage != "" { + c.Build.Image = c.Stack.BuildImage + } + + if len(c.Run.Images) != 0 { + // use the first run image as the "stack" + c.Stack.RunImage = c.Run.Images[0].Image + c.Stack.RunImageMirrors = c.Run.Images[0].Mirrors + } else if len(c.Run.Images) == 0 && c.Stack.RunImage != "" { + c.Run.Images = []RunImageConfig{{ + Image: c.Stack.RunImage, + Mirrors: c.Stack.RunImageMirrors, + }, + } + } +} + // parseConfig reads a builder configuration from file func parseConfig(file *os.File) (Config, error) { builderConfig := Config{} diff --git a/builder/config_reader_test.go b/builder/config_reader_test.go index b3c6409a7a..93a1bf851d 100644 --- a/builder/config_reader_test.go +++ b/builder/config_reader_test.go @@ -166,13 +166,13 @@ uri = "noop-buildpack.tgz" testBuildImage = "test-build-image" ) - it("returns error if no id", func() { + it("returns error if no stack id and no run images", func() { config := builder.Config{ Stack: builder.StackConfig{ BuildImage: testBuildImage, RunImage: testRunImage, }} - h.AssertError(t, builder.ValidateConfig(config), "stack.id is required") + h.AssertError(t, builder.ValidateConfig(config), "run.images are required") }) it("returns error if no build image", func() { @@ -181,7 +181,7 @@ uri = "noop-buildpack.tgz" ID: testID, RunImage: testRunImage, }} - h.AssertError(t, builder.ValidateConfig(config), "stack.build-image is required") + h.AssertError(t, builder.ValidateConfig(config), "build.image is required") }) it("returns error if no run image", func() { @@ -190,7 +190,43 @@ uri = "noop-buildpack.tgz" ID: testID, BuildImage: testBuildImage, }} - h.AssertError(t, builder.ValidateConfig(config), "stack.run-image is required") + h.AssertError(t, builder.ValidateConfig(config), "run.images are required") + }) + + it("returns error if no run images image", func() { + config := builder.Config{ + Build: builder.BuildConfig{ + Image: testBuildImage, + }, + Run: builder.RunConfig{ + Images: []builder.RunImageConfig{{ + Image: "", + }}, + }} + h.AssertError(t, builder.ValidateConfig(config), "run.images.image is required") + }) + + it("returns error if no stack or run image", func() { + config := builder.Config{ + Build: builder.BuildConfig{ + Image: testBuildImage, + }} + h.AssertError(t, builder.ValidateConfig(config), "run.images are required") + }) + + it("returns error if no stack and no build image", func() { + config := builder.Config{ + Run: builder.RunConfig{ + Images: []builder.RunImageConfig{{ + Image: testBuildImage, + }}, + }} + h.AssertError(t, builder.ValidateConfig(config), "build.image is required") + }) + + it("returns error if no stack, run, or build image", func() { + config := builder.Config{} + h.AssertError(t, builder.ValidateConfig(config), "build.image is required") }) }) } diff --git a/internal/build/container_ops.go b/internal/build/container_ops.go index 6bf5ffbb10..d88d4adf6e 100644 --- a/internal/build/container_ops.go +++ b/internal/build/container_ops.go @@ -228,6 +228,37 @@ func WriteStackToml(dstPath string, stack builder.StackMetadata, os string) Cont } } +// WriteRunToml writes a `run.toml` based on the RunConfig provided to the destination path. +func WriteRunToml(dstPath string, runImages []builder.RunImageMetadata, os string) ContainerOperation { + return func(ctrClient DockerClient, ctx context.Context, containerID string, stdout, stderr io.Writer) error { + buf := &bytes.Buffer{} + err := toml.NewEncoder(buf).Encode(builder.RunImages{ + Images: runImages, + }) + if err != nil { + return errors.Wrap(err, "marshaling stack metadata") + } + + tarBuilder := archive.TarBuilder{} + + tarPath := dstPath + if os == "windows" { + tarPath = paths.WindowsToSlash(dstPath) + } + + tarBuilder.AddFile(tarPath, 0755, archive.NormalizedDateTime, buf.Bytes()) + reader := tarBuilder.Reader(archive.DefaultTarWriterFactory()) + defer reader.Close() + + if os == "windows" { + dirName := paths.WindowsDir(dstPath) + return copyDirWindows(ctx, ctrClient, containerID, reader, dirName, stdout, stderr) + } + + return ctrClient.CopyToContainer(ctx, containerID, "/", reader, types.CopyToContainerOptions{}) + } +} + func createReader(src, dst string, uid, gid int, includeRoot bool, fileFilter func(string) bool) (io.ReadCloser, error) { fi, err := os.Stat(src) if err != nil { diff --git a/internal/build/container_ops_test.go b/internal/build/container_ops_test.go index 55062aaa08..cbb80d50d9 100644 --- a/internal/build/container_ops_test.go +++ b/internal/build/container_ops_test.go @@ -404,6 +404,102 @@ drwsrwsrwt 2 123 456 (.*) some-vol }) }) + when("#WriteRunToml", func() { + it("writes file", func() { + containerDir := "/layers-vol" + containerPath := "/layers-vol/run.toml" + if osType == "windows" { + containerDir = `c:\layers-vol` + containerPath = `c:\layers-vol\run.toml` + } + + ctrCmd := []string{"ls", "-al", "/layers-vol/run.toml"} + if osType == "windows" { + ctrCmd = []string{"cmd", "/c", `dir /q /n c:\layers-vol\run.toml`} + } + ctx := context.Background() + ctr, err := createContainer(ctx, imageName, containerDir, osType, ctrCmd...) + h.AssertNil(t, err) + defer cleanupContainer(ctx, ctr.ID) + + writeOp := build.WriteRunToml(containerPath, []builder.RunImageMetadata{builder.RunImageMetadata{ + Image: "image-1", + Mirrors: []string{ + "mirror-1", + "mirror-2", + }, + }, + }, osType) + + var outBuf, errBuf bytes.Buffer + err = writeOp(ctrClient, ctx, ctr.ID, &outBuf, &errBuf) + h.AssertNil(t, err) + + err = container.RunWithHandler(ctx, ctrClient, ctr.ID, container.DefaultHandler(&outBuf, &errBuf)) + h.AssertNil(t, err) + + h.AssertEq(t, errBuf.String(), "") + if osType == "windows" { + h.AssertContains(t, outBuf.String(), `01/01/1980 12:00 AM 68 ... run.toml`) + } else { + h.AssertContains(t, outBuf.String(), `-rwxr-xr-x 1 root root 68 Jan 1 1980 /layers-vol/run.toml`) + } + }) + + it("has expected contents", func() { + containerDir := "/layers-vol" + containerPath := "/layers-vol/run.toml" + if osType == "windows" { + containerDir = `c:\layers-vol` + containerPath = `c:\layers-vol\run.toml` + } + + ctrCmd := []string{"cat", "/layers-vol/run.toml"} + if osType == "windows" { + ctrCmd = []string{"cmd", "/c", `type c:\layers-vol\run.toml`} + } + + ctx := context.Background() + ctr, err := createContainer(ctx, imageName, containerDir, osType, ctrCmd...) + h.AssertNil(t, err) + defer cleanupContainer(ctx, ctr.ID) + + writeOp := build.WriteRunToml(containerPath, []builder.RunImageMetadata{ + { + Image: "image-1", + Mirrors: []string{ + "mirror-1", + "mirror-2", + }, + }, + { + Image: "image-2", + Mirrors: []string{ + "mirror-3", + "mirror-4", + }, + }, + }, osType) + + var outBuf, errBuf bytes.Buffer + err = writeOp(ctrClient, ctx, ctr.ID, &outBuf, &errBuf) + h.AssertNil(t, err) + + err = container.RunWithHandler(ctx, ctrClient, ctr.ID, container.DefaultHandler(&outBuf, &errBuf)) + h.AssertNil(t, err) + + h.AssertEq(t, errBuf.String(), "") + h.AssertContains(t, outBuf.String(), `[[images]] + image = "image-1" + mirrors = ["mirror-1", "mirror-2"] + +[[images]] + image = "image-2" + mirrors = ["mirror-3", "mirror-4"] +`) + }) + }) + when("#WriteProjectMetadata", func() { it("writes file", func() { containerDir := "/layers-vol" diff --git a/internal/build/fakes/fake_builder.go b/internal/build/fakes/fake_builder.go index c09121d48b..2bcd9db86a 100644 --- a/internal/build/fakes/fake_builder.go +++ b/internal/build/fakes/fake_builder.go @@ -17,6 +17,7 @@ type FakeBuilder struct { ReturnForGID int ReturnForLifecycleDescriptor builder.LifecycleDescriptor ReturnForStack builder.StackMetadata + ReturnForRunImages []builder.RunImageMetadata ReturnForOrderExtensions dist.Order } @@ -112,6 +113,10 @@ func (b *FakeBuilder) Stack() builder.StackMetadata { return b.ReturnForStack } +func (b *FakeBuilder) RunImages() []builder.RunImageMetadata { + return b.ReturnForRunImages +} + func WithBuilder(builder *FakeBuilder) func(*build.LifecycleOptions) { return func(opts *build.LifecycleOptions) { opts.Builder = builder diff --git a/internal/build/lifecycle_execution.go b/internal/build/lifecycle_execution.go index e2b56bd88b..7c790c6336 100644 --- a/internal/build/lifecycle_execution.go +++ b/internal/build/lifecycle_execution.go @@ -518,6 +518,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach } stackOp := NullOp() + runOp := NullOp() if !platformAPILessThan07 { for _, tag := range l.opts.AdditionalTags { args = append([]string{"-tag", tag}, args...) @@ -527,6 +528,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach } args = append([]string{"-stack", l.mountPaths.stackPath()}, args...) stackOp = WithContainerOperations(WriteStackToml(l.mountPaths.stackPath(), l.opts.Builder.Stack(), l.os)) + runOp = WithContainerOperations(WriteRunToml(l.mountPaths.runPath(), l.opts.Builder.RunImages(), l.os)) } flagsOp := WithFlags(flags...) @@ -551,6 +553,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach flagsOp, cacheBindOp, stackOp, + runOp, ) analyze = phaseFactory.New(configProvider) @@ -572,6 +575,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach WithNetwork(l.opts.Network), cacheBindOp, stackOp, + runOp, ) analyze = phaseFactory.New(configProvider) diff --git a/internal/build/lifecycle_executor.go b/internal/build/lifecycle_executor.go index 54cb6df5aa..8c508e36ba 100644 --- a/internal/build/lifecycle_executor.go +++ b/internal/build/lifecycle_executor.go @@ -38,6 +38,7 @@ type Builder interface { GID() int LifecycleDescriptor() builder.LifecycleDescriptor Stack() builder.StackMetadata + RunImages() []builder.RunImageMetadata Image() imgutil.Image OrderExtensions() dist.Order } diff --git a/internal/build/mount_paths.go b/internal/build/mount_paths.go index 89d266b6fd..ad4bd91136 100644 --- a/internal/build/mount_paths.go +++ b/internal/build/mount_paths.go @@ -38,6 +38,10 @@ func (m mountPaths) stackPath() string { return m.join(m.layersDir(), "stack.toml") } +func (m mountPaths) runPath() string { + return m.join(m.layersDir(), "run.toml") +} + func (m mountPaths) projectPath() string { return m.join(m.layersDir(), "project-metadata.toml") } diff --git a/internal/builder/builder.go b/internal/builder/builder.go index d840007a52..953dda32d3 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -236,6 +236,11 @@ func (b *Builder) Stack() StackMetadata { return b.metadata.Stack } +// RunImages returns the run image metadata +func (b *Builder) RunImages() []RunImageMetadata { + return b.metadata.RunImages +} + // Mixins returns the mixins of the builder func (b *Builder) Mixins() []string { return b.mixins diff --git a/internal/builder/metadata.go b/internal/builder/metadata.go index c16e55ff31..67333e4301 100644 --- a/internal/builder/metadata.go +++ b/internal/builder/metadata.go @@ -8,12 +8,13 @@ const ( ) type Metadata struct { - Description string `json:"description"` - Buildpacks []dist.ModuleInfo `json:"buildpacks"` - Extensions []dist.ModuleInfo `json:"extensions"` - Stack StackMetadata `json:"stack"` - Lifecycle LifecycleMetadata `json:"lifecycle"` - CreatedBy CreatorMetadata `json:"createdBy"` + Description string `json:"description"` + Buildpacks []dist.ModuleInfo `json:"buildpacks"` + Extensions []dist.ModuleInfo `json:"extensions"` + Stack StackMetadata `json:"stack"` + Lifecycle LifecycleMetadata `json:"lifecycle"` + CreatedBy CreatorMetadata `json:"createdBy"` + RunImages []RunImageMetadata `json:"images"` } type CreatorMetadata struct { @@ -32,6 +33,10 @@ type StackMetadata struct { RunImage RunImageMetadata `json:"runImage" toml:"run-image"` } +type RunImages struct { + Images []RunImageMetadata `json:"images" toml:"images"` +} + type RunImageMetadata struct { Image string `json:"image" toml:"image"` Mirrors []string `json:"mirrors" toml:"mirrors"` diff --git a/pkg/client/create_builder.go b/pkg/client/create_builder.go index 2f728efa6d..58867709fd 100644 --- a/pkg/client/create_builder.go +++ b/pkg/client/create_builder.go @@ -63,7 +63,10 @@ func (c *Client) CreateBuilder(ctx context.Context, opts CreateBuilderOptions) e bldr.SetOrder(opts.Config.Order) bldr.SetOrderExtensions(opts.Config.OrderExtensions) - bldr.SetStack(opts.Config.Stack) + + if opts.Config.Stack.ID != "" { + bldr.SetStack(opts.Config.Stack) + } return bldr.Save(c.logger, builder.CreatorMetadata{Version: c.version}) } @@ -82,43 +85,47 @@ func (c *Client) validateConfig(ctx context.Context, opts CreateBuilderOptions) func (c *Client) validateRunImageConfig(ctx context.Context, opts CreateBuilderOptions) error { var runImages []imgutil.Image - for _, i := range append([]string{opts.Config.Stack.RunImage}, opts.Config.Stack.RunImageMirrors...) { - if !opts.Publish { - img, err := c.imageFetcher.Fetch(ctx, i, image.FetchOptions{Daemon: true, PullPolicy: opts.PullPolicy}) + for _, r := range opts.Config.Run.Images { + for _, i := range append([]string{r.Image}, r.Mirrors...) { + if !opts.Publish { + img, err := c.imageFetcher.Fetch(ctx, i, image.FetchOptions{Daemon: true, PullPolicy: opts.PullPolicy}) + if err != nil { + if errors.Cause(err) != image.ErrNotFound { + return errors.Wrap(err, "failed to fetch image") + } + } else { + runImages = append(runImages, img) + continue + } + } + + img, err := c.imageFetcher.Fetch(ctx, i, image.FetchOptions{Daemon: false, PullPolicy: opts.PullPolicy}) if err != nil { if errors.Cause(err) != image.ErrNotFound { return errors.Wrap(err, "failed to fetch image") } + c.logger.Warnf("run image %s is not accessible", style.Symbol(i)) } else { runImages = append(runImages, img) - continue } } - - img, err := c.imageFetcher.Fetch(ctx, i, image.FetchOptions{Daemon: false, PullPolicy: opts.PullPolicy}) - if err != nil { - if errors.Cause(err) != image.ErrNotFound { - return errors.Wrap(err, "failed to fetch image") - } - c.logger.Warnf("run image %s is not accessible", style.Symbol(i)) - } else { - runImages = append(runImages, img) - } } for _, img := range runImages { - stackID, err := img.Label("io.buildpacks.stack.id") - if err != nil { - return errors.Wrap(err, "failed to label image") - } + if opts.Config.Stack.ID != "" { + stackID, err := img.Label("io.buildpacks.stack.id") + if err != nil { + return errors.Wrap(err, "failed to label image") + } - if stackID != opts.Config.Stack.ID { - return fmt.Errorf( - "stack %s from builder config is incompatible with stack %s from run image %s", - style.Symbol(opts.Config.Stack.ID), - style.Symbol(stackID), - style.Symbol(img.Name()), - ) + if stackID != opts.Config.Stack.ID { + return fmt.Errorf( + "stack %s from builder config is incompatible with stack %s from run image %s", + style.Symbol(opts.Config.Stack.ID), + style.Symbol(stackID), + style.Symbol(img.Name()), + ) + } } } @@ -126,7 +133,7 @@ func (c *Client) validateRunImageConfig(ctx context.Context, opts CreateBuilderO } func (c *Client) createBaseBuilder(ctx context.Context, opts CreateBuilderOptions) (*builder.Builder, error) { - baseImage, err := c.imageFetcher.Fetch(ctx, opts.Config.Stack.BuildImage, image.FetchOptions{Daemon: !opts.Publish, PullPolicy: opts.PullPolicy}) + baseImage, err := c.imageFetcher.Fetch(ctx, opts.Config.Build.Image, image.FetchOptions{Daemon: !opts.Publish, PullPolicy: opts.PullPolicy}) if err != nil { return nil, errors.Wrap(err, "fetch build image") } @@ -153,7 +160,7 @@ func (c *Client) createBaseBuilder(ctx context.Context, opts CreateBuilderOption bldr.SetDescription(opts.Config.Description) - if bldr.StackID != opts.Config.Stack.ID { + if opts.Config.Stack.ID != "" && bldr.StackID != opts.Config.Stack.ID { return nil, fmt.Errorf( "stack %s from builder config is incompatible with stack %s from build image", style.Symbol(opts.Config.Stack.ID), diff --git a/pkg/client/create_builder_test.go b/pkg/client/create_builder_test.go index e7a703cf14..1e9b592ea0 100644 --- a/pkg/client/create_builder_test.go +++ b/pkg/client/create_builder_test.go @@ -167,10 +167,16 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) { }}, }, Stack: pubbldr.StackConfig{ - ID: "some.stack.id", - BuildImage: "some/build-image", - RunImage: "some/run-image", - RunImageMirrors: []string{"localhost:5000/some/run-image"}, + ID: "some.stack.id", + }, + Run: pubbldr.RunConfig{ + Images: []pubbldr.RunImageConfig{{ + Image: "some/run-image", + Mirrors: []string{"localhost:5000/some/run-image"}, + }}, + }, + Build: pubbldr.BuildConfig{ + Image: "some/build-image", }, Lifecycle: pubbldr.LifecycleConfig{URI: "file:///some-lifecycle"}, }, @@ -201,12 +207,14 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) { } when("validating the builder config", func() { - it("should fail when the stack ID is empty", func() { + it("should not fail when the stack ID is empty", func() { opts.Config.Stack.ID = "" + prepareFetcherWithBuildImage() + prepareFetcherWithRunImages() err := subject.CreateBuilder(context.TODO(), opts) - h.AssertError(t, err, "stack.id is required") + h.AssertNil(t, err) }) it("should fail when the stack ID from the builder config does not match the stack ID from the build image", func() { @@ -219,20 +227,56 @@ func testCreateBuilder(t *testing.T, when spec.G, it spec.S) { h.AssertError(t, err, "stack 'some.stack.id' from builder config is incompatible with stack 'other.stack.id' from build image") }) - it("should fail when the build image is empty", func() { + it("should not fail when the stack is empty", func() { + opts.Config.Stack.ID = "" + opts.Config.Stack.BuildImage = "" + opts.Config.Stack.RunImage = "" + prepareFetcherWithBuildImage() + prepareFetcherWithRunImages() + + err := subject.CreateBuilder(context.TODO(), opts) + + h.AssertNil(t, err) + }) + + it("should fail when the run images and stack are empty", func() { opts.Config.Stack.BuildImage = "" + opts.Config.Stack.RunImage = "" + + opts.Config.Run = pubbldr.RunConfig{} err := subject.CreateBuilder(context.TODO(), opts) - h.AssertError(t, err, "stack.build-image is required") + h.AssertError(t, err, "run.images are required") }) - it("should fail when the run image is empty", func() { + it("should fail when the run images image and stack are empty", func() { + opts.Config.Stack.BuildImage = "" opts.Config.Stack.RunImage = "" + opts.Config.Run = pubbldr.RunConfig{ + Images: []pubbldr.RunImageConfig{{}}, + } + + err := subject.CreateBuilder(context.TODO(), opts) + + h.AssertError(t, err, "run.images.image is required") + }) + + it("should fail if stack and run image are different", func() { + opts.Config.Stack.RunImage = "some-other-stack-run-image" + + err := subject.CreateBuilder(context.TODO(), opts) + + h.AssertError(t, err, "run.images and stack.run-image do not match") + }) + + it("should fail if stack and build image are different", func() { + opts.Config.Stack.BuildImage = "some-other-stack-build-image" + err := subject.CreateBuilder(context.TODO(), opts) - h.AssertError(t, err, "stack.run-image is required") + h.AssertError(t, err, "build.image and stack.build-image do not match") }) it("should fail when lifecycle version is not a semver", func() {