From e638b0238761171e060b0b4faad477a117f71af6 Mon Sep 17 00:00:00 2001 From: Anthony Emengo Date: Wed, 22 Dec 2021 14:59:12 -0500 Subject: [PATCH 1/2] Add new command: pack download-sbom Signed-off-by: Anthony Emengo --- cmd/cmd.go | 1 + internal/commands/commands.go | 1 + internal/commands/download_sbom.go | 47 ++++++ internal/commands/download_sbom_test.go | 110 ++++++++++++ .../commands/testmocks/mock_pack_client.go | 66 +++++--- pkg/client/download_sbom.go | 63 +++++++ pkg/client/download_sbom_test.go | 158 ++++++++++++++++++ 7 files changed, 420 insertions(+), 26 deletions(-) create mode 100644 internal/commands/download_sbom.go create mode 100644 internal/commands/download_sbom_test.go create mode 100644 pkg/client/download_sbom.go create mode 100644 pkg/client/download_sbom_test.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 1cef31785a..9945dcf367 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -79,6 +79,7 @@ func NewPackCommand(logger ConfigurableLogger) (*cobra.Command, error) { rootCmd.AddCommand(commands.InspectImage(logger, imagewriter.NewFactory(), cfg, packClient)) rootCmd.AddCommand(commands.NewStackCommand(logger)) rootCmd.AddCommand(commands.Rebase(logger, cfg, packClient)) + rootCmd.AddCommand(commands.DownloadSBOM(logger, packClient)) rootCmd.AddCommand(commands.InspectBuildpack(logger, cfg, packClient)) rootCmd.AddCommand(commands.InspectBuilder(logger, cfg, packClient, builderwriter.NewFactory())) diff --git a/internal/commands/commands.go b/internal/commands/commands.go index c57d626138..6066b89dd5 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -29,6 +29,7 @@ type PackClient interface { YankBuildpack(client.YankBuildpackOptions) error InspectBuildpack(client.InspectBuildpackOptions) (*client.BuildpackInfo, error) PullBuildpack(context.Context, client.PullBuildpackOptions) error + DownloadSBOM(name string, options client.DownloadSBOMOptions) error } func AddHelpFlag(cmd *cobra.Command, commandName string) { diff --git a/internal/commands/download_sbom.go b/internal/commands/download_sbom.go new file mode 100644 index 0000000000..a10990bc24 --- /dev/null +++ b/internal/commands/download_sbom.go @@ -0,0 +1,47 @@ +package commands + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" + + cpkg "github.com/buildpacks/pack/pkg/client" + "github.com/buildpacks/pack/pkg/logging" +) + +type DownloadSBOMFlags struct { + Local bool + Remote bool + DestinationDir string +} + +func DownloadSBOM( + logger logging.Logger, + client PackClient, +) *cobra.Command { + var flags DownloadSBOMFlags + cmd := &cobra.Command{ + Use: "download-sbom ", + Args: cobra.ExactArgs(1), + Short: "Download SBoM from specified image", + Long: "Download layer containing Structured Bill of Materials (SBoM) from specified image", + Example: "pack download-sbom buildpacksio/pack", + RunE: logError(logger, func(cmd *cobra.Command, args []string) error { + if flags.Local && flags.Remote { + return errors.New("expected either '--local' or '--remote', not both") + } + + img := args[0] + options := cpkg.DownloadSBOMOptions{ + Daemon: !flags.Remote, + DestinationDir: flags.DestinationDir, + } + + return client.DownloadSBOM(img, options) + }), + } + AddHelpFlag(cmd, "download-sbom") + cmd.Flags().BoolVar(&flags.Local, "local", false, "Pull SBoM from local daemon (Default)") + cmd.Flags().BoolVar(&flags.Remote, "remote", false, "Pull SBoM from remote registry") + cmd.Flags().StringVar(&flags.DestinationDir, "output-dir", ".", "Path to export SBoM contents.\nIt defaults export to the current working directory.") + return cmd +} diff --git a/internal/commands/download_sbom_test.go b/internal/commands/download_sbom_test.go new file mode 100644 index 0000000000..62cd8713e6 --- /dev/null +++ b/internal/commands/download_sbom_test.go @@ -0,0 +1,110 @@ +package commands_test + +import ( + "bytes" + "testing" + + "github.com/golang/mock/gomock" + "github.com/heroku/color" + "github.com/pkg/errors" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/spf13/cobra" + + "github.com/buildpacks/pack/internal/commands" + "github.com/buildpacks/pack/internal/commands/testmocks" + cpkg "github.com/buildpacks/pack/pkg/client" + "github.com/buildpacks/pack/pkg/logging" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestDownloadSBOMCommand(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "DownloadSBOMCommand", testDownloadSBOMCommand, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testDownloadSBOMCommand(t *testing.T, when spec.G, it spec.S) { + var ( + command *cobra.Command + logger logging.Logger + outBuf bytes.Buffer + mockController *gomock.Controller + mockClient *testmocks.MockPackClient + ) + + it.Before(func() { + mockController = gomock.NewController(t) + mockClient = testmocks.NewMockPackClient(mockController) + logger = logging.NewLogWithWriters(&outBuf, &outBuf) + command = commands.DownloadSBOM(logger, mockClient) + }) + + it.After(func() { + mockController.Finish() + }) + + when("#DownloadSBOM", func() { + when("happy path", func() { + it("returns no error", func() { + mockClient.EXPECT().DownloadSBOM("some/image", cpkg.DownloadSBOMOptions{ + Daemon: true, + DestinationDir: ".", + }) + command.SetArgs([]string{"some/image"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + }) + + when("the remote flag is specified", func() { + it("respects the remote flag", func() { + mockClient.EXPECT().DownloadSBOM("some/image", cpkg.DownloadSBOMOptions{ + Daemon: false, + DestinationDir: ".", + }) + command.SetArgs([]string{"some/image", "--remote"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + }) + + when("the output-dir flag is specified", func() { + it("respects the output-dir flag", func() { + mockClient.EXPECT().DownloadSBOM("some/image", cpkg.DownloadSBOMOptions{ + Daemon: true, + DestinationDir: "some-destination-dir", + }) + command.SetArgs([]string{"some/image", "--output-dir", "some-destination-dir"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + }) + + when("both --local and --remote are specified", func() { + it("returns a user-friendly message", func() { + command.SetArgs([]string{"some/image", "--local", "--remote"}) + + err := command.Execute() + h.AssertError(t, err, "expected either '--local' or '--remote', not both") + }) + }) + + when("the client returns an error", func() { + it("returns the error", func() { + mockClient.EXPECT().DownloadSBOM("some/image", cpkg.DownloadSBOMOptions{ + Daemon: true, + DestinationDir: ".", + }).Return(errors.New("some-error")) + + command.SetArgs([]string{"some/image"}) + + err := command.Execute() + h.AssertError(t, err, "some-error") + }) + }) + }) +} diff --git a/internal/commands/testmocks/mock_pack_client.go b/internal/commands/testmocks/mock_pack_client.go index 522616dc8f..28338a006a 100644 --- a/internal/commands/testmocks/mock_pack_client.go +++ b/internal/commands/testmocks/mock_pack_client.go @@ -13,30 +13,30 @@ import ( client "github.com/buildpacks/pack/pkg/client" ) -// MockPackClient is a mock of PackClient interface +// MockPackClient is a mock of PackClient interface. type MockPackClient struct { ctrl *gomock.Controller recorder *MockPackClientMockRecorder } -// MockPackClientMockRecorder is the mock recorder for MockPackClient +// MockPackClientMockRecorder is the mock recorder for MockPackClient. type MockPackClientMockRecorder struct { mock *MockPackClient } -// NewMockPackClient creates a new mock instance +// NewMockPackClient creates a new mock instance. func NewMockPackClient(ctrl *gomock.Controller) *MockPackClient { mock := &MockPackClient{ctrl: ctrl} mock.recorder = &MockPackClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use +// EXPECT returns an object that allows the caller to indicate expected use. func (m *MockPackClient) EXPECT() *MockPackClientMockRecorder { return m.recorder } -// Build mocks base method +// Build mocks base method. func (m *MockPackClient) Build(arg0 context.Context, arg1 client.BuildOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Build", arg0, arg1) @@ -44,13 +44,13 @@ func (m *MockPackClient) Build(arg0 context.Context, arg1 client.BuildOptions) e return ret0 } -// Build indicates an expected call of Build +// Build indicates an expected call of Build. func (mr *MockPackClientMockRecorder) Build(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockPackClient)(nil).Build), arg0, arg1) } -// CreateBuilder mocks base method +// CreateBuilder mocks base method. func (m *MockPackClient) CreateBuilder(arg0 context.Context, arg1 client.CreateBuilderOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateBuilder", arg0, arg1) @@ -58,13 +58,27 @@ func (m *MockPackClient) CreateBuilder(arg0 context.Context, arg1 client.CreateB return ret0 } -// CreateBuilder indicates an expected call of CreateBuilder +// CreateBuilder indicates an expected call of CreateBuilder. func (mr *MockPackClientMockRecorder) CreateBuilder(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBuilder", reflect.TypeOf((*MockPackClient)(nil).CreateBuilder), arg0, arg1) } -// InspectBuilder mocks base method +// DownloadSBOM mocks base method. +func (m *MockPackClient) DownloadSBOM(arg0 string, arg1 client.DownloadSBOMOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DownloadSBOM", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DownloadSBOM indicates an expected call of DownloadSBOM. +func (mr *MockPackClientMockRecorder) DownloadSBOM(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadSBOM", reflect.TypeOf((*MockPackClient)(nil).DownloadSBOM), arg0, arg1) +} + +// InspectBuilder mocks base method. func (m *MockPackClient) InspectBuilder(arg0 string, arg1 bool, arg2 ...client.BuilderInspectionModifier) (*client.BuilderInfo, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -77,14 +91,14 @@ func (m *MockPackClient) InspectBuilder(arg0 string, arg1 bool, arg2 ...client.B return ret0, ret1 } -// InspectBuilder indicates an expected call of InspectBuilder +// InspectBuilder indicates an expected call of InspectBuilder. func (mr *MockPackClientMockRecorder) InspectBuilder(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectBuilder", reflect.TypeOf((*MockPackClient)(nil).InspectBuilder), varargs...) } -// InspectBuildpack mocks base method +// InspectBuildpack mocks base method. func (m *MockPackClient) InspectBuildpack(arg0 client.InspectBuildpackOptions) (*client.BuildpackInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InspectBuildpack", arg0) @@ -93,13 +107,13 @@ func (m *MockPackClient) InspectBuildpack(arg0 client.InspectBuildpackOptions) ( return ret0, ret1 } -// InspectBuildpack indicates an expected call of InspectBuildpack +// InspectBuildpack indicates an expected call of InspectBuildpack. func (mr *MockPackClientMockRecorder) InspectBuildpack(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectBuildpack", reflect.TypeOf((*MockPackClient)(nil).InspectBuildpack), arg0) } -// InspectImage mocks base method +// InspectImage mocks base method. func (m *MockPackClient) InspectImage(arg0 string, arg1 bool) (*client.ImageInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InspectImage", arg0, arg1) @@ -108,13 +122,13 @@ func (m *MockPackClient) InspectImage(arg0 string, arg1 bool) (*client.ImageInfo return ret0, ret1 } -// InspectImage indicates an expected call of InspectImage +// InspectImage indicates an expected call of InspectImage. func (mr *MockPackClientMockRecorder) InspectImage(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectImage", reflect.TypeOf((*MockPackClient)(nil).InspectImage), arg0, arg1) } -// NewBuildpack mocks base method +// NewBuildpack mocks base method. func (m *MockPackClient) NewBuildpack(arg0 context.Context, arg1 client.NewBuildpackOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NewBuildpack", arg0, arg1) @@ -122,13 +136,13 @@ func (m *MockPackClient) NewBuildpack(arg0 context.Context, arg1 client.NewBuild return ret0 } -// NewBuildpack indicates an expected call of NewBuildpack +// NewBuildpack indicates an expected call of NewBuildpack. func (mr *MockPackClientMockRecorder) NewBuildpack(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewBuildpack", reflect.TypeOf((*MockPackClient)(nil).NewBuildpack), arg0, arg1) } -// PackageBuildpack mocks base method +// PackageBuildpack mocks base method. func (m *MockPackClient) PackageBuildpack(arg0 context.Context, arg1 client.PackageBuildpackOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PackageBuildpack", arg0, arg1) @@ -136,13 +150,13 @@ func (m *MockPackClient) PackageBuildpack(arg0 context.Context, arg1 client.Pack return ret0 } -// PackageBuildpack indicates an expected call of PackageBuildpack +// PackageBuildpack indicates an expected call of PackageBuildpack. func (mr *MockPackClientMockRecorder) PackageBuildpack(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PackageBuildpack", reflect.TypeOf((*MockPackClient)(nil).PackageBuildpack), arg0, arg1) } -// PullBuildpack mocks base method +// PullBuildpack mocks base method. func (m *MockPackClient) PullBuildpack(arg0 context.Context, arg1 client.PullBuildpackOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PullBuildpack", arg0, arg1) @@ -150,13 +164,13 @@ func (m *MockPackClient) PullBuildpack(arg0 context.Context, arg1 client.PullBui return ret0 } -// PullBuildpack indicates an expected call of PullBuildpack +// PullBuildpack indicates an expected call of PullBuildpack. func (mr *MockPackClientMockRecorder) PullBuildpack(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PullBuildpack", reflect.TypeOf((*MockPackClient)(nil).PullBuildpack), arg0, arg1) } -// Rebase mocks base method +// Rebase mocks base method. func (m *MockPackClient) Rebase(arg0 context.Context, arg1 client.RebaseOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Rebase", arg0, arg1) @@ -164,13 +178,13 @@ func (m *MockPackClient) Rebase(arg0 context.Context, arg1 client.RebaseOptions) return ret0 } -// Rebase indicates an expected call of Rebase +// Rebase indicates an expected call of Rebase. func (mr *MockPackClientMockRecorder) Rebase(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rebase", reflect.TypeOf((*MockPackClient)(nil).Rebase), arg0, arg1) } -// RegisterBuildpack mocks base method +// RegisterBuildpack mocks base method. func (m *MockPackClient) RegisterBuildpack(arg0 context.Context, arg1 client.RegisterBuildpackOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RegisterBuildpack", arg0, arg1) @@ -178,13 +192,13 @@ func (m *MockPackClient) RegisterBuildpack(arg0 context.Context, arg1 client.Reg return ret0 } -// RegisterBuildpack indicates an expected call of RegisterBuildpack +// RegisterBuildpack indicates an expected call of RegisterBuildpack. func (mr *MockPackClientMockRecorder) RegisterBuildpack(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterBuildpack", reflect.TypeOf((*MockPackClient)(nil).RegisterBuildpack), arg0, arg1) } -// YankBuildpack mocks base method +// YankBuildpack mocks base method. func (m *MockPackClient) YankBuildpack(arg0 client.YankBuildpackOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "YankBuildpack", arg0) @@ -192,7 +206,7 @@ func (m *MockPackClient) YankBuildpack(arg0 client.YankBuildpackOptions) error { return ret0 } -// YankBuildpack indicates an expected call of YankBuildpack +// YankBuildpack indicates an expected call of YankBuildpack. func (mr *MockPackClientMockRecorder) YankBuildpack(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "YankBuildpack", reflect.TypeOf((*MockPackClient)(nil).YankBuildpack), arg0) diff --git a/pkg/client/download_sbom.go b/pkg/client/download_sbom.go new file mode 100644 index 0000000000..26a4a8e09a --- /dev/null +++ b/pkg/client/download_sbom.go @@ -0,0 +1,63 @@ +package client + +import ( + "context" + + "github.com/buildpacks/lifecycle/layers" + "github.com/buildpacks/lifecycle/platform" + "github.com/pkg/errors" + + "github.com/buildpacks/pack/pkg/dist" + "github.com/buildpacks/pack/pkg/image" +) + +type DownloadSBOMOptions struct { + Daemon bool + DestinationDir string +} + +// Deserialize just the subset of fields we need to avoid breaking changes +type sbomMetadata struct { + BOM *platform.LayerMetadata `json:"sbom" toml:"sbom"` +} + +func (s *sbomMetadata) isMissing() bool { + return s == nil || + s.BOM == nil || + s.BOM.SHA == "" +} + +const ( + Local = iota + Remote +) + +// DownloadSBOM pulls SBOM layer from an image. +// It reads the SBOM metadata of an image then +// pulls the corresponding diffId, if it exists +func (c *Client) DownloadSBOM(name string, options DownloadSBOMOptions) error { + img, err := c.imageFetcher.Fetch(context.Background(), name, image.FetchOptions{Daemon: options.Daemon, PullPolicy: image.PullNever}) + if err != nil { + if errors.Cause(err) == image.ErrNotFound { + return errors.Wrapf(image.ErrNotFound, "image '%s' cannot be found", name) + } + return err + } + + var sbomMD sbomMetadata + if _, err := dist.GetLabel(img, platform.LayerMetadataLabel, &sbomMD); err != nil { + return err + } + + if sbomMD.isMissing() { + return errors.Errorf("could not find SBoM information on '%s'", name) + } + + rc, err := img.GetLayer(sbomMD.BOM.SHA) + if err != nil { + return err + } + defer rc.Close() + + return layers.Extract(rc, options.DestinationDir) +} diff --git a/pkg/client/download_sbom_test.go b/pkg/client/download_sbom_test.go new file mode 100644 index 0000000000..f1978423d1 --- /dev/null +++ b/pkg/client/download_sbom_test.go @@ -0,0 +1,158 @@ +package client + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/buildpacks/imgutil/fakes" + "github.com/golang/mock/gomock" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/pack/pkg/archive" + "github.com/buildpacks/pack/pkg/image" + "github.com/buildpacks/pack/pkg/logging" + "github.com/buildpacks/pack/pkg/testmocks" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestDownloadSBOM(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "DownloadSBOM", testDownloadSBOM, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testDownloadSBOM(t *testing.T, when spec.G, it spec.S) { + var ( + subject *Client + mockImageFetcher *testmocks.MockImageFetcher + mockDockerClient *testmocks.MockCommonAPIClient + mockController *gomock.Controller + out bytes.Buffer + ) + + it.Before(func() { + mockController = gomock.NewController(t) + mockImageFetcher = testmocks.NewMockImageFetcher(mockController) + mockDockerClient = testmocks.NewMockCommonAPIClient(mockController) + + var err error + subject, err = NewClient(WithLogger(logging.NewLogWithWriters(&out, &out)), WithFetcher(mockImageFetcher), WithDockerClient(mockDockerClient)) + h.AssertNil(t, err) + }) + + it.After(func() { + mockController.Finish() + }) + + when("the image exists", func() { + var ( + mockImage *testmocks.MockImage + tmpDir string + tmpFile string + ) + + it.Before(func() { + var err error + tmpDir, err = ioutil.TempDir("", "pack.download.sbom.test.") + h.AssertNil(t, err) + + f, err := ioutil.TempFile("", "pack.download.sbom.test.") + h.AssertNil(t, err) + tmpFile = f.Name() + + err = archive.CreateSingleFileTar(tmpFile, "sbom", "some-sbom-content") + h.AssertNil(t, err) + + data, err := ioutil.ReadFile(tmpFile) + h.AssertNil(t, err) + + hsh := sha256.New() + hsh.Write(data) + shasum := hex.EncodeToString(hsh.Sum(nil)) + + mockImage = testmocks.NewImage("some/image", "", nil) + mockImage.AddLayerWithDiffID(tmpFile, fmt.Sprintf("sha256:%s", shasum)) + h.AssertNil(t, mockImage.SetLabel( + "io.buildpacks.lifecycle.metadata", + fmt.Sprintf( + `{ + "sbom": { + "sha": "sha256:%s" + } +}`, shasum))) + + mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/image", image.FetchOptions{Daemon: true, PullPolicy: image.PullNever}).Return(mockImage, nil) + }) + + it.After(func() { + os.RemoveAll(tmpDir) + os.RemoveAll(tmpFile) + }) + + it("returns the stack ID", func() { + err := subject.DownloadSBOM("some/image", DownloadSBOMOptions{Daemon: true, DestinationDir: tmpDir}) + h.AssertNil(t, err) + + contents, err := ioutil.ReadFile(filepath.Join(tmpDir, "sbom")) + h.AssertNil(t, err) + + h.AssertEq(t, string(contents), "some-sbom-content") + }) + }) + + when("the image doesn't exist", func() { + it("returns nil", func() { + mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/non-existent-image", image.FetchOptions{Daemon: true, PullPolicy: image.PullNever}).Return(nil, image.ErrNotFound) + + err := subject.DownloadSBOM("some/non-existent-image", DownloadSBOMOptions{Daemon: true, DestinationDir: ""}) + h.AssertError(t, err, "image 'some/non-existent-image' cannot be found") + }) + }) + + when("there is an error fetching the image", func() { + it("returns the error", func() { + mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/image", image.FetchOptions{Daemon: true, PullPolicy: image.PullNever}).Return(nil, errors.New("some-error")) + + err := subject.DownloadSBOM("some/image", DownloadSBOMOptions{Daemon: true, DestinationDir: ""}) + h.AssertError(t, err, "some-error") + }) + }) + + when("the image is SBOM metadata", func() { + it("returns empty data", func() { + mockImageFetcher.EXPECT(). + Fetch(gomock.Any(), "some/image-without-labels", image.FetchOptions{Daemon: true, PullPolicy: image.PullNever}). + Return(fakes.NewImage("some/image-without-labels", "", nil), nil) + + err := subject.DownloadSBOM("some/image-without-labels", DownloadSBOMOptions{Daemon: true, DestinationDir: ""}) + h.AssertError(t, err, "could not find SBoM information on 'some/image-without-labels'") + }) + }) + + when("the image has malformed metadata", func() { + var badImage *fakes.Image + + it.Before(func() { + badImage = fakes.NewImage("some/image-with-malformed-metadata", "", nil) + mockImageFetcher.EXPECT(). + Fetch(gomock.Any(), "some/image-with-malformed-metadata", image.FetchOptions{Daemon: true, PullPolicy: image.PullNever}). + Return(badImage, nil) + }) + + it("returns an error when layers md cannot parse", func() { + h.AssertNil(t, badImage.SetLabel("io.buildpacks.lifecycle.metadata", "not ---- json")) + + err := subject.DownloadSBOM("some/image-with-malformed-metadata", DownloadSBOMOptions{Daemon: true, DestinationDir: ""}) + h.AssertError(t, err, "unmarshalling label 'io.buildpacks.lifecycle.metadata'") + }) + }) +} From 7db1ac5f50576055e9a6a6a0cd7883ff7b18f5ef Mon Sep 17 00:00:00 2001 From: Anthony Emengo Date: Fri, 7 Jan 2022 14:32:28 -0500 Subject: [PATCH 2/2] Address PR comments - pack download-sbom -> pack sbom download .... - Add warning message for pack inspect-image <> --bom - Remove --local flag - Add -o shorthand for --output-dir flag Signed-off-by: Anthony Emengo --- cmd/cmd.go | 2 +- internal/commands/download_sbom.go | 17 +++++------------ internal/commands/download_sbom_test.go | 9 --------- internal/commands/inspect_image.go | 4 ++++ internal/commands/sbom.go | 20 ++++++++++++++++++++ 5 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 internal/commands/sbom.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 9945dcf367..9cf792c4bd 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -79,7 +79,7 @@ func NewPackCommand(logger ConfigurableLogger) (*cobra.Command, error) { rootCmd.AddCommand(commands.InspectImage(logger, imagewriter.NewFactory(), cfg, packClient)) rootCmd.AddCommand(commands.NewStackCommand(logger)) rootCmd.AddCommand(commands.Rebase(logger, cfg, packClient)) - rootCmd.AddCommand(commands.DownloadSBOM(logger, packClient)) + rootCmd.AddCommand(commands.NewSBOMCommand(logger, cfg, packClient)) rootCmd.AddCommand(commands.InspectBuildpack(logger, cfg, packClient)) rootCmd.AddCommand(commands.InspectBuilder(logger, cfg, packClient, builderwriter.NewFactory())) diff --git a/internal/commands/download_sbom.go b/internal/commands/download_sbom.go index a10990bc24..06797ba019 100644 --- a/internal/commands/download_sbom.go +++ b/internal/commands/download_sbom.go @@ -1,7 +1,6 @@ package commands import ( - "github.com/pkg/errors" "github.com/spf13/cobra" cpkg "github.com/buildpacks/pack/pkg/client" @@ -9,7 +8,6 @@ import ( ) type DownloadSBOMFlags struct { - Local bool Remote bool DestinationDir string } @@ -20,16 +18,12 @@ func DownloadSBOM( ) *cobra.Command { var flags DownloadSBOMFlags cmd := &cobra.Command{ - Use: "download-sbom ", + Use: "download ", Args: cobra.ExactArgs(1), Short: "Download SBoM from specified image", Long: "Download layer containing Structured Bill of Materials (SBoM) from specified image", - Example: "pack download-sbom buildpacksio/pack", + Example: "pack sbom download buildpacksio/pack", RunE: logError(logger, func(cmd *cobra.Command, args []string) error { - if flags.Local && flags.Remote { - return errors.New("expected either '--local' or '--remote', not both") - } - img := args[0] options := cpkg.DownloadSBOMOptions{ Daemon: !flags.Remote, @@ -39,9 +33,8 @@ func DownloadSBOM( return client.DownloadSBOM(img, options) }), } - AddHelpFlag(cmd, "download-sbom") - cmd.Flags().BoolVar(&flags.Local, "local", false, "Pull SBoM from local daemon (Default)") - cmd.Flags().BoolVar(&flags.Remote, "remote", false, "Pull SBoM from remote registry") - cmd.Flags().StringVar(&flags.DestinationDir, "output-dir", ".", "Path to export SBoM contents.\nIt defaults export to the current working directory.") + AddHelpFlag(cmd, "download") + cmd.Flags().BoolVar(&flags.Remote, "remote", false, "Download SBoM of image in remote registry (without pulling image)") + cmd.Flags().StringVarP(&flags.DestinationDir, "output-dir", "o", ".", "Path to export SBoM contents.\nIt defaults export to the current working directory.") return cmd } diff --git a/internal/commands/download_sbom_test.go b/internal/commands/download_sbom_test.go index 62cd8713e6..268b2f0a36 100644 --- a/internal/commands/download_sbom_test.go +++ b/internal/commands/download_sbom_test.go @@ -84,15 +84,6 @@ func testDownloadSBOMCommand(t *testing.T, when spec.G, it spec.S) { }) }) - when("both --local and --remote are specified", func() { - it("returns a user-friendly message", func() { - command.SetArgs([]string{"some/image", "--local", "--remote"}) - - err := command.Execute() - h.AssertError(t, err, "expected either '--local' or '--remote', not both") - }) - }) - when("the client returns an error", func() { it("returns the error", func() { mockClient.EXPECT().DownloadSBOM("some/image", cpkg.DownloadSBOMOptions{ diff --git a/internal/commands/inspect_image.go b/internal/commands/inspect_image.go index 45bc7b394f..ac33f6772c 100644 --- a/internal/commands/inspect_image.go +++ b/internal/commands/inspect_image.go @@ -50,6 +50,10 @@ func InspectImage( remote, remoteErr := client.InspectImage(img, false) local, localErr := client.InspectImage(img, true) + if flags.BOM { + logger.Warn("Using the '--bom' flag with 'pack inspect-image ' is deprecated. Users are encouraged to use 'pack sbom download '.") + } + if err := w.Print(logger, sharedImageInfo, local, remote, localErr, remoteErr); err != nil { return err } diff --git a/internal/commands/sbom.go b/internal/commands/sbom.go new file mode 100644 index 0000000000..39e7c0d015 --- /dev/null +++ b/internal/commands/sbom.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/pkg/logging" +) + +func NewSBOMCommand(logger logging.Logger, cfg config.Config, client PackClient) *cobra.Command { + cmd := &cobra.Command{ + Use: "sbom", + Short: "Interact with SBoM", + RunE: nil, + } + + cmd.AddCommand(DownloadSBOM(logger, client)) + AddHelpFlag(cmd, "sbom") + return cmd +}