diff --git a/internal/build/container_ops.go b/internal/build/container_ops.go index 5d7c1eb60f..a5fbf41e80 100644 --- a/internal/build/container_ops.go +++ b/internal/build/container_ops.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/api/types" dcontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" + darchive "github.com/docker/docker/pkg/archive" "github.com/pkg/errors" "github.com/buildpacks/pack/internal/builder" @@ -43,6 +44,18 @@ func CopyOut(handler func(closer io.ReadCloser) error, srcs ...string) Container } } +func CopyOutTo(src, dest string) ContainerOperation { + return CopyOut(func(reader io.ReadCloser) error { + info := darchive.CopyInfo{ + Path: src, + IsDir: true, + } + + defer reader.Close() + return darchive.CopyTo(reader, info, dest) + }, src) +} + // CopyDir copies a local directory (src) to the destination on the container while filtering files and changing it's UID/GID. // if includeRoot is set the UID/GID will be set on the dst directory. func CopyDir(src, dst string, uid, gid int, os string, includeRoot bool, fileFilter func(string) bool) ContainerOperation { diff --git a/internal/build/lifecycle_execution.go b/internal/build/lifecycle_execution.go index 6a0d0f61c2..251da2e2b2 100644 --- a/internal/build/lifecycle_execution.go +++ b/internal/build/lifecycle_execution.go @@ -257,6 +257,9 @@ func (l *LifecycleExecution) Create(ctx context.Context, publish bool, dockerHos cacheOpts, WithContainerOperations(WriteProjectMetadata(l.mountPaths.projectPath(), l.opts.ProjectMetadata, l.os)), WithContainerOperations(CopyDir(l.opts.AppPath, l.mountPaths.appDir(), l.opts.Builder.UID(), l.opts.Builder.GID(), l.os, true, l.opts.FileFilter)), + If(l.opts.SBOMDestinationDir != "", WithPostContainerRunOperations( + EnsureVolumeAccess(l.opts.Builder.UID(), l.opts.Builder.GID(), l.os, l.layersVolume, l.appVolume), + CopyOutTo(l.mountPaths.sbomDir(), l.opts.SBOMDestinationDir))), If(l.opts.Interactive, WithPostContainerRunOperations( EnsureVolumeAccess(l.opts.Builder.UID(), l.opts.Builder.GID(), l.os, l.layersVolume, l.appVolume), CopyOut(l.opts.Termui.ReadLayers, l.mountPaths.layersDir(), l.mountPaths.appDir()))), @@ -541,6 +544,9 @@ func (l *LifecycleExecution) newExport(repoName, runImage string, publish bool, cacheOpt, WithContainerOperations(WriteStackToml(l.mountPaths.stackPath(), l.opts.Builder.Stack(), l.os)), WithContainerOperations(WriteProjectMetadata(l.mountPaths.projectPath(), l.opts.ProjectMetadata, l.os)), + If(l.opts.SBOMDestinationDir != "", WithPostContainerRunOperations( + EnsureVolumeAccess(l.opts.Builder.UID(), l.opts.Builder.GID(), l.os, l.layersVolume, l.appVolume), + CopyOutTo(l.mountPaths.sbomDir(), l.opts.SBOMDestinationDir))), If(l.opts.Interactive, WithPostContainerRunOperations( EnsureVolumeAccess(l.opts.Builder.UID(), l.opts.Builder.GID(), l.os, l.layersVolume, l.appVolume), CopyOut(l.opts.Termui.ReadLayers, l.mountPaths.layersDir(), l.mountPaths.appDir()))), diff --git a/internal/build/lifecycle_execution_test.go b/internal/build/lifecycle_execution_test.go index 994c2d2ffe..66d38ac9dc 100644 --- a/internal/build/lifecycle_execution_test.go +++ b/internal/build/lifecycle_execution_test.go @@ -1025,7 +1025,32 @@ func testLifecycleExecution(t *testing.T, when spec.G, it spec.S) { h.AssertEq(t, fakePhase.CleanupCallCount, 1) h.AssertEq(t, fakePhase.RunCallCount, 1) - h.AssertEq(t, len(fakePhaseFactory.NewCalledWithProvider[0].PostContainerRunOps()), 2) + + provider := fakePhaseFactory.NewCalledWithProvider[0] + h.AssertEq(t, len(provider.PostContainerRunOps()), 2) + h.AssertFunctionName(t, provider.PostContainerRunOps()[0], "EnsureVolumeAccess") + h.AssertFunctionName(t, provider.PostContainerRunOps()[1], "CopyOut") + }) + }) + + when("sbom destination directory is provided", func() { + it("provides copy-sbom-func as a post container operation", func() { + lifecycle := newTestLifecycleExec(t, false, func(opts *build.LifecycleOptions) { + opts.SBOMDestinationDir = "some-destination-dir" + }) + fakePhase := &fakes.FakePhase{} + fakePhaseFactory := fakes.NewFakePhaseFactory(fakes.WhichReturnsForNew(fakePhase)) + + err := lifecycle.Create(context.Background(), false, "", false, "test", "test", "test", fakeBuildCache, fakeLaunchCache, []string{}, []string{}, fakePhaseFactory) + h.AssertNil(t, err) + + h.AssertEq(t, fakePhase.CleanupCallCount, 1) + h.AssertEq(t, fakePhase.RunCallCount, 1) + + provider := fakePhaseFactory.NewCalledWithProvider[0] + h.AssertEq(t, len(provider.PostContainerRunOps()), 2) + h.AssertFunctionName(t, provider.PostContainerRunOps()[0], "EnsureVolumeAccess") + h.AssertFunctionName(t, provider.PostContainerRunOps()[1], "CopyOut") }) }) }) @@ -2565,7 +2590,32 @@ func testLifecycleExecution(t *testing.T, when spec.G, it spec.S) { h.AssertEq(t, fakePhase.CleanupCallCount, 1) h.AssertEq(t, fakePhase.RunCallCount, 1) - h.AssertEq(t, len(fakePhaseFactory.NewCalledWithProvider[0].PostContainerRunOps()), 2) + + provider := fakePhaseFactory.NewCalledWithProvider[0] + h.AssertEq(t, len(provider.PostContainerRunOps()), 2) + h.AssertFunctionName(t, provider.PostContainerRunOps()[0], "EnsureVolumeAccess") + h.AssertFunctionName(t, provider.PostContainerRunOps()[1], "CopyOut") + }) + }) + + when("sbom destination directory is provided", func() { + it("provides copy-sbom-func as a post container operation", func() { + lifecycle := newTestLifecycleExec(t, false, func(opts *build.LifecycleOptions) { + opts.SBOMDestinationDir = "some-destination-dir" + }) + fakePhase := &fakes.FakePhase{} + fakePhaseFactory := fakes.NewFakePhaseFactory(fakes.WhichReturnsForNew(fakePhase)) + + err := lifecycle.Create(context.Background(), false, "", false, "test", "test", "test", fakeBuildCache, fakeLaunchCache, []string{}, []string{}, fakePhaseFactory) + h.AssertNil(t, err) + + h.AssertEq(t, fakePhase.CleanupCallCount, 1) + h.AssertEq(t, fakePhase.RunCallCount, 1) + + provider := fakePhaseFactory.NewCalledWithProvider[0] + h.AssertEq(t, len(provider.PostContainerRunOps()), 2) + h.AssertFunctionName(t, provider.PostContainerRunOps()[0], "EnsureVolumeAccess") + h.AssertFunctionName(t, provider.PostContainerRunOps()[1], "CopyOut") }) }) }) diff --git a/internal/build/lifecycle_executor.go b/internal/build/lifecycle_executor.go index fc9bbc63f2..b0c5973b9b 100644 --- a/internal/build/lifecycle_executor.go +++ b/internal/build/lifecycle_executor.go @@ -88,6 +88,7 @@ type LifecycleOptions struct { Workspace string GID int PreviousImage string + SBOMDestinationDir string } func NewLifecycleExecutor(logger logging.Logger, docker client.CommonAPIClient) *LifecycleExecutor { diff --git a/internal/build/mount_paths.go b/internal/build/mount_paths.go index 015052de75..133919ee7f 100644 --- a/internal/build/mount_paths.go +++ b/internal/build/mount_paths.go @@ -57,3 +57,7 @@ func (m mountPaths) cacheDir() string { func (m mountPaths) launchCacheDir() string { return m.join(m.volume, "launch-cache") } + +func (m mountPaths) sbomDir() string { + return m.join(m.volume, "layers", "sbom") +} diff --git a/internal/commands/build.go b/internal/commands/build.go index d6e567598d..ee80ee55c9 100644 --- a/internal/commands/build.go +++ b/internal/commands/build.go @@ -43,6 +43,7 @@ type BuildFlags struct { Workspace string GID int PreviousImage string + SBOMDestinationDir string } // Build an image from source code @@ -157,6 +158,7 @@ func Build(logger logging.Logger, cfg config.Config, packClient PackClient) *cob GroupID: gid, PreviousImage: flags.PreviousImage, Interactive: flags.Interactive, + SBOMDestinationDir: flags.SBOMDestinationDir, }); err != nil { return errors.Wrap(err, "failed to build") } @@ -197,6 +199,7 @@ This option may set DOCKER_HOST environment variable for the build container if cmd.Flags().StringVar(&buildFlags.Workspace, "workspace", "", "Location at which to mount the app dir in the build image") cmd.Flags().IntVar(&buildFlags.GID, "gid", 0, `Override GID of user's group in the stack's build and run images. The provided value must be a positive number`) cmd.Flags().StringVar(&buildFlags.PreviousImage, "previous-image", "", "Set previous image to a particular tag reference, digest reference, or (when performing a daemon build) image ID") + cmd.Flags().StringVar(&buildFlags.SBOMDestinationDir, "sbom-output-dir", "", "Path to export SBoM contents.\nOmitting the flag will yield no SBoM content.") cmd.Flags().BoolVar(&buildFlags.Interactive, "interactive", false, "Launch a terminal UI to depict the build process") if !cfg.Experimental { cmd.Flags().MarkHidden("interactive") diff --git a/internal/commands/build_test.go b/internal/commands/build_test.go index 65ef7ee697..b5f7a24496 100644 --- a/internal/commands/build_test.go +++ b/internal/commands/build_test.go @@ -772,6 +772,17 @@ builder = "my-builder" h.AssertError(t, err, "Interactive mode is currently experimental.") }) }) + + when("sbom destination directory is provided", func() { + it("forwards the network onto the client", func() { + mockClient.EXPECT(). + Build(gomock.Any(), EqBuildOptionsWithSBOMOutputDir("some-output-dir")). + Return(nil) + + command.SetArgs([]string{"image", "--builder", "my-builder", "--sbom-output-dir", "some-output-dir"}) + h.AssertNil(t, command.Execute()) + }) + }) }) } @@ -911,6 +922,15 @@ func EqBuildOptionsWithPreviousImage(prevImage string) gomock.Matcher { } } +func EqBuildOptionsWithSBOMOutputDir(s string) interface{} { + return buildOptionsMatcher{ + description: fmt.Sprintf("sbom-destination-dir=%s", s), + equals: func(o client.BuildOptions) bool { + return o.SBOMDestinationDir == s + }, + } +} + type buildOptionsMatcher struct { equals func(client.BuildOptions) bool description string diff --git a/pkg/client/build.go b/pkg/client/build.go index 733c1e530d..690a9cb321 100644 --- a/pkg/client/build.go +++ b/pkg/client/build.go @@ -167,6 +167,9 @@ type BuildOptions struct { // This places registry credentials on the builder's build image. // Only trust builders from reputable sources. TrustBuilder IsTrustedBuilder + + // Directory to output any SBOM artifacts + SBOMDestinationDir string } // ProxyConfig specifies proxy setting to be set as environment variables in a container. @@ -352,6 +355,7 @@ func (c *Client) Build(ctx context.Context, opts BuildOptions) error { PreviousImage: opts.PreviousImage, Interactive: opts.Interactive, Termui: termui.NewTermui(imageRef.Name(), bldr, runImageName), + SBOMDestinationDir: opts.SBOMDestinationDir, } lifecycleVersion := ephemeralBuilder.LifecycleDescriptor().Info.Version diff --git a/pkg/client/build_test.go b/pkg/client/build_test.go index c9861dceb7..c6c7086481 100644 --- a/pkg/client/build_test.go +++ b/pkg/client/build_test.go @@ -2471,6 +2471,17 @@ func testBuild(t *testing.T, when spec.G, it spec.S) { h.AssertEq(t, fakeLifecycle.Opts.Interactive, true) }) }) + + when("sbom destination dir option", func() { + it("passthroughs to lifecycle", func() { + h.AssertNil(t, subject.Build(context.TODO(), BuildOptions{ + Builder: defaultBuilderName, + Image: "example.com/some/repo:tag", + SBOMDestinationDir: "some-destination-dir", + })) + h.AssertEq(t, fakeLifecycle.Opts.SBOMDestinationDir, "some-destination-dir") + }) + }) }) }