diff --git a/.github/actions/bootstrap/action.yaml b/.github/actions/bootstrap/action.yaml index 112037f00..10ef12956 100644 --- a/.github/actions/bootstrap/action.yaml +++ b/.github/actions/bootstrap/action.yaml @@ -4,7 +4,7 @@ inputs: go-version: description: "Go version to install" required: true - default: "1.19.x" + default: "1.20.x" use-go-cache: description: "Restore go cache" required: true diff --git a/.gitignore b/.gitignore index e4c0ffcd7..ddea041ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ go.work go.work.sum +/bin /.bin CHANGELOG.md VERSION diff --git a/cmd/syft/cli/attest.go b/cmd/syft/cli/attest.go index a826977d4..0234057fe 100644 --- a/cmd/syft/cli/attest.go +++ b/cmd/syft/cli/attest.go @@ -43,6 +43,7 @@ func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions, po RunE: func(cmd *cobra.Command, args []string) error { if app.CheckForAppUpdate { checkForApplicationUpdate() + // TODO: this is broke, the bus isn't available yet } return attest.Run(cmd.Context(), app, args) diff --git a/cmd/syft/cli/attest/attest.go b/cmd/syft/cli/attest/attest.go index cdd25ad32..1d0dddf1c 100644 --- a/cmd/syft/cli/attest/attest.go +++ b/cmd/syft/cli/attest/attest.go @@ -16,11 +16,11 @@ import ( "github.com/anchore/syft/cmd/syft/cli/eventloop" "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/cmd/syft/cli/packages" + "github.com/anchore/syft/cmd/syft/internal/ui" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/file" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event/monitor" @@ -39,6 +39,13 @@ func Run(_ context.Context, app *config.Application, args []string) error { // note: must be a container image userInput := args[0] + _, err = exec.LookPath("cosign") + if err != nil { + // when cosign is not installed the error will be rendered like so: + // 2023/06/30 08:31:52 error during command execution: 'syft attest' requires cosign to be installed: exec: "cosign": executable file not found in $PATH + return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err) + } + eventBus := partybus.NewBus() stereoscope.SetBus(eventBus) syft.SetBus(eventBus) @@ -119,7 +126,7 @@ func execWorker(app *config.Application, userInput string) <-chan error { errs := make(chan error) go func() { defer close(errs) - defer bus.Publish(partybus.Event{Type: event.Exit}) + defer bus.Exit() s, err := buildSBOM(app, userInput, errs) if err != nil { @@ -207,8 +214,8 @@ func execWorker(app *config.Application, userInput string) <-chan error { Context: "cosign", }, Value: &monitor.ShellProgress{ - Reader: r, - Manual: mon, + Reader: r, + Progressable: mon, }, }, ) diff --git a/cmd/syft/cli/commands.go b/cmd/syft/cli/commands.go index 39e3ab012..aa64d5a62 100644 --- a/cmd/syft/cli/commands.go +++ b/cmd/syft/cli/commands.go @@ -125,7 +125,7 @@ func checkForApplicationUpdate() { log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version) bus.Publish(partybus.Event{ - Type: event.AppUpdateAvailable, + Type: event.CLIAppUpdateAvailable, Value: newVersion, }) } else { diff --git a/cmd/syft/cli/convert.go b/cmd/syft/cli/convert.go index a93cb304a..16c24cac5 100644 --- a/cmd/syft/cli/convert.go +++ b/cmd/syft/cli/convert.go @@ -43,6 +43,7 @@ func Convert(v *viper.Viper, app *config.Application, ro *options.RootOptions, p RunE: func(cmd *cobra.Command, args []string) error { if app.CheckForAppUpdate { checkForApplicationUpdate() + // TODO: this is broke, the bus isn't available yet } return convert.Run(cmd.Context(), app, args) }, diff --git a/cmd/syft/cli/convert/convert.go b/cmd/syft/cli/convert/convert.go index a646bded3..2f0dbcedb 100644 --- a/cmd/syft/cli/convert/convert.go +++ b/cmd/syft/cli/convert/convert.go @@ -6,20 +6,29 @@ import ( "io" "os" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/stereoscope" + "github.com/anchore/syft/cmd/syft/cli/eventloop" "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/cmd/syft/internal/ui" + "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/formats" + "github.com/anchore/syft/syft/sbom" ) func Run(_ context.Context, app *config.Application, args []string) error { log.Warn("convert is an experimental feature, run `syft convert -h` for help") + writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath) if err != nil { return err } - // this can only be a SBOM file + // could be an image or a directory, with or without a scheme userInput := args[0] var reader io.ReadCloser @@ -37,10 +46,40 @@ func Run(_ context.Context, app *config.Application, args []string) error { reader = f } - sbom, _, err := formats.Decode(reader) - if err != nil { - return fmt.Errorf("failed to decode SBOM: %w", err) - } + eventBus := partybus.NewBus() + stereoscope.SetBus(eventBus) + syft.SetBus(eventBus) + subscription := eventBus.Subscribe() - return writer.Write(*sbom) + return eventloop.EventLoop( + execWorker(reader, writer), + eventloop.SetupSignals(), + subscription, + stereoscope.Cleanup, + ui.Select(options.IsVerbose(app), app.Quiet)..., + ) +} + +func execWorker(reader io.Reader, writer sbom.Writer) <-chan error { + errs := make(chan error) + go func() { + defer close(errs) + defer bus.Exit() + + s, _, err := formats.Decode(reader) + if err != nil { + errs <- fmt.Errorf("failed to decode SBOM: %w", err) + return + } + + if s == nil { + errs <- fmt.Errorf("no SBOM produced") + return + } + + if err := writer.Write(*s); err != nil { + errs <- fmt.Errorf("failed to write SBOM: %w", err) + } + }() + return errs } diff --git a/cmd/syft/cli/eventloop/event_loop.go b/cmd/syft/cli/eventloop/event_loop.go index 592556ca2..e7d008e71 100644 --- a/cmd/syft/cli/eventloop/event_loop.go +++ b/cmd/syft/cli/eventloop/event_loop.go @@ -8,20 +8,20 @@ import ( "github.com/hashicorp/go-multierror" "github.com/wagoodman/go-partybus" + "github.com/anchore/clio" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/ui" ) -// eventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and +// EventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and // signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until // an eventual graceful exit. -func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...ui.UI) error { +func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...clio.UI) error { defer cleanupFn() events := subscription.Events() var err error - var ux ui.UI + var ux clio.UI - if ux, err = setupUI(subscription.Unsubscribe, uxs...); err != nil { + if ux, err = setupUI(subscription, uxs...); err != nil { return err } @@ -85,9 +85,9 @@ func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription * // during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error // will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks // when there are environmental problem (e.g. unable to setup a TUI with the current TTY). -func setupUI(unsubscribe func() error, uis ...ui.UI) (ui.UI, error) { +func setupUI(subscription *partybus.Subscription, uis ...clio.UI) (clio.UI, error) { for _, ux := range uis { - if err := ux.Setup(unsubscribe); err != nil { + if err := ux.Setup(subscription); err != nil { log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err) continue } diff --git a/cmd/syft/cli/eventloop/event_loop_test.go b/cmd/syft/cli/eventloop/event_loop_test.go index 2b76bcb65..495af90b8 100644 --- a/cmd/syft/cli/eventloop/event_loop_test.go +++ b/cmd/syft/cli/eventloop/event_loop_test.go @@ -11,34 +11,37 @@ import ( "github.com/stretchr/testify/mock" "github.com/wagoodman/go-partybus" - "github.com/anchore/syft/internal/ui" + "github.com/anchore/clio" "github.com/anchore/syft/syft/event" ) -var _ ui.UI = (*uiMock)(nil) +var _ clio.UI = (*uiMock)(nil) type uiMock struct { - t *testing.T - finalEvent partybus.Event - unsubscribe func() error + t *testing.T + finalEvent partybus.Event + subscription partybus.Unsubscribable mock.Mock } -func (u *uiMock) Setup(unsubscribe func() error) error { +func (u *uiMock) Setup(unsubscribe partybus.Unsubscribable) error { + u.t.Helper() u.t.Logf("UI Setup called") - u.unsubscribe = unsubscribe - return u.Called(unsubscribe).Error(0) + u.subscription = unsubscribe + return u.Called(unsubscribe.Unsubscribe).Error(0) } func (u *uiMock) Handle(event partybus.Event) error { + u.t.Helper() u.t.Logf("UI Handle called: %+v", event.Type) if event == u.finalEvent { - assert.NoError(u.t, u.unsubscribe()) + assert.NoError(u.t, u.subscription.Unsubscribe()) } return u.Called(event).Error(0) } func (u *uiMock) Teardown(_ bool) error { + u.t.Helper() u.t.Logf("UI Teardown called") return u.Called().Error(0) } @@ -51,7 +54,7 @@ func Test_EventLoop_gracefulExit(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.Exit, + Type: event.CLIExit, } worker := func() <-chan error { @@ -183,7 +186,7 @@ func Test_EventLoop_unsubscribeError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.Exit, + Type: event.CLIExit, } worker := func() <-chan error { @@ -252,7 +255,7 @@ func Test_EventLoop_handlerError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.Exit, + Type: event.CLIExit, Error: fmt.Errorf("an exit error occured"), } @@ -377,7 +380,7 @@ func Test_EventLoop_uiTeardownError(t *testing.T) { t.Cleanup(testBus.Close) finalEvent := partybus.Event{ - Type: event.Exit, + Type: event.CLIExit, } worker := func() <-chan error { diff --git a/cmd/syft/cli/options/writer.go b/cmd/syft/cli/options/writer.go index 40c8a2675..1ea4ff1a2 100644 --- a/cmd/syft/cli/options/writer.go +++ b/cmd/syft/cli/options/writer.go @@ -1,6 +1,7 @@ package options import ( + "bytes" "fmt" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/hashicorp/go-multierror" "github.com/mitchellh/go-homedir" + "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/table" @@ -114,14 +116,6 @@ type sbomMultiWriter struct { writers []sbom.Writer } -type nopWriteCloser struct { - io.Writer -} - -func (n nopWriteCloser) Close() error { - return nil -} - // newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) { if len(options) == 0 { @@ -133,9 +127,8 @@ func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, e for _, option := range options { switch len(option.Path) { case 0: - out.writers = append(out.writers, &sbomStreamWriter{ + out.writers = append(out.writers, &sbomPublisher{ format: option.Format, - out: nopWriteCloser{Writer: os.Stdout}, }) default: // create any missing subdirectories @@ -195,3 +188,19 @@ func (w *sbomStreamWriter) Close() error { } return nil } + +// sbomPublisher implements sbom.Writer that publishes results to the event bus +type sbomPublisher struct { + format sbom.Format +} + +// Write the provided SBOM to the data stream +func (w *sbomPublisher) Write(s sbom.SBOM) error { + buf := &bytes.Buffer{} + if err := w.format.Encode(buf, s); err != nil { + return fmt.Errorf("unable to encode SBOM: %w", err) + } + + bus.Report(buf.String()) + return nil +} diff --git a/cmd/syft/cli/options/writer_test.go b/cmd/syft/cli/options/writer_test.go index 2e251234e..643d251cd 100644 --- a/cmd/syft/cli/options/writer_test.go +++ b/cmd/syft/cli/options/writer_test.go @@ -191,6 +191,8 @@ func Test_newSBOMMultiWriter(t *testing.T) { if e.file != "" { assert.FileExists(t, tmp+e.file) } + case *sbomPublisher: + assert.Equal(t, string(w.format.ID()), e.format) default: t.Fatalf("unknown writer type: %T", w) } diff --git a/cmd/syft/cli/packages.go b/cmd/syft/cli/packages.go index 9f34c52bb..88154b199 100644 --- a/cmd/syft/cli/packages.go +++ b/cmd/syft/cli/packages.go @@ -70,6 +70,7 @@ func Packages(v *viper.Viper, app *config.Application, ro *options.RootOptions, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { if app.CheckForAppUpdate { + // TODO: this is broke, the bus isn't available yet checkForApplicationUpdate() } return packages.Run(cmd.Context(), app, args) diff --git a/cmd/syft/cli/packages/packages.go b/cmd/syft/cli/packages/packages.go index 53b2b860d..a84b3c09a 100644 --- a/cmd/syft/cli/packages/packages.go +++ b/cmd/syft/cli/packages/packages.go @@ -10,15 +10,14 @@ import ( "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/syft/cmd/syft/cli/eventloop" "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/cmd/syft/internal/ui" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" "github.com/anchore/syft/internal/file" - "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/formats/template" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" @@ -52,10 +51,12 @@ func Run(_ context.Context, app *config.Application, args []string) error { ) } +// nolint:funlen func execWorker(app *config.Application, userInput string, writer sbom.Writer) <-chan error { errs := make(chan error) go func() { defer close(errs) + defer bus.Exit() detection, err := source.Detect( userInput, @@ -115,12 +116,13 @@ func execWorker(app *config.Application, userInput string, writer sbom.Writer) < if s == nil { errs <- fmt.Errorf("no SBOM produced for %q", userInput) + return } - bus.Publish(partybus.Event{ - Type: event.Exit, - Value: func() error { return writer.Write(*s) }, - }) + if err := writer.Write(*s); err != nil { + errs <- fmt.Errorf("failed to write SBOM: %w", err) + return + } }() return errs } diff --git a/cmd/syft/cli/poweruser.go b/cmd/syft/cli/poweruser.go index f979d3afb..e3c935d9e 100644 --- a/cmd/syft/cli/poweruser.go +++ b/cmd/syft/cli/poweruser.go @@ -41,6 +41,7 @@ func PowerUser(v *viper.Viper, app *config.Application, ro *options.RootOptions) RunE: func(cmd *cobra.Command, args []string) error { if app.CheckForAppUpdate { checkForApplicationUpdate() + // TODO: this is broke, the bus isn't available yet } return poweruser.Run(cmd.Context(), app, args) }, diff --git a/cmd/syft/cli/poweruser/poweruser.go b/cmd/syft/cli/poweruser/poweruser.go index e37455ece..e9a251f3e 100644 --- a/cmd/syft/cli/poweruser/poweruser.go +++ b/cmd/syft/cli/poweruser/poweruser.go @@ -13,14 +13,13 @@ import ( "github.com/anchore/syft/cmd/syft/cli/eventloop" "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/cmd/syft/cli/packages" + "github.com/anchore/syft/cmd/syft/internal/ui" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/formats/syftjson" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" @@ -59,6 +58,7 @@ func execWorker(app *config.Application, userInput string, writer sbom.Writer) < errs := make(chan error) go func() { defer close(errs) + defer bus.Exit() app.Secrets.Cataloger.Enabled = true app.FileMetadata.Cataloger.Enabled = true @@ -133,10 +133,10 @@ func execWorker(app *config.Application, userInput string, writer sbom.Writer) < s.Relationships = append(s.Relationships, packages.MergeRelationships(relationships...)...) - bus.Publish(partybus.Event{ - Type: event.Exit, - Value: func() error { return writer.Write(s) }, - }) + if err := writer.Write(s); err != nil { + errs <- fmt.Errorf("failed to write sbom: %w", err) + return + } }() return errs diff --git a/cmd/syft/cli/ui/__snapshots__/handle_attestation_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_attestation_test.snap new file mode 100755 index 000000000..981402b26 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_attestation_test.snap @@ -0,0 +1,19 @@ + +[TestHandler_handleAttestationStarted/attesting_in_progress/task_line - 1] + ⠋ Creating a thing running a thing +--- + +[TestHandler_handleAttestationStarted/attesting_in_progress/log - 1] + ░░ contents + ░░ of + ░░ stuff! + +--- + +[TestHandler_handleAttestationStarted/attesting_complete/task_line - 1] + ✔ Created a thing running a thing +--- + +[TestHandler_handleAttestationStarted/attesting_complete/log - 1] + +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_cataloger_task_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_cataloger_task_test.snap new file mode 100755 index 000000000..aff1f474a --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_cataloger_task_test.snap @@ -0,0 +1,16 @@ + +[TestHandler_handleCatalogerTaskStarted/cataloging_task_in_progress - 1] + some task title [some value] +--- + +[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_in_progress - 1] + └── some task title [some value] +--- + +[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete - 1] + ✔ └── some task done [some value] +--- + +[TestHandler_handleCatalogerTaskStarted/cataloging_sub_task_complete_with_removal - 1] + +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_fetch_image_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_fetch_image_test.snap new file mode 100755 index 000000000..19e0b1e69 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_fetch_image_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleFetchImage/fetch_image_in_progress - 1] + ⠋ Loading image ━━━━━━━━━━━━━━━━━━━━ [current] the-image +--- + +[TestHandler_handleFetchImage/fetch_image_complete - 1] + ✔ Loaded image the-image +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_file_digests_cataloger_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_file_digests_cataloger_test.snap new file mode 100755 index 000000000..b4572c265 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_file_digests_cataloger_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleFileDigestsCatalogerStarted/cataloging_in_progress - 1] + ⠋ Cataloging file digests ━━━━━━━━━━━━━━━━━━━━ [current] +--- + +[TestHandler_handleFileDigestsCatalogerStarted/cataloging_complete - 1] + ✔ Cataloged file digests +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_file_indexing_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_file_indexing_test.snap new file mode 100755 index 000000000..2f5968e8f --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_file_indexing_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleFileIndexingStarted/cataloging_in_progress - 1] + ⠋ Indexing file system ━━━━━━━━━━━━━━━━━━━━ [current] /some/path +--- + +[TestHandler_handleFileIndexingStarted/cataloging_complete - 1] + ✔ Indexed file system /some/path +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_file_metadata_cataloger_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_file_metadata_cataloger_test.snap new file mode 100755 index 000000000..200a0dfb4 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_file_metadata_cataloger_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleFileMetadataCatalogerStarted/cataloging_in_progress - 1] + ⠋ Cataloging file metadata ━━━━━━━━━━━━━━━━━━━━ [current] +--- + +[TestHandler_handleFileMetadataCatalogerStarted/cataloging_complete - 1] + ✔ Cataloged file metadata +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_package_cataloger_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_package_cataloger_test.snap new file mode 100755 index 000000000..5d5c165e0 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_package_cataloger_test.snap @@ -0,0 +1,16 @@ + +[TestHandler_handlePackageCatalogerStarted/cataloging_in_progress - 1] + ⠋ Cataloging packages [50 packages] +--- + +[TestHandler_handlePackageCatalogerStarted/cataloging_only_files_complete - 1] + ⠋ Cataloging packages [50 packages] +--- + +[TestHandler_handlePackageCatalogerStarted/cataloging_only_packages_complete - 1] + ⠋ Cataloging packages [100 packages] +--- + +[TestHandler_handlePackageCatalogerStarted/cataloging_complete - 1] + ✔ Cataloged packages [100 packages] +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_pull_docker_image_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_pull_docker_image_test.snap new file mode 100755 index 000000000..6f8bf9e29 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_pull_docker_image_test.snap @@ -0,0 +1,12 @@ + +[Test_dockerPullStatusFormatter_Render/pulling - 1] +3 Layers▕▅▃ ▏[12 B / 30 B] +--- + +[Test_dockerPullStatusFormatter_Render/download_complete - 1] +3 Layers▕█▃ ▏[30 B] Extracting... +--- + +[Test_dockerPullStatusFormatter_Render/complete - 1] +3 Layers▕███▏[30 B] Extracting... +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_read_image_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_read_image_test.snap new file mode 100755 index 000000000..ded3b615b --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_read_image_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleReadImage/read_image_in_progress - 1] + ⠋ Parsing image ━━━━━━━━━━━━━━━━━━━━ id +--- + +[TestHandler_handleReadImage/read_image_complete - 1] + ✔ Parsed image id +--- diff --git a/cmd/syft/cli/ui/__snapshots__/handle_secrets_cataloger_test.snap b/cmd/syft/cli/ui/__snapshots__/handle_secrets_cataloger_test.snap new file mode 100755 index 000000000..00a123ef7 --- /dev/null +++ b/cmd/syft/cli/ui/__snapshots__/handle_secrets_cataloger_test.snap @@ -0,0 +1,8 @@ + +[TestHandler_handleSecretsCatalogerStarted/cataloging_in_progress - 1] + ⠋ Cataloging secrets ━━━━━━━━━━━━━━━━━━━━ [64 secrets] +--- + +[TestHandler_handleSecretsCatalogerStarted/cataloging_complete - 1] + ✔ Cataloged secrets [64 secrets] +--- diff --git a/cmd/syft/cli/ui/handle_attestation.go b/cmd/syft/cli/ui/handle_attestation.go new file mode 100644 index 000000000..26c58e389 --- /dev/null +++ b/cmd/syft/cli/ui/handle_attestation.go @@ -0,0 +1,247 @@ +package ui + +import ( + "bufio" + "fmt" + "io" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + "github.com/zyedidia/generic/queue" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" +) + +var ( + _ tea.Model = (*attestLogFrame)(nil) + _ cosignOutputReader = (*backgroundLineReader)(nil) +) + +type attestLogFrame struct { + reader cosignOutputReader + prog progress.Progressable + lines []string + completed bool + failed bool + windowSize tea.WindowSizeMsg + + id uint32 + sequence int + + updateDuration time.Duration + borderStype lipgloss.Style +} + +// attestLogFrameTickMsg indicates that the timer has ticked and we should render a frame. +type attestLogFrameTickMsg struct { + Time time.Time + Sequence int + ID uint32 +} + +type cosignOutputReader interface { + Lines() []string +} + +type backgroundLineReader struct { + limit int + lines *queue.Queue[string] + lock *sync.RWMutex +} + +func (m *Handler) handleAttestationStarted(e partybus.Event) []tea.Model { + reader, prog, taskInfo, err := syftEventParsers.ParseAttestationStartedEvent(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + stage := progress.Stage{} + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: taskInfo.Title.Default, + Running: taskInfo.Title.WhileRunning, + Success: taskInfo.Title.OnSuccess, + }, + taskprogress.WithStagedProgressable( + struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &stage, + }, + ), + ) + + tsk.HideStageOnSuccess = false + + if taskInfo.Context != "" { + tsk.Context = []string{taskInfo.Context} + } + + borderStyle := tsk.HintStyle + + return []tea.Model{ + tsk, + newLogFrame(newBackgroundLineReader(m.Running, reader, &stage), prog, borderStyle), + } +} + +func newLogFrame(reader cosignOutputReader, prog progress.Progressable, borderStyle lipgloss.Style) attestLogFrame { + return attestLogFrame{ + reader: reader, + prog: prog, + id: uuid.Must(uuid.NewUUID()).ID(), + updateDuration: 250 * time.Millisecond, + borderStype: borderStyle, + } +} + +func newBackgroundLineReader(wg *sync.WaitGroup, reader io.Reader, stage *progress.Stage) *backgroundLineReader { + wg.Add(1) + r := &backgroundLineReader{ + limit: 7, + lock: &sync.RWMutex{}, + lines: queue.New[string](), + } + + go func() { + defer wg.Done() + r.read(reader, stage) + }() + + return r +} + +func (l *backgroundLineReader) read(reader io.Reader, stage *progress.Stage) { + s := bufio.NewScanner(reader) + + for s.Scan() { + l.lock.Lock() + + text := s.Text() + l.lines.Enqueue(text) + + if strings.Contains(text, "tlog entry created with index") { + fields := strings.SplitN(text, ":", 2) + present := text + if len(fields) == 2 { + present = fmt.Sprintf("transparency log index: %s", fields[1]) + } + stage.Current = present + } else if strings.Contains(text, "WARNING: skipping transparency log upload") { + stage.Current = "transparency log upload skipped" + } + + // only show the last X lines of the shell output + for l.lines.Len() > l.limit { + l.lines.Dequeue() + } + + l.lock.Unlock() + } +} + +func (l backgroundLineReader) Lines() []string { + l.lock.RLock() + defer l.lock.RUnlock() + + var lines []string + + l.lines.Each(func(line string) { + lines = append(lines, line) + }) + + return lines +} + +func (l attestLogFrame) Init() tea.Cmd { + // this is the periodic update of state information + return func() tea.Msg { + return attestLogFrameTickMsg{ + // The time at which the tick occurred. + Time: time.Now(), + + // The ID of the log frame that this message belongs to. This can be + // helpful when routing messages, however bear in mind that log frames + // will ignore messages that don't contain ID by default. + ID: l.id, + + Sequence: l.sequence, + } + } +} + +func (l attestLogFrame) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + l.windowSize = msg + return l, nil + + case attestLogFrameTickMsg: + l.lines = l.reader.Lines() + + l.completed = progress.IsCompleted(l.prog) + err := l.prog.Error() + l.failed = err != nil && !progress.IsErrCompleted(err) + + tickCmd := l.handleTick(msg) + + return l, tickCmd + } + + return l, nil +} + +func (l attestLogFrame) View() string { + if l.completed && !l.failed { + return "" + } + + sb := strings.Builder{} + + for _, line := range l.lines { + sb.WriteString(fmt.Sprintf(" %s %s\n", l.borderStype.Render("░░"), line)) + } + + return sb.String() +} + +func (l attestLogFrame) queueNextTick() tea.Cmd { + return tea.Tick(l.updateDuration, func(t time.Time) tea.Msg { + return attestLogFrameTickMsg{ + Time: t, + ID: l.id, + Sequence: l.sequence, + } + }) +} + +func (l *attestLogFrame) handleTick(msg attestLogFrameTickMsg) tea.Cmd { + // If an ID is set, and the ID doesn't belong to this log frame, reject the message. + if msg.ID > 0 && msg.ID != l.id { + return nil + } + + // If a sequence is set, and it's not the one we expect, reject the message. + // This prevents the log frame from receiving too many messages and + // thus updating too frequently. + if msg.Sequence > 0 && msg.Sequence != l.sequence { + return nil + } + + l.sequence++ + + // note: even if the log is completed we should still respond to stage changes and window size events + return l.queueNextTick() +} diff --git a/cmd/syft/cli/ui/handle_attestation_test.go b/cmd/syft/cli/ui/handle_attestation_test.go new file mode 100644 index 000000000..6fcc9fbfe --- /dev/null +++ b/cmd/syft/cli/ui/handle_attestation_test.go @@ -0,0 +1,133 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/monitor" +) + +func TestHandler_handleAttestationStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "attesting in progress", + // note: this model depends on a background reader. Multiple iterations ensures that the + // reader has time to at least start and process the test fixture before the runModel + // test harness completes (which is a fake event loop anyway). + iterations: 2, + eventFn: func(t *testing.T) partybus.Event { + reader := strings.NewReader("contents\nof\nstuff!") + + src := monitor.GenericTask{ + Title: monitor.Title{ + Default: "Create a thing", + WhileRunning: "Creating a thing", + OnSuccess: "Created a thing", + }, + Context: "running a thing", + } + + mon := progress.NewManual(-1) + mon.Set(50) + + value := &monitor.ShellProgress{ + Reader: reader, + Progressable: mon, + } + + return partybus.Event{ + Type: syftEvent.AttestationStarted, + Source: src, + Value: value, + } + }, + }, + { + name: "attesting complete", + // note: this model depends on a background reader. Multiple iterations ensures that the + // reader has time to at least start and process the test fixture before the runModel + // test harness completes (which is a fake event loop anyway). + iterations: 2, + eventFn: func(t *testing.T) partybus.Event { + reader := strings.NewReader("contents\nof\nstuff!") + + src := monitor.GenericTask{ + Title: monitor.Title{ + Default: "Create a thing", + WhileRunning: "Creating a thing", + OnSuccess: "Created a thing", + }, + Context: "running a thing", + } + + mon := progress.NewManual(-1) + mon.Set(50) + mon.SetCompleted() + + value := &monitor.ShellProgress{ + Reader: reader, + Progressable: mon, + } + + return partybus.Event{ + Type: syftEvent.AttestationStarted, + Source: src, + Value: value, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 2) + + t.Run("task line", func(t *testing.T) { + tsk, ok := models[0].(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + + t.Run("log", func(t *testing.T) { + log, ok := models[1].(attestLogFrame) + require.True(t, ok) + got := runModel(t, log, tt.iterations, attestLogFrameTickMsg{ + Time: time.Now(), + Sequence: log.sequence, + ID: log.id, + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + + }) + } +} diff --git a/cmd/syft/cli/ui/handle_cataloger_task.go b/cmd/syft/cli/ui/handle_cataloger_task.go new file mode 100644 index 000000000..393bd6e7a --- /dev/null +++ b/cmd/syft/cli/ui/handle_cataloger_task.go @@ -0,0 +1,72 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/event/monitor" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" +) + +var _ progress.Stager = (*catalogerTaskStageAdapter)(nil) + +type catalogerTaskStageAdapter struct { + mon *monitor.CatalogerTask +} + +func newCatalogerTaskStageAdapter(mon *monitor.CatalogerTask) *catalogerTaskStageAdapter { + return &catalogerTaskStageAdapter{ + mon: mon, + } +} + +func (c catalogerTaskStageAdapter) Stage() string { + return c.mon.GetValue() +} + +func (m *Handler) handleCatalogerTaskStarted(e partybus.Event) []tea.Model { + mon, err := syftEventParsers.ParseCatalogerTaskStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + var prefix string + if mon.SubStatus { + // TODO: support list of sub-statuses, not just a single leaf + prefix = "└── " + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + // TODO: prefix should not be part of the title, but instead a separate field that is aware of the tree structure + Default: prefix + mon.Title, + Running: prefix + mon.Title, + Success: prefix + mon.TitleOnCompletion, + }, + taskprogress.WithStagedProgressable( + struct { + progress.Stager + progress.Progressable + }{ + Progressable: mon.GetMonitor(), + Stager: newCatalogerTaskStageAdapter(mon), + }, + ), + ) + + // TODO: this isn't ideal since the model stays around after it is no longer needed, but it works for now + tsk.HideOnSuccess = mon.RemoveOnCompletion + tsk.HideStageOnSuccess = false + tsk.HideProgressOnSuccess = false + + tsk.TitleStyle = lipgloss.NewStyle() + // TODO: this is a hack to get the spinner to not show up, but ideally the component would support making the spinner optional + tsk.Spinner.Spinner.Frames = []string{" "} + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_cataloger_task_test.go b/cmd/syft/cli/ui/handle_cataloger_task_test.go new file mode 100644 index 000000000..055694588 --- /dev/null +++ b/cmd/syft/cli/ui/handle_cataloger_task_test.go @@ -0,0 +1,123 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/monitor" +) + +func TestHandler_handleCatalogerTaskStarted(t *testing.T) { + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging task in progress", + eventFn: func(t *testing.T) partybus.Event { + src := &monitor.CatalogerTask{ + SubStatus: false, + RemoveOnCompletion: false, + Title: "some task title", + TitleOnCompletion: "some task done", + } + + src.SetValue("some value") + + return partybus.Event{ + Type: syftEvent.CatalogerTaskStarted, + Source: src, + } + }, + }, + { + name: "cataloging sub task in progress", + eventFn: func(t *testing.T) partybus.Event { + src := &monitor.CatalogerTask{ + SubStatus: true, + RemoveOnCompletion: false, + Title: "some task title", + TitleOnCompletion: "some task done", + } + + src.SetValue("some value") + + return partybus.Event{ + Type: syftEvent.CatalogerTaskStarted, + Source: src, + } + }, + }, + { + name: "cataloging sub task complete", + eventFn: func(t *testing.T) partybus.Event { + src := &monitor.CatalogerTask{ + SubStatus: true, + RemoveOnCompletion: false, + Title: "some task title", + TitleOnCompletion: "some task done", + } + + src.SetValue("some value") + src.SetCompleted() + + return partybus.Event{ + Type: syftEvent.CatalogerTaskStarted, + Source: src, + } + }, + }, + { + name: "cataloging sub task complete with removal", + eventFn: func(t *testing.T) partybus.Event { + src := &monitor.CatalogerTask{ + SubStatus: true, + RemoveOnCompletion: true, + Title: "some task title", + TitleOnCompletion: "some task done", + } + + src.SetValue("some value") + src.SetCompleted() + + return partybus.Event{ + Type: syftEvent.CatalogerTaskStarted, + Source: src, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_fetch_image.go b/cmd/syft/cli/ui/handle_fetch_image.go new file mode 100644 index 000000000..9821853a0 --- /dev/null +++ b/cmd/syft/cli/ui/handle_fetch_image.go @@ -0,0 +1,32 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers" + "github.com/anchore/syft/internal/log" +) + +func (m *Handler) handleFetchImage(e partybus.Event) []tea.Model { + imgName, prog, err := stereoEventParsers.ParseFetchImage(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Load image", + Running: "Loading image", + Success: "Loaded image", + }, + taskprogress.WithStagedProgressable(prog), + ) + if imgName != "" { + tsk.Context = []string{imgName} + } + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_fetch_image_test.go b/cmd/syft/cli/ui/handle_fetch_image_test.go new file mode 100644 index 000000000..c514b9865 --- /dev/null +++ b/cmd/syft/cli/ui/handle_fetch_image_test.go @@ -0,0 +1,99 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoscopeEvent "github.com/anchore/stereoscope/pkg/event" +) + +func TestHandler_handleFetchImage(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "fetch image in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: stereoscopeEvent.FetchImage, + Source: "the-image", + Value: mon, + } + }, + }, + { + name: "fetch image complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: stereoscopeEvent.FetchImage, + Source: "the-image", + Value: mon, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_file_digests_cataloger.go b/cmd/syft/cli/ui/handle_file_digests_cataloger.go new file mode 100644 index 000000000..79550e9cf --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_digests_cataloger.go @@ -0,0 +1,28 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" +) + +func (m *Handler) handleFileDigestsCatalogerStarted(e partybus.Event) []tea.Model { + prog, err := syftEventParsers.ParseFileDigestsCatalogingStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Catalog file digests", + Running: "Cataloging file digests", + Success: "Cataloged file digests", + }, taskprogress.WithStagedProgressable(prog), + ) + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_file_digests_cataloger_test.go b/cmd/syft/cli/ui/handle_file_digests_cataloger_test.go new file mode 100644 index 000000000..2e74009a4 --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_digests_cataloger_test.go @@ -0,0 +1,97 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" +) + +func TestHandler_handleFileDigestsCatalogerStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileDigestsCatalogerStarted, + Value: mon, + } + }, + }, + { + name: "cataloging complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileDigestsCatalogerStarted, + Value: mon, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_file_indexing.go b/cmd/syft/cli/ui/handle_file_indexing.go new file mode 100644 index 000000000..7d2eef9b5 --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_indexing.go @@ -0,0 +1,31 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" +) + +func (m *Handler) handleFileIndexingStarted(e partybus.Event) []tea.Model { + path, prog, err := syftEventParsers.ParseFileIndexingStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Index files system", + Running: "Indexing file system", + Success: "Indexed file system", + }, + taskprogress.WithStagedProgressable(prog), + ) + + tsk.Context = []string{path} + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_file_indexing_test.go b/cmd/syft/cli/ui/handle_file_indexing_test.go new file mode 100644 index 000000000..86473c411 --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_indexing_test.go @@ -0,0 +1,99 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" +) + +func TestHandler_handleFileIndexingStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileIndexingStarted, + Source: "/some/path", + Value: mon, + } + }, + }, + { + name: "cataloging complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileIndexingStarted, + Source: "/some/path", + Value: mon, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_file_metadata_cataloger.go b/cmd/syft/cli/ui/handle_file_metadata_cataloger.go new file mode 100644 index 000000000..58535abc1 --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_metadata_cataloger.go @@ -0,0 +1,29 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" +) + +func (m *Handler) handleFileMetadataCatalogerStarted(e partybus.Event) []tea.Model { + prog, err := syftEventParsers.ParseFileMetadataCatalogingStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Catalog file metadata", + Running: "Cataloging file metadata", + Success: "Cataloged file metadata", + }, + taskprogress.WithStagedProgressable(prog), + ) + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_file_metadata_cataloger_test.go b/cmd/syft/cli/ui/handle_file_metadata_cataloger_test.go new file mode 100644 index 000000000..d247001c8 --- /dev/null +++ b/cmd/syft/cli/ui/handle_file_metadata_cataloger_test.go @@ -0,0 +1,97 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" +) + +func TestHandler_handleFileMetadataCatalogerStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileMetadataCatalogerStarted, + Value: mon, + } + }, + }, + { + name: "cataloging complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + mon := struct { + progress.Progressable + progress.Stager + }{ + Progressable: prog, + Stager: &progress.Stage{ + Current: "current", + }, + } + + return partybus.Event{ + Type: syftEvent.FileMetadataCatalogerStarted, + Value: mon, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_package_cataloger.go b/cmd/syft/cli/ui/handle_package_cataloger.go new file mode 100644 index 000000000..3aa2f9330 --- /dev/null +++ b/cmd/syft/cli/ui/handle_package_cataloger.go @@ -0,0 +1,87 @@ +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" + "github.com/anchore/syft/syft/pkg/cataloger" +) + +var _ progress.StagedProgressable = (*packageCatalogerProgressAdapter)(nil) + +type packageCatalogerProgressAdapter struct { + monitor *cataloger.Monitor + monitors []progress.Monitorable +} + +func newPackageCatalogerProgressAdapter(monitor *cataloger.Monitor) packageCatalogerProgressAdapter { + return packageCatalogerProgressAdapter{ + monitor: monitor, + monitors: []progress.Monitorable{ + monitor.FilesProcessed, + monitor.PackagesDiscovered, + }, + } +} + +func (p packageCatalogerProgressAdapter) Stage() string { + return fmt.Sprintf("%d packages", p.monitor.PackagesDiscovered.Current()) +} + +func (p packageCatalogerProgressAdapter) Current() int64 { + return p.monitor.PackagesDiscovered.Current() +} + +func (p packageCatalogerProgressAdapter) Error() error { + completedMonitors := 0 + for _, monitor := range p.monitors { + err := monitor.Error() + if err == nil { + continue + } + if progress.IsErrCompleted(err) { + completedMonitors++ + continue + } + // something went wrong + return err + } + if completedMonitors == len(p.monitors) && len(p.monitors) > 0 { + return p.monitors[0].Error() + } + return nil +} + +func (p packageCatalogerProgressAdapter) Size() int64 { + // this is an inherently unknown value (indeterminate total number of packages to discover) + return -1 +} + +func (m *Handler) handlePackageCatalogerStarted(e partybus.Event) []tea.Model { + monitor, err := syftEventParsers.ParsePackageCatalogerStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Catalog packages", + Running: "Cataloging packages", + Success: "Cataloged packages", + }, + taskprogress.WithStagedProgressable( + newPackageCatalogerProgressAdapter(monitor), + ), + ) + + tsk.HideStageOnSuccess = false + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_package_cataloger_test.go b/cmd/syft/cli/ui/handle_package_cataloger_test.go new file mode 100644 index 000000000..a5a72d59d --- /dev/null +++ b/cmd/syft/cli/ui/handle_package_cataloger_test.go @@ -0,0 +1,133 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/pkg/cataloger" +) + +func TestHandler_handlePackageCatalogerStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + mon := cataloger.Monitor{ + FilesProcessed: progress.NewManual(-1), + PackagesDiscovered: prog, + } + + return partybus.Event{ + Type: syftEvent.PackageCatalogerStarted, + Value: mon, + } + }, + }, + { + name: "cataloging only files complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + files := progress.NewManual(-1) + files.SetCompleted() + + mon := cataloger.Monitor{ + FilesProcessed: files, + PackagesDiscovered: prog, + } + + return partybus.Event{ + Type: syftEvent.PackageCatalogerStarted, + Value: mon, + } + }, + }, + { + name: "cataloging only packages complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + files := progress.NewManual(-1) + + mon := cataloger.Monitor{ + FilesProcessed: files, + PackagesDiscovered: prog, + } + + return partybus.Event{ + Type: syftEvent.PackageCatalogerStarted, + Value: mon, + } + }, + }, + { + name: "cataloging complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + files := progress.NewManual(-1) + files.SetCompleted() + + mon := cataloger.Monitor{ + FilesProcessed: files, + PackagesDiscovered: prog, + } + + return partybus.Event{ + Type: syftEvent.PackageCatalogerStarted, + Value: mon, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_pull_docker_image.go b/cmd/syft/cli/ui/handle_pull_docker_image.go new file mode 100644 index 000000000..6675e3aeb --- /dev/null +++ b/cmd/syft/cli/ui/handle_pull_docker_image.go @@ -0,0 +1,201 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/dustin/go-humanize" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoscopeParsers "github.com/anchore/stereoscope/pkg/event/parsers" + "github.com/anchore/stereoscope/pkg/image/docker" + "github.com/anchore/syft/internal/log" +) + +var _ interface { + progress.Stager + progress.Progressable +} = (*dockerPullProgressAdapter)(nil) + +type dockerPullStatus interface { + Complete() bool + Layers() []docker.LayerID + Current(docker.LayerID) docker.LayerState +} + +type dockerPullProgressAdapter struct { + status dockerPullStatus + formatter dockerPullStatusFormatter +} + +type dockerPullStatusFormatter struct { + auxInfoStyle lipgloss.Style + dockerPullCompletedStyle lipgloss.Style + dockerPullDownloadStyle lipgloss.Style + dockerPullExtractStyle lipgloss.Style + dockerPullStageChars []string + layerCaps []string +} + +func (m *Handler) handlePullDockerImage(e partybus.Event) []tea.Model { + _, pullStatus, err := stereoscopeParsers.ParsePullDockerImage(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Pull image", + Running: "Pulling image", + Success: "Pulled image", + }, + taskprogress.WithStagedProgressable( + newDockerPullProgressAdapter(pullStatus), + ), + ) + + tsk.HintStyle = lipgloss.NewStyle() + tsk.HintEndCaps = nil + + return []tea.Model{tsk} +} + +func newDockerPullProgressAdapter(status dockerPullStatus) *dockerPullProgressAdapter { + return &dockerPullProgressAdapter{ + status: status, + formatter: newDockerPullStatusFormatter(), + } +} + +func newDockerPullStatusFormatter() dockerPullStatusFormatter { + return dockerPullStatusFormatter{ + auxInfoStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")), + dockerPullCompletedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#fcba03")), + dockerPullDownloadStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")), + dockerPullExtractStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#ffffff")), + dockerPullStageChars: strings.Split("▁▃▄▅▆▇█", ""), + layerCaps: strings.Split("▕▏", ""), + } +} + +func (d dockerPullProgressAdapter) Size() int64 { + return -1 +} + +func (d dockerPullProgressAdapter) Current() int64 { + return 1 +} + +func (d dockerPullProgressAdapter) Error() error { + if d.status.Complete() { + return progress.ErrCompleted + } + // TODO: return intermediate error indications + return nil +} + +func (d dockerPullProgressAdapter) Stage() string { + return d.formatter.Render(d.status) +} + +// Render crafts the given docker image pull status summarized into a single line. +func (f dockerPullStatusFormatter) Render(pullStatus dockerPullStatus) string { + var size, current uint64 + + layers := pullStatus.Layers() + status := make(map[docker.LayerID]docker.LayerState) + completed := make([]string, len(layers)) + + // fetch the current state + for idx, layer := range layers { + completed[idx] = " " + status[layer] = pullStatus.Current(layer) + } + + numCompleted := 0 + for idx, layer := range layers { + prog := status[layer].PhaseProgress + curN := prog.Current() + curSize := prog.Size() + + if progress.IsCompleted(prog) { + input := f.dockerPullStageChars[len(f.dockerPullStageChars)-1] + completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input) + } else if curN != 0 { + var ratio float64 + switch { + case curN == 0 || curSize < 0: + ratio = 0 + case curN >= curSize: + ratio = 1 + default: + ratio = float64(curN) / float64(curSize) + } + + i := int(ratio * float64(len(f.dockerPullStageChars)-1)) + input := f.dockerPullStageChars[i] + completed[idx] = f.formatDockerPullPhase(status[layer].Phase, input) + } + + if progress.IsErrCompleted(status[layer].DownloadProgress.Error()) { + numCompleted++ + } + } + + for _, layer := range layers { + prog := status[layer].DownloadProgress + size += uint64(prog.Size()) + current += uint64(prog.Current()) + } + + var progStr, auxInfo string + if len(layers) > 0 { + render := strings.Join(completed, "") + prefix := f.dockerPullCompletedStyle.Render(fmt.Sprintf("%d Layers", len(layers))) + auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s / %s]", humanize.Bytes(current), humanize.Bytes(size))) + if len(layers) == numCompleted { + auxInfo = f.auxInfoStyle.Render(fmt.Sprintf("[%s] Extracting...", humanize.Bytes(size))) + } + + progStr = fmt.Sprintf("%s%s%s%s", prefix, f.layerCap(false), render, f.layerCap(true)) + } + + return progStr + auxInfo +} + +// formatDockerPullPhase returns a single character that represents the status of a layer pull. +func (f dockerPullStatusFormatter) formatDockerPullPhase(phase docker.PullPhase, inputStr string) string { + switch phase { + case docker.WaitingPhase: + // ignore any progress related to waiting + return " " + case docker.PullingFsPhase, docker.DownloadingPhase: + return f.dockerPullDownloadStyle.Render(inputStr) + case docker.DownloadCompletePhase: + return f.dockerPullDownloadStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1]) + case docker.ExtractingPhase: + return f.dockerPullExtractStyle.Render(inputStr) + case docker.VerifyingChecksumPhase, docker.PullCompletePhase: + return f.dockerPullCompletedStyle.Render(inputStr) + case docker.AlreadyExistsPhase: + return f.dockerPullCompletedStyle.Render(f.dockerPullStageChars[len(f.dockerPullStageChars)-1]) + default: + return inputStr + } +} + +func (f dockerPullStatusFormatter) layerCap(end bool) string { + l := len(f.layerCaps) + if l == 0 { + return "" + } + if end { + return f.layerCaps[l-1] + } + return f.layerCaps[0] +} diff --git a/cmd/syft/cli/ui/handle_pull_docker_image_test.go b/cmd/syft/cli/ui/handle_pull_docker_image_test.go new file mode 100644 index 000000000..afa986fc2 --- /dev/null +++ b/cmd/syft/cli/ui/handle_pull_docker_image_test.go @@ -0,0 +1,163 @@ +package ui + +import ( + "testing" + + "github.com/gkampitakis/go-snaps/snaps" + "github.com/wagoodman/go-progress" + + "github.com/anchore/stereoscope/pkg/image/docker" +) + +var _ dockerPullStatus = (*mockDockerPullStatus)(nil) + +type mockDockerPullStatus struct { + complete bool + layers []docker.LayerID + current map[docker.LayerID]docker.LayerState +} + +func (m mockDockerPullStatus) Complete() bool { + return m.complete +} + +func (m mockDockerPullStatus) Layers() []docker.LayerID { + return m.layers +} + +func (m mockDockerPullStatus) Current(id docker.LayerID) docker.LayerState { + return m.current[id] +} + +func Test_dockerPullStatusFormatter_Render(t *testing.T) { + + tests := []struct { + name string + status dockerPullStatus + }{ + { + name: "pulling", + status: func() dockerPullStatus { + complete := progress.NewManual(10) + complete.Set(10) + complete.SetCompleted() + + quarter := progress.NewManual(10) + quarter.Set(2) + + half := progress.NewManual(10) + half.Set(6) + + empty := progress.NewManual(10) + + return mockDockerPullStatus{ + complete: false, + layers: []docker.LayerID{ + "sha256:1", + "sha256:2", + "sha256:3", + }, + current: map[docker.LayerID]docker.LayerState{ + "sha256:1": { + Phase: docker.ExtractingPhase, + PhaseProgress: half, + DownloadProgress: complete, + }, + "sha256:2": { + Phase: docker.DownloadingPhase, + PhaseProgress: quarter, + DownloadProgress: quarter, + }, + "sha256:3": { + Phase: docker.WaitingPhase, + PhaseProgress: empty, + DownloadProgress: empty, + }, + }, + } + }(), + }, + { + name: "download complete", + status: func() dockerPullStatus { + complete := progress.NewManual(10) + complete.Set(10) + complete.SetCompleted() + + quarter := progress.NewManual(10) + quarter.Set(2) + + half := progress.NewManual(10) + half.Set(6) + + empty := progress.NewManual(10) + + return mockDockerPullStatus{ + complete: false, + layers: []docker.LayerID{ + "sha256:1", + "sha256:2", + "sha256:3", + }, + current: map[docker.LayerID]docker.LayerState{ + "sha256:1": { + Phase: docker.ExtractingPhase, + PhaseProgress: complete, + DownloadProgress: complete, + }, + "sha256:2": { + Phase: docker.ExtractingPhase, + PhaseProgress: quarter, + DownloadProgress: complete, + }, + "sha256:3": { + Phase: docker.ExtractingPhase, + PhaseProgress: empty, + DownloadProgress: complete, + }, + }, + } + }(), + }, + { + name: "complete", + status: func() dockerPullStatus { + complete := progress.NewManual(10) + complete.Set(10) + complete.SetCompleted() + + return mockDockerPullStatus{ + complete: true, + layers: []docker.LayerID{ + "sha256:1", + "sha256:2", + "sha256:3", + }, + current: map[docker.LayerID]docker.LayerState{ + "sha256:1": { + Phase: docker.PullCompletePhase, + PhaseProgress: complete, + DownloadProgress: complete, + }, + "sha256:2": { + Phase: docker.PullCompletePhase, + PhaseProgress: complete, + DownloadProgress: complete, + }, + "sha256:3": { + Phase: docker.PullCompletePhase, + PhaseProgress: complete, + DownloadProgress: complete, + }, + }, + } + }(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newDockerPullStatusFormatter() + snaps.MatchSnapshot(t, f.Render(tt.status)) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_read_image.go b/cmd/syft/cli/ui/handle_read_image.go new file mode 100644 index 000000000..e55ff485e --- /dev/null +++ b/cmd/syft/cli/ui/handle_read_image.go @@ -0,0 +1,33 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers" + "github.com/anchore/syft/internal/log" +) + +func (m *Handler) handleReadImage(e partybus.Event) []tea.Model { + imgMetadata, prog, err := stereoEventParsers.ParseReadImage(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Parse image", + Running: "Parsing image", + Success: "Parsed image", + }, + taskprogress.WithProgress(prog), + ) + + if imgMetadata != nil { + tsk.Context = []string{imgMetadata.ID} + } + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_read_image_test.go b/cmd/syft/cli/ui/handle_read_image_test.go new file mode 100644 index 000000000..864d1e782 --- /dev/null +++ b/cmd/syft/cli/ui/handle_read_image_test.go @@ -0,0 +1,117 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoscopeEvent "github.com/anchore/stereoscope/pkg/event" + "github.com/anchore/stereoscope/pkg/image" +) + +func TestHandler_handleReadImage(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "read image in progress", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(50) + + src := image.Metadata{ + ID: "id", + Size: 42, + Config: v1.ConfigFile{ + Architecture: "arch", + Author: "auth", + Container: "cont", + OS: "os", + OSVersion: "os-ver", + Variant: "vari", + }, + MediaType: "media", + ManifestDigest: "digest", + Architecture: "arch", + Variant: "var", + OS: "os", + } + + return partybus.Event{ + Type: stereoscopeEvent.ReadImage, + Source: src, + Value: prog, + } + }, + }, + { + name: "read image complete", + eventFn: func(t *testing.T) partybus.Event { + prog := &progress.Manual{} + prog.SetTotal(100) + prog.Set(100) + prog.SetCompleted() + + src := image.Metadata{ + ID: "id", + Size: 42, + Config: v1.ConfigFile{ + Architecture: "arch", + Author: "auth", + Container: "cont", + OS: "os", + OSVersion: "os-ver", + Variant: "vari", + }, + MediaType: "media", + ManifestDigest: "digest", + Architecture: "arch", + Variant: "var", + OS: "os", + } + + return partybus.Event{ + Type: stereoscopeEvent.ReadImage, + Source: src, + Value: prog, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handle_secrets_cataloger.go b/cmd/syft/cli/ui/handle_secrets_cataloger.go new file mode 100644 index 000000000..95b96454b --- /dev/null +++ b/cmd/syft/cli/ui/handle_secrets_cataloger.go @@ -0,0 +1,57 @@ +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + "github.com/anchore/syft/internal/log" + syftEventParsers "github.com/anchore/syft/syft/event/parsers" + "github.com/anchore/syft/syft/file/cataloger/secrets" +) + +var _ progress.StagedProgressable = (*secretsCatalogerProgressAdapter)(nil) + +// Deprecated: will be removed in syft 1.0 +type secretsCatalogerProgressAdapter struct { + *secrets.Monitor +} + +// Deprecated: will be removed in syft 1.0 +func newSecretsCatalogerProgressAdapter(monitor *secrets.Monitor) secretsCatalogerProgressAdapter { + return secretsCatalogerProgressAdapter{ + Monitor: monitor, + } +} + +func (s secretsCatalogerProgressAdapter) Stage() string { + return fmt.Sprintf("%d secrets", s.Monitor.SecretsDiscovered.Current()) +} + +// Deprecated: will be removed in syft 1.0 +func (m *Handler) handleSecretsCatalogerStarted(e partybus.Event) []tea.Model { + mon, err := syftEventParsers.ParseSecretsCatalogingStarted(e) + if err != nil { + log.WithFields("error", err).Warn("unable to parse event") + return nil + } + + tsk := m.newTaskProgress( + taskprogress.Title{ + Default: "Catalog secrets", + Running: "Cataloging secrets", + Success: "Cataloged secrets", + }, + + taskprogress.WithStagedProgressable( + newSecretsCatalogerProgressAdapter(mon), + ), + ) + + tsk.HideStageOnSuccess = false + + return []tea.Model{tsk} +} diff --git a/cmd/syft/cli/ui/handle_secrets_cataloger_test.go b/cmd/syft/cli/ui/handle_secrets_cataloger_test.go new file mode 100644 index 000000000..3a04cbce5 --- /dev/null +++ b/cmd/syft/cli/ui/handle_secrets_cataloger_test.go @@ -0,0 +1,96 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly/bubbles/taskprogress" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/file/cataloger/secrets" +) + +func TestHandler_handleSecretsCatalogerStarted(t *testing.T) { + + tests := []struct { + name string + eventFn func(*testing.T) partybus.Event + iterations int + }{ + { + name: "cataloging in progress", + eventFn: func(t *testing.T) partybus.Event { + stage := &progress.Stage{ + Current: "current", + } + secretsDiscovered := progress.NewManual(-1) + secretsDiscovered.Set(64) + prog := progress.NewManual(72) + prog.Set(50) + + return partybus.Event{ + Type: syftEvent.SecretsCatalogerStarted, + Source: secretsDiscovered, + Value: secrets.Monitor{ + Stager: progress.Stager(stage), + SecretsDiscovered: secretsDiscovered, + Progressable: prog, + }, + } + }, + }, + { + name: "cataloging complete", + eventFn: func(t *testing.T) partybus.Event { + stage := &progress.Stage{ + Current: "current", + } + secretsDiscovered := progress.NewManual(-1) + secretsDiscovered.Set(64) + prog := progress.NewManual(72) + prog.Set(72) + prog.SetCompleted() + + return partybus.Event{ + Type: syftEvent.SecretsCatalogerStarted, + Source: secretsDiscovered, + Value: secrets.Monitor{ + Stager: progress.Stager(stage), + SecretsDiscovered: secretsDiscovered, + Progressable: prog, + }, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := tt.eventFn(t) + handler := New(DefaultHandlerConfig()) + handler.WindowSize = tea.WindowSizeMsg{ + Width: 100, + Height: 80, + } + + models := handler.Handle(event) + require.Len(t, models, 1) + model := models[0] + + tsk, ok := model.(taskprogress.Model) + require.True(t, ok) + + got := runModel(t, tsk, tt.iterations, taskprogress.TickMsg{ + Time: time.Now(), + Sequence: tsk.Sequence(), + ID: tsk.ID(), + }) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/cmd/syft/cli/ui/handler.go b/cmd/syft/cli/ui/handler.go new file mode 100644 index 000000000..8dae89dca --- /dev/null +++ b/cmd/syft/cli/ui/handler.go @@ -0,0 +1,68 @@ +package ui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly" + "github.com/anchore/bubbly/bubbles/taskprogress" + stereoscopeEvent "github.com/anchore/stereoscope/pkg/event" + syftEvent "github.com/anchore/syft/syft/event" +) + +var _ bubbly.EventHandler = (*Handler)(nil) + +type HandlerConfig struct { + TitleWidth int + AdjustDefaultTask func(taskprogress.Model) taskprogress.Model +} + +type Handler struct { + WindowSize tea.WindowSizeMsg + Running *sync.WaitGroup + Config HandlerConfig + + bubbly.EventHandler +} + +func DefaultHandlerConfig() HandlerConfig { + return HandlerConfig{ + TitleWidth: 30, + } +} + +func New(cfg HandlerConfig) *Handler { + d := bubbly.NewEventDispatcher() + + h := &Handler{ + EventHandler: d, + Running: &sync.WaitGroup{}, + Config: cfg, + } + + // register all supported event types with the respective handler functions + d.AddHandlers(map[partybus.EventType]bubbly.EventHandlerFn{ + stereoscopeEvent.PullDockerImage: h.handlePullDockerImage, + stereoscopeEvent.ReadImage: h.handleReadImage, + stereoscopeEvent.FetchImage: h.handleFetchImage, + syftEvent.PackageCatalogerStarted: h.handlePackageCatalogerStarted, + syftEvent.FileDigestsCatalogerStarted: h.handleFileDigestsCatalogerStarted, + syftEvent.FileMetadataCatalogerStarted: h.handleFileMetadataCatalogerStarted, + syftEvent.FileIndexingStarted: h.handleFileIndexingStarted, + syftEvent.AttestationStarted: h.handleAttestationStarted, + syftEvent.CatalogerTaskStarted: h.handleCatalogerTaskStarted, + + // deprecated + syftEvent.SecretsCatalogerStarted: h.handleSecretsCatalogerStarted, + }) + + return h +} + +func (m *Handler) Update(msg tea.Msg) { + if msg, ok := msg.(tea.WindowSizeMsg); ok { + m.WindowSize = msg + } +} diff --git a/cmd/syft/cli/ui/new_task_progress.go b/cmd/syft/cli/ui/new_task_progress.go new file mode 100644 index 000000000..036f7b37d --- /dev/null +++ b/cmd/syft/cli/ui/new_task_progress.go @@ -0,0 +1,19 @@ +package ui + +import "github.com/anchore/bubbly/bubbles/taskprogress" + +func (m Handler) newTaskProgress(title taskprogress.Title, opts ...taskprogress.Option) taskprogress.Model { + tsk := taskprogress.New(m.Running, opts...) + + tsk.HideProgressOnSuccess = true + tsk.HideStageOnSuccess = true + tsk.WindowSize = m.WindowSize + tsk.TitleWidth = m.Config.TitleWidth + tsk.TitleOptions = title + + if m.Config.AdjustDefaultTask != nil { + tsk = m.Config.AdjustDefaultTask(tsk) + } + + return tsk +} diff --git a/cmd/syft/cli/ui/util_test.go b/cmd/syft/cli/ui/util_test.go new file mode 100644 index 000000000..cfd5ddf54 --- /dev/null +++ b/cmd/syft/cli/ui/util_test.go @@ -0,0 +1,62 @@ +package ui + +import ( + "reflect" + "testing" + "unsafe" + + tea "github.com/charmbracelet/bubbletea" +) + +func runModel(t testing.TB, m tea.Model, iterations int, message tea.Msg) string { + t.Helper() + if iterations == 0 { + iterations = 1 + } + m.Init() + var cmd tea.Cmd = func() tea.Msg { + return message + } + + for i := 0; cmd != nil && i < iterations; i++ { + msgs := flatten(cmd()) + var nextCmds []tea.Cmd + var next tea.Cmd + for _, msg := range msgs { + t.Logf("Message: %+v %+v\n", reflect.TypeOf(msg), msg) + m, next = m.Update(msg) + nextCmds = append(nextCmds, next) + } + cmd = tea.Batch(nextCmds...) + } + return m.View() +} + +func flatten(p tea.Msg) (msgs []tea.Msg) { + if reflect.TypeOf(p).Name() == "batchMsg" { + partials := extractBatchMessages(p) + for _, m := range partials { + msgs = append(msgs, flatten(m)...) + } + } else { + msgs = []tea.Msg{p} + } + return msgs +} + +func extractBatchMessages(m tea.Msg) (ret []tea.Msg) { + sliceMsgType := reflect.SliceOf(reflect.TypeOf(tea.Cmd(nil))) + value := reflect.ValueOf(m) // note: this is technically unaddressable + + // make our own instance that is addressable + valueCopy := reflect.New(value.Type()).Elem() + valueCopy.Set(value) + + cmds := reflect.NewAt(sliceMsgType, unsafe.Pointer(valueCopy.UnsafeAddr())).Elem() + for i := 0; i < cmds.Len(); i++ { + item := cmds.Index(i) + r := item.Call(nil) + ret = append(ret, r[0].Interface().(tea.Msg)) + } + return ret +} diff --git a/cmd/syft/internal/ui/__snapshots__/post_ui_event_writer_test.snap b/cmd/syft/internal/ui/__snapshots__/post_ui_event_writer_test.snap new file mode 100755 index 000000000..bf473e331 --- /dev/null +++ b/cmd/syft/internal/ui/__snapshots__/post_ui_event_writer_test.snap @@ -0,0 +1,46 @@ + +[Test_postUIEventWriter_write/no_events/stdout - 1] + +--- + +[Test_postUIEventWriter_write/no_events/stderr - 1] + +--- + +[Test_postUIEventWriter_write/all_events/stdout - 1] + + + + + +--- + +[Test_postUIEventWriter_write/all_events/stderr - 1] + + + + + + + + + + + + + +--- + +[Test_postUIEventWriter_write/quiet_only_shows_report/stdout - 1] + + +--- + +[Test_postUIEventWriter_write/quiet_only_shows_report/stderr - 1] + +--- diff --git a/cmd/syft/internal/ui/no_ui.go b/cmd/syft/internal/ui/no_ui.go new file mode 100644 index 000000000..015ae8218 --- /dev/null +++ b/cmd/syft/internal/ui/no_ui.go @@ -0,0 +1,44 @@ +package ui + +import ( + "os" + + "github.com/wagoodman/go-partybus" + + "github.com/anchore/clio" + "github.com/anchore/syft/syft/event" +) + +var _ clio.UI = (*NoUI)(nil) + +type NoUI struct { + finalizeEvents []partybus.Event + subscription partybus.Unsubscribable + quiet bool +} + +func None(quiet bool) *NoUI { + return &NoUI{ + quiet: quiet, + } +} + +func (n *NoUI) Setup(subscription partybus.Unsubscribable) error { + n.subscription = subscription + return nil +} + +func (n *NoUI) Handle(e partybus.Event) error { + switch e.Type { + case event.CLIReport, event.CLINotification: + // keep these for when the UI is terminated to show to the screen (or perform other events) + n.finalizeEvents = append(n.finalizeEvents, e) + case event.CLIExit: + return n.subscription.Unsubscribe() + } + return nil +} + +func (n NoUI) Teardown(_ bool) error { + return newPostUIEventWriter(os.Stdout, os.Stderr).write(n.quiet, n.finalizeEvents...) +} diff --git a/cmd/syft/internal/ui/post_ui_event_writer.go b/cmd/syft/internal/ui/post_ui_event_writer.go new file mode 100644 index 000000000..e3772981b --- /dev/null +++ b/cmd/syft/internal/ui/post_ui_event_writer.go @@ -0,0 +1,133 @@ +package ui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/hashicorp/go-multierror" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/parsers" +) + +type postUIEventWriter struct { + handles []postUIHandle +} + +type postUIHandle struct { + respectQuiet bool + event partybus.EventType + writer io.Writer + dispatch eventWriter +} + +type eventWriter func(io.Writer, ...partybus.Event) error + +func newPostUIEventWriter(stdout, stderr io.Writer) *postUIEventWriter { + return &postUIEventWriter{ + handles: []postUIHandle{ + { + event: event.CLIReport, + respectQuiet: false, + writer: stdout, + dispatch: writeReports, + }, + { + event: event.CLINotification, + respectQuiet: true, + writer: stderr, + dispatch: writeNotifications, + }, + { + event: event.CLIAppUpdateAvailable, + respectQuiet: true, + writer: stderr, + dispatch: writeAppUpdate, + }, + }, + } +} + +func (w postUIEventWriter) write(quiet bool, events ...partybus.Event) error { + var errs error + for _, h := range w.handles { + if quiet && h.respectQuiet { + continue + } + + for _, e := range events { + if e.Type != h.event { + continue + } + + if err := h.dispatch(h.writer, e); err != nil { + errs = multierror.Append(errs, err) + } + } + } + return errs +} + +func writeReports(writer io.Writer, events ...partybus.Event) error { + var reports []string + for _, e := range events { + _, report, err := parsers.ParseCLIReport(e) + if err != nil { + log.WithFields("error", err).Warn("failed to gather final report") + continue + } + + // remove all whitespace padding from the end of the report + reports = append(reports, strings.TrimRight(report, "\n ")+"\n") + } + + // prevent the double new-line at the end of the report + report := strings.Join(reports, "\n") + + if _, err := fmt.Fprint(writer, report); err != nil { + return fmt.Errorf("failed to write final report to stdout: %w", err) + } + return nil +} + +func writeNotifications(writer io.Writer, events ...partybus.Event) error { + // 13 = high intensity magenta (ANSI 16 bit code) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")) + + for _, e := range events { + _, notification, err := parsers.ParseCLINotification(e) + if err != nil { + log.WithFields("error", err).Warn("failed to parse notification") + continue + } + + if _, err := fmt.Fprintln(writer, style.Render(notification)); err != nil { + // don't let this be fatal + log.WithFields("error", err).Warn("failed to write final notifications") + } + } + return nil +} + +func writeAppUpdate(writer io.Writer, events ...partybus.Event) error { + // 13 = high intensity magenta (ANSI 16 bit code) + italics + style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")).Italic(true) + + for _, e := range events { + notice, err := parsers.ParseCLIAppUpdateAvailable(e) + if err != nil { + log.WithFields("error", err).Warn("failed to parse app update notification") + continue + } + + if _, err := fmt.Fprintln(writer, style.Render(notice)); err != nil { + // don't let this be fatal + log.WithFields("error", err).Warn("failed to write app update notification") + } + } + return nil +} diff --git a/cmd/syft/internal/ui/post_ui_event_writer_test.go b/cmd/syft/internal/ui/post_ui_event_writer_test.go new file mode 100644 index 000000000..a5bdd5792 --- /dev/null +++ b/cmd/syft/internal/ui/post_ui_event_writer_test.go @@ -0,0 +1,95 @@ +package ui + +import ( + "bytes" + "testing" + + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/syft/syft/event" +) + +func Test_postUIEventWriter_write(t *testing.T) { + + tests := []struct { + name string + quiet bool + events []partybus.Event + wantErr require.ErrorAssertionFunc + }{ + { + name: "no events", + }, + { + name: "all events", + events: []partybus.Event{ + { + Type: event.CLINotification, + Value: "\n\n\n\n", + }, + { + Type: event.CLINotification, + Value: "", + }, + { + Type: event.CLIAppUpdateAvailable, + Value: "\n\n\n\n", + }, + { + Type: event.CLINotification, + Value: "", + }, + { + Type: event.CLIReport, + Value: "\n\n\n\n", + }, + { + Type: event.CLIReport, + Value: "", + }, + }, + }, + { + name: "quiet only shows report", + quiet: true, + events: []partybus.Event{ + + { + Type: event.CLINotification, + Value: "", + }, + { + Type: event.CLIAppUpdateAvailable, + Value: "", + }, + { + Type: event.CLIReport, + Value: "", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + w := newPostUIEventWriter(stdout, stderr) + + tt.wantErr(t, w.write(tt.quiet, tt.events...)) + + t.Run("stdout", func(t *testing.T) { + snaps.MatchSnapshot(t, stdout.String()) + }) + + t.Run("stderr", func(t *testing.T) { + snaps.MatchSnapshot(t, stderr.String()) + }) + }) + } +} diff --git a/internal/ui/select.go b/cmd/syft/internal/ui/select.go similarity index 72% rename from internal/ui/select.go rename to cmd/syft/internal/ui/select.go index 2c501cacb..27b536192 100644 --- a/internal/ui/select.go +++ b/cmd/syft/internal/ui/select.go @@ -8,6 +8,9 @@ import ( "runtime" "golang.org/x/term" + + "github.com/anchore/clio" + handler "github.com/anchore/syft/cmd/syft/cli/ui" ) // Select is responsible for determining the specific UI function given select user option, the current platform @@ -15,16 +18,18 @@ import ( // is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of // the final SBOM report. -func Select(verbose, quiet bool) (uis []UI) { +func Select(verbose, quiet bool) (uis []clio.UI) { isStdoutATty := term.IsTerminal(int(os.Stdout.Fd())) isStderrATty := term.IsTerminal(int(os.Stderr.Fd())) notATerminal := !isStderrATty && !isStdoutATty switch { case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: - uis = append(uis, NewLoggerUI()) + uis = append(uis, None(quiet)) default: - uis = append(uis, NewEphemeralTerminalUI()) + // TODO: it may make sense in the future to pass handler options into select + h := handler.New(handler.DefaultHandlerConfig()) + uis = append(uis, New(h, verbose, quiet)) } return uis diff --git a/internal/ui/select_windows.go b/cmd/syft/internal/ui/select_windows.go similarity index 81% rename from internal/ui/select_windows.go rename to cmd/syft/internal/ui/select_windows.go index cd8c79839..0408be53b 100644 --- a/internal/ui/select_windows.go +++ b/cmd/syft/internal/ui/select_windows.go @@ -3,11 +3,13 @@ package ui +import "github.com/anchore/clio" + // Select is responsible for determining the specific UI function given select user option, the current platform // config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs // is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there // are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of // the final SBOM report. -func Select(verbose, quiet bool) (uis []UI) { - return append(uis, NewLoggerUI()) +func Select(verbose, quiet bool) (uis []clio.UI) { + return append(uis, None(quiet)) } diff --git a/cmd/syft/internal/ui/ui.go b/cmd/syft/internal/ui/ui.go new file mode 100644 index 000000000..4a8ab83e5 --- /dev/null +++ b/cmd/syft/internal/ui/ui.go @@ -0,0 +1,163 @@ +package ui + +import ( + "os" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/bubbly/bubbles/frame" + "github.com/anchore/clio" + "github.com/anchore/go-logger" + handler "github.com/anchore/syft/cmd/syft/cli/ui" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/event" +) + +var _ interface { + tea.Model + partybus.Responder + clio.UI +} = (*UI)(nil) + +type UI struct { + program *tea.Program + running *sync.WaitGroup + quiet bool + subscription partybus.Unsubscribable + finalizeEvents []partybus.Event + + handler *handler.Handler + frame tea.Model +} + +func New(h *handler.Handler, _, quiet bool) *UI { + return &UI{ + handler: h, + frame: frame.New(), + running: &sync.WaitGroup{}, + quiet: quiet, + } +} + +func (m *UI) Setup(subscription partybus.Unsubscribable) error { + // we still want to collect log messages, however, we also the logger shouldn't write to the screen directly + if logWrapper, ok := log.Get().(logger.Controller); ok { + logWrapper.SetOutput(m.frame.(*frame.Frame).Footer()) + } + + m.subscription = subscription + m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin)) + m.running.Add(1) + + go func() { + defer m.running.Done() + if _, err := m.program.Run(); err != nil { + log.Errorf("unable to start UI: %+v", err) + m.exit() + } + }() + + return nil +} + +func (m *UI) exit() { + // stop the event loop + bus.Exit() +} + +func (m *UI) Handle(e partybus.Event) error { + if m.program != nil { + m.program.Send(e) + if e.Type == event.CLIExit { + return m.subscription.Unsubscribe() + } + } + return nil +} + +func (m *UI) Teardown(force bool) error { + if !force { + m.handler.Running.Wait() + m.program.Quit() + } else { + m.program.Kill() + } + + m.running.Wait() + + // TODO: allow for writing out the full log output to the screen (only a partial log is shown currently) + // this needs coordination to know what the last frame event is to change the state accordingly (which isn't possible now) + + return newPostUIEventWriter(os.Stdout, os.Stderr).write(m.quiet, m.finalizeEvents...) +} + +// bubbletea.Model functions + +func (m UI) Init() tea.Cmd { + return m.frame.Init() +} + +func (m UI) RespondsTo() []partybus.EventType { + return append([]partybus.EventType{ + event.CLIReport, + event.CLINotification, + event.CLIExit, + event.CLIAppUpdateAvailable, + }, m.handler.RespondsTo()...) +} + +func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events) + + var cmds []tea.Cmd + + // allow for non-partybus UI updates (such as window size events). Note: these must not affect existing models, + // that is the responsibility of the frame object on this UI object. The handler is a factory of models + // which the frame is responsible for the lifecycle of. This update allows for injecting the initial state + // of the world when creating those models. + m.handler.Update(msg) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c": + m.exit() + return m, tea.Quit + } + + case partybus.Event: + log.WithFields("component", "ui").Tracef("event: %q", msg.Type) + + switch msg.Type { + case event.CLIReport, event.CLINotification, event.CLIExit, event.CLIAppUpdateAvailable: + // keep these for when the UI is terminated to show to the screen (or perform other events) + m.finalizeEvents = append(m.finalizeEvents, msg) + + // why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop. + // for this reason we'll let the syft event loop call Teardown() which will explicitly wait for these components + return m, nil + } + + for _, newModel := range m.handler.Handle(msg) { + if newModel == nil { + continue + } + cmds = append(cmds, newModel.Init()) + m.frame.(*frame.Frame).AppendModel(newModel) + } + // intentionally fallthrough to update the frame model + } + + frameModel, cmd := m.frame.Update(msg) + m.frame = frameModel + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m UI) View() string { + return m.frame.View() +} diff --git a/go.mod b/go.mod index ff8d61dfc..03c6a45d1 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.8.4 github.com/vifraa/gopom v0.2.1 - github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5 + github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5 github.com/wagoodman/jotframe v0.0.0-20211129225309-56b0d0a4aebb github.com/xeipuuv/gojsonschema v1.2.0 @@ -52,12 +52,17 @@ require ( github.com/CycloneDX/cyclonedx-go v0.7.1 github.com/Masterminds/semver v1.5.0 github.com/Masterminds/sprig/v3 v3.2.3 - github.com/anchore/go-logger v0.0.0-20220728155337-03b66a5207d8 + github.com/anchore/bubbly v0.0.0-20230622134437-40226fdcc0f5 + github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0 + github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe github.com/anchore/stereoscope v0.0.0-20230627195312-cd49355d934e + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 github.com/dave/jennifer v1.6.1 github.com/deitch/magic v0.0.0-20230404182410-1ff89d7342da github.com/docker/docker v24.0.2+incompatible github.com/github/go-spdx/v2 v2.1.2 + github.com/gkampitakis/go-snaps v0.4.0 github.com/go-git/go-billy/v5 v5.4.1 github.com/go-git/go-git/v5 v5.7.0 github.com/google/go-containerregistry v0.15.2 @@ -67,6 +72,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/sassoftware/go-rpmutils v0.2.0 github.com/vbatts/go-mtree v0.5.3 + github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 golang.org/x/exp v0.0.0-20230202163644-54bba9f4231b gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.23.1 @@ -80,9 +86,14 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230518184743-7afd39499903 // indirect github.com/acomagu/bufpipe v1.0.4 // indirect + github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba // indirect github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/becheran/wildmatch-go v1.0.0 // indirect + github.com/charmbracelet/bubbles v0.16.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/cloudflare/circl v1.3.3 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/containerd/containerd v1.7.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -93,14 +104,18 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/felixge/fgprof v0.9.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.0 // indirect + github.com/gkampitakis/ciinfo v0.1.1 // indirect + github.com/gkampitakis/go-diff v1.3.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-restruct/restruct v1.2.0-alpha // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect @@ -112,21 +127,32 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.16.5 // indirect github.com/klauspost/pgzip v1.2.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect github.com/nwaples/rardecode v1.1.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc3 // indirect + github.com/pborman/indent v1.2.1 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/skeema/knownhosts v1.1.1 // indirect github.com/spf13/cast v1.5.1 // indirect diff --git a/go.sum b/go.sum index f321e6edd..693beaee4 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,14 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/anchore/go-logger v0.0.0-20220728155337-03b66a5207d8 h1:imgMA0gN0TZx7PSa/pdWqXadBvrz8WsN6zySzCe4XX0= -github.com/anchore/go-logger v0.0.0-20220728155337-03b66a5207d8/go.mod h1:+gPap4jha079qzRTUaehv+UZ6sSdaNwkH0D3b6zhTuk= +github.com/anchore/bubbly v0.0.0-20230622134437-40226fdcc0f5 h1:ylXHybVevy9Musod3gplxsn7g9Ws7ET/XcCrWFXkuvw= +github.com/anchore/bubbly v0.0.0-20230622134437-40226fdcc0f5/go.mod h1:tBC1jAU9gk7ekAbUmBXCuRX1l5Z9sMSqgcGSgsV1ECY= +github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0 h1:g0UqRW60JDrf5fb40RUyIwwcfQ3nAJqGj4aUCVTwFE4= +github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0/go.mod h1:0IQVIROfgRX4WZFMfgsbNZmMgLKqW/KgByyJDYvWiDE= +github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba h1:tJ186HK8e0Lf+hhNWX4fJrq14yj3mw8JQkkLhA0nFhE= +github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba/go.mod h1:E3zNHEz7mizIFGJhuX+Ga7AbCmEN5TfzVDxmOfj7XZw= +github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe h1:Df867YMmymdMG6z5IW8pR0/2CRpLIjYnaTXLp6j+s0k= +github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe/go.mod h1:ubLFmlsv8/DFUQrZwY5syT5/8Er3ugSr4rDFwHsE3hg= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= @@ -112,6 +118,8 @@ github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -127,6 +135,14 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -146,6 +162,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd v1.7.0 h1:G/ZQr3gMZs6ZT0qPUZ15znx5QSdQdASW11nXTLTM2Pg= github.com/containerd/containerd v1.7.0/go.mod h1:QfR7Efgb/6X2BDpTPJRvPTYDE9rsF0FsXX9J8sIs/sc= github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= @@ -202,6 +220,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -211,6 +231,12 @@ github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmx github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/github/go-spdx/v2 v2.1.2 h1:p+Tv0yMgcuO0/vnMe9Qh4tmUgYhI6AsLVlakZ/Sx+DM= github.com/github/go-spdx/v2 v2.1.2/go.mod h1:hMCrsFgT0QnCwn7G8gxy/MxMpy67WgZrwFeISTn0o6w= +github.com/gkampitakis/ciinfo v0.1.1 h1:dz1LCkOd+zmZ3YYlFNpr0hRDqGY7Ox2mcaltHzdahqk= +github.com/gkampitakis/ciinfo v0.1.1/go.mod h1:bVaOGziPqf8PoeYZxatq1HmCsJUmv191hLnFboYxd9Y= +github.com/gkampitakis/go-diff v1.3.0 h1:Szdbo5w73LSQ9sQ02h+NSSf2ZlW/E8naJCI1ZzQtWgE= +github.com/gkampitakis/go-diff v1.3.0/go.mod h1:QUJDQRA0JkEX0d7tgDaBHzJv9IH6k6e91TByC+9/RFk= +github.com/gkampitakis/go-snaps v0.4.0 h1:yTMQ4RaGrQvsr70XZRoxZeJiMkmdLbZ9fWpW/vypdVk= +github.com/gkampitakis/go-snaps v0.4.0/go.mod h1:xYclGIA7Al0CoYwehW0dd/NEr6oJge+1Dl4OWWxQUWY= github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -315,7 +341,9 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -372,6 +400,7 @@ github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= @@ -414,13 +443,17 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381 h1:bqDmpDG49ZRnB5PcgP0RXtQvnMSgIF14M7CBd2shtXs= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -442,11 +475,14 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= @@ -482,6 +518,14 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= @@ -494,6 +538,8 @@ github.com/opencontainers/image-spec v1.1.0-rc3 h1:fzg1mXZFj8YdPeNkRXMg+zb88BFV0 github.com/opencontainers/image-spec v1.1.0-rc3/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pborman/indent v1.2.1 h1:lFiviAbISHv3Rf0jcuh489bi06hj98JsVMtIDZQb9yM= +github.com/pborman/indent v1.2.1/go.mod h1:FitS+t35kIYtB5xWTZAPhnmrxcciEEOdbyrrpz5K6Vw= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -504,10 +550,13 @@ github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0 github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -529,11 +578,14 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= @@ -618,8 +670,8 @@ github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RV github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY= github.com/vifraa/gopom v0.2.1 h1:MYVMAMyiGzXPPy10EwojzKIL670kl5Zbae+o3fFvQEM= github.com/vifraa/gopom v0.2.1/go.mod h1:oPa1dcrGrtlO37WPDBm5SqHAT+wTgF8An1Q71Z6Vv4o= -github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5 h1:phTLPgMRDYTizrBSKsNSOa2zthoC2KsJsaY/8sg3rD8= -github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5/go.mod h1:JPirS5jde/CF5qIjcK4WX+eQmKXdPc6vcZkJ/P0hfPw= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5 h1:lwgTsTy18nYqASnH58qyfRW/ldj7Gt2zzBvgYPzdA4s= github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/wagoodman/jotframe v0.0.0-20211129225309-56b0d0a4aebb h1:Yz6VVOcLuWLAHYlJzTw7JKnWxdV/WXpug2X0quEzRnY= @@ -643,6 +695,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg= +github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= @@ -878,6 +932,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/bus/bus.go b/internal/bus/bus.go index 0810c2fc6..c85eb77cb 100644 --- a/internal/bus/bus.go +++ b/internal/bus/bus.go @@ -16,20 +16,20 @@ package bus import "github.com/wagoodman/go-partybus" var publisher partybus.Publisher -var active bool -// SetPublisher sets the singleton event bus publisher. This is optional; if no bus is provided, the library will +// Set sets the singleton event bus publisher. This is optional; if no bus is provided, the library will // behave no differently than if a bus had been provided. -func SetPublisher(p partybus.Publisher) { +func Set(p partybus.Publisher) { publisher = p - if p != nil { - active = true - } +} + +func Get() partybus.Publisher { + return publisher } // Publish an event onto the bus. If there is no bus set by the calling application, this does nothing. -func Publish(event partybus.Event) { - if active { - publisher.Publish(event) +func Publish(e partybus.Event) { + if publisher != nil { + publisher.Publish(e) } } diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go new file mode 100644 index 000000000..5efacfb35 --- /dev/null +++ b/internal/bus/helpers.go @@ -0,0 +1,32 @@ +package bus + +import ( + "github.com/wagoodman/go-partybus" + + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/event" +) + +func Exit() { + Publish(partybus.Event{ + Type: event.CLIExit, + }) +} + +func Report(report string) { + if len(report) == 0 { + return + } + report = log.Redactor.RedactString(report) + Publish(partybus.Event{ + Type: event.CLIReport, + Value: report, + }) +} + +func Notify(message string) { + Publish(partybus.Event{ + Type: event.CLINotification, + Value: message, + }) +} diff --git a/internal/log/log.go b/internal/log/log.go index 30952987f..242c5f0b7 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -6,67 +6,86 @@ package log import ( "github.com/anchore/go-logger" "github.com/anchore/go-logger/adapter/discard" + "github.com/anchore/go-logger/adapter/redact" ) -// Log is the singleton used to facilitate logging internally within syft -var Log logger.Logger = discard.New() +var ( + // log is the singleton used to facilitate logging internally within + log = discard.New() + + store = redact.NewStore() + + Redactor = store.(redact.Redactor) +) + +func Set(l logger.Logger) { + log = redact.New(l, store) +} + +func Get() logger.Logger { + return log +} + +func Redact(values ...string) { + store.Add(values...) +} // Errorf takes a formatted template string and template arguments for the error logging level. func Errorf(format string, args ...interface{}) { - Log.Errorf(format, args...) + log.Errorf(format, args...) } // Error logs the given arguments at the error logging level. func Error(args ...interface{}) { - Log.Error(args...) + log.Error(args...) } // Warnf takes a formatted template string and template arguments for the warning logging level. func Warnf(format string, args ...interface{}) { - Log.Warnf(format, args...) + log.Warnf(format, args...) } // Warn logs the given arguments at the warning logging level. func Warn(args ...interface{}) { - Log.Warn(args...) + log.Warn(args...) } // Infof takes a formatted template string and template arguments for the info logging level. func Infof(format string, args ...interface{}) { - Log.Infof(format, args...) + log.Infof(format, args...) } // Info logs the given arguments at the info logging level. func Info(args ...interface{}) { - Log.Info(args...) + log.Info(args...) } // Debugf takes a formatted template string and template arguments for the debug logging level. func Debugf(format string, args ...interface{}) { - Log.Debugf(format, args...) + log.Debugf(format, args...) } // Debug logs the given arguments at the debug logging level. func Debug(args ...interface{}) { - Log.Debug(args...) + log.Debug(args...) } // Tracef takes a formatted template string and template arguments for the trace logging level. func Tracef(format string, args ...interface{}) { - Log.Tracef(format, args...) + log.Tracef(format, args...) } // Trace logs the given arguments at the trace logging level. func Trace(args ...interface{}) { - Log.Trace(args...) + log.Trace(args...) } // WithFields returns a message logger with multiple key-value fields. func WithFields(fields ...interface{}) logger.MessageLogger { - return Log.WithFields(fields...) + return log.WithFields(fields...) } // Nested returns a new logger with hard coded key-value pairs func Nested(fields ...interface{}) logger.Logger { - return Log.Nested(fields...) + return log.Nested(fields...) } diff --git a/internal/ui/common_event_handlers.go b/internal/ui/common_event_handlers.go deleted file mode 100644 index f7dbaaa3d..000000000 --- a/internal/ui/common_event_handlers.go +++ /dev/null @@ -1,24 +0,0 @@ -package ui - -import ( - "fmt" - - "github.com/wagoodman/go-partybus" - - syftEventParsers "github.com/anchore/syft/syft/event/parsers" -) - -// handleExit is a UI function for processing the Exit bus event, -// and calling the given function to output the contents. -func handleExit(event partybus.Event) error { - // show the report to stdout - fn, err := syftEventParsers.ParseExit(event) - if err != nil { - return fmt.Errorf("bad CatalogerFinished event: %w", err) - } - - if err := fn(); err != nil { - return fmt.Errorf("unable to show package catalog report: %w", err) - } - return nil -} diff --git a/internal/ui/components/spinner.go b/internal/ui/components/spinner.go deleted file mode 100644 index debc8cb96..000000000 --- a/internal/ui/components/spinner.go +++ /dev/null @@ -1,42 +0,0 @@ -package components - -import ( - "strings" - "sync" -) - -// TODO: move me to a common module (used in multiple repos) - -const ( - SpinnerDotSet = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" -) - -type Spinner struct { - index int - charset []string - lock sync.Mutex -} - -func NewSpinner(charset string) Spinner { - return Spinner{ - charset: strings.Split(charset, ""), - } -} - -func (s *Spinner) Current() string { - s.lock.Lock() - defer s.lock.Unlock() - - return s.charset[s.index] -} - -func (s *Spinner) Next() string { - s.lock.Lock() - defer s.lock.Unlock() - c := s.charset[s.index] - s.index++ - if s.index >= len(s.charset) { - s.index = 0 - } - return c -} diff --git a/internal/ui/ephemeral_terminal_ui.go b/internal/ui/ephemeral_terminal_ui.go deleted file mode 100644 index c5270f8b9..000000000 --- a/internal/ui/ephemeral_terminal_ui.go +++ /dev/null @@ -1,154 +0,0 @@ -//go:build linux || darwin || netbsd -// +build linux darwin netbsd - -package ui - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "sync" - - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/jotframe/pkg/frame" - - "github.com/anchore/go-logger" - "github.com/anchore/syft/internal/log" - syftEvent "github.com/anchore/syft/syft/event" - "github.com/anchore/syft/ui" -) - -// ephemeralTerminalUI provides an "ephemeral" terminal user interface to display the application state dynamically. -// The terminal is placed into raw mode and the cursor is manipulated to allow for a dynamic, multi-line -// UI (provided by the jotframe lib), for this reason all other application mechanisms that write to the screen -// must be suppressed before starting (such as logs); since bytes in the device and in application memory combine to make -// a shared state, bytes coming from elsewhere to the screen will disrupt this state. -// -// This UI is primarily driven off of events from the event bus, creating single-line terminal widgets to represent a -// published element on the event bus, typically polling the element for the latest state. This allows for the UI to -// control update frequency to the screen, provide "liveness" indications that are interpolated between bus events, -// and overall loosely couple the bus events from screen interactions. -// -// By convention, all elements published on the bus should be treated as read-only, and publishers on the bus should -// attempt to enforce this when possible by wrapping complex objects with interfaces to prescribe interactions. Also by -// convention, each new event that the UI should respond to should be added either in this package as a handler function, -// or in the shared ui package as a function on the main handler object. All handler functions should be completed -// processing an event before the ETUI exits (coordinated with a sync.WaitGroup) -type ephemeralTerminalUI struct { - unsubscribe func() error - handler *ui.Handler - waitGroup *sync.WaitGroup - frame *frame.Frame - logBuffer *bytes.Buffer - uiOutput *os.File -} - -// NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer. -func NewEphemeralTerminalUI() UI { - return &ephemeralTerminalUI{ - handler: ui.NewHandler(), - waitGroup: &sync.WaitGroup{}, - uiOutput: os.Stderr, - } -} - -func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error { - h.unsubscribe = unsubscribe - hideCursor(h.uiOutput) - - // prep the logger to not clobber the screen from now on (logrus only) - h.logBuffer = bytes.NewBufferString("") - logController, ok := log.Log.(logger.Controller) - if ok { - logController.SetOutput(h.logBuffer) - } - - return h.openScreen() -} - -func (h *ephemeralTerminalUI) Handle(event partybus.Event) error { - ctx := context.Background() - switch { - case h.handler.RespondsTo(event): - if err := h.handler.Handle(ctx, h.frame, event, h.waitGroup); err != nil { - log.Errorf("unable to show %s event: %+v", event.Type, err) - } - - case event.Type == syftEvent.AppUpdateAvailable: - if err := handleAppUpdateAvailable(ctx, h.frame, event, h.waitGroup); err != nil { - log.Errorf("unable to show %s event: %+v", event.Type, err) - } - - case event.Type == syftEvent.Exit: - // we need to close the screen now since signaling the sbom is ready means that we - // are about to write bytes to stdout, so we should reset the terminal state first - h.closeScreen(false) - - if err := handleExit(event); err != nil { - log.Errorf("unable to show %s event: %+v", event.Type, err) - } - - // this is the last expected event, stop listening to events - return h.unsubscribe() - } - return nil -} - -func (h *ephemeralTerminalUI) openScreen() error { - config := frame.Config{ - PositionPolicy: frame.PolicyFloatForward, - // only report output to stderr, reserve report output for stdout - Output: h.uiOutput, - } - - fr, err := frame.New(config) - if err != nil { - return fmt.Errorf("failed to create the screen object: %w", err) - } - h.frame = fr - - return nil -} - -func (h *ephemeralTerminalUI) closeScreen(force bool) { - // we may have other background processes still displaying progress, wait for them to - // finish before discontinuing dynamic content and showing the final report - if !h.frame.IsClosed() { - if !force { - h.waitGroup.Wait() - } - h.frame.Close() - // TODO: there is a race condition within frame.Close() that sometimes leads to an extra blank line being output - frame.Close() - - // only flush the log on close - h.flushLog() - } -} - -func (h *ephemeralTerminalUI) flushLog() { - // flush any errors to the screen before the report - logController, ok := log.Log.(logger.Controller) - if ok { - fmt.Fprint(logController.GetOutput(), h.logBuffer.String()) - logController.SetOutput(h.uiOutput) - } else { - fmt.Fprint(h.uiOutput, h.logBuffer.String()) - } -} - -func (h *ephemeralTerminalUI) Teardown(force bool) error { - h.closeScreen(force) - showCursor(h.uiOutput) - return nil -} - -func hideCursor(output io.Writer) { - fmt.Fprint(output, "\x1b[?25l") -} - -func showCursor(output io.Writer) { - fmt.Fprint(output, "\x1b[?25h") -} diff --git a/internal/ui/etui_event_handlers.go b/internal/ui/etui_event_handlers.go deleted file mode 100644 index f1703f813..000000000 --- a/internal/ui/etui_event_handlers.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build linux || darwin || netbsd -// +build linux darwin netbsd - -package ui - -import ( - "context" - "fmt" - "io" - "sync" - - "github.com/gookit/color" - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/jotframe/pkg/frame" - - "github.com/anchore/syft/internal" - syftEventParsers "github.com/anchore/syft/syft/event/parsers" -) - -// handleAppUpdateAvailable is a UI handler function to display a new application version to the top of the screen. -func handleAppUpdateAvailable(_ context.Context, fr *frame.Frame, event partybus.Event, _ *sync.WaitGroup) error { - newVersion, err := syftEventParsers.ParseAppUpdateAvailable(event) - if err != nil { - return fmt.Errorf("bad AppUpdateAvailable event: %w", err) - } - - line, err := fr.Prepend() - if err != nil { - return err - } - - message := color.Magenta.Sprintf("New version of %s is available: %s", internal.ApplicationName, newVersion) - _, _ = io.WriteString(line, message) - - return nil -} diff --git a/internal/ui/logger_ui.go b/internal/ui/logger_ui.go deleted file mode 100644 index 48f5c1ed6..000000000 --- a/internal/ui/logger_ui.go +++ /dev/null @@ -1,40 +0,0 @@ -package ui - -import ( - "github.com/wagoodman/go-partybus" - - "github.com/anchore/syft/internal/log" - syftEvent "github.com/anchore/syft/syft/event" -) - -type loggerUI struct { - unsubscribe func() error -} - -// NewLoggerUI writes all events to the common application logger and writes the final report to the given writer. -func NewLoggerUI() UI { - return &loggerUI{} -} - -func (l *loggerUI) Setup(unsubscribe func() error) error { - l.unsubscribe = unsubscribe - return nil -} - -func (l loggerUI) Handle(event partybus.Event) error { - // ignore all events except for the final event - if event.Type != syftEvent.Exit { - return nil - } - - if err := handleExit(event); err != nil { - log.Warnf("unable to show catalog image finished event: %+v", err) - } - - // this is the last expected event, stop listening to events - return l.unsubscribe() -} - -func (l loggerUI) Teardown(_ bool) error { - return nil -} diff --git a/internal/ui/ui.go b/internal/ui/ui.go deleted file mode 100644 index cb551f1cf..000000000 --- a/internal/ui/ui.go +++ /dev/null @@ -1,11 +0,0 @@ -package ui - -import ( - "github.com/wagoodman/go-partybus" -) - -type UI interface { - Setup(unsubscribe func() error) error - partybus.Handler - Teardown(force bool) error -} diff --git a/syft/event/event.go b/syft/event/event.go index 1e9e6f400..4c2a29296 100644 --- a/syft/event/event.go +++ b/syft/event/event.go @@ -4,37 +4,51 @@ defined here there should be a corresponding event parser defined in the parsers */ package event -import "github.com/wagoodman/go-partybus" +import ( + "github.com/wagoodman/go-partybus" + + "github.com/anchore/syft/internal" +) const ( - // AppUpdateAvailable is a partybus event that occurs when an application update is available - AppUpdateAvailable partybus.EventType = "syft-app-update-available" + typePrefix = internal.ApplicationName + cliTypePrefix = typePrefix + "-cli" + + // Events from the syft library // PackageCatalogerStarted is a partybus event that occurs when the package cataloging has begun - PackageCatalogerStarted partybus.EventType = "syft-package-cataloger-started-event" + PackageCatalogerStarted partybus.EventType = typePrefix + "-package-cataloger-started-event" //nolint:gosec // SecretsCatalogerStarted is a partybus event that occurs when the secrets cataloging has begun - SecretsCatalogerStarted partybus.EventType = "syft-secrets-cataloger-started-event" + SecretsCatalogerStarted partybus.EventType = typePrefix + "-secrets-cataloger-started-event" // FileMetadataCatalogerStarted is a partybus event that occurs when the file metadata cataloging has begun - FileMetadataCatalogerStarted partybus.EventType = "syft-file-metadata-cataloger-started-event" + FileMetadataCatalogerStarted partybus.EventType = typePrefix + "-file-metadata-cataloger-started-event" // FileDigestsCatalogerStarted is a partybus event that occurs when the file digests cataloging has begun - FileDigestsCatalogerStarted partybus.EventType = "syft-file-digests-cataloger-started-event" + FileDigestsCatalogerStarted partybus.EventType = typePrefix + "-file-digests-cataloger-started-event" // FileIndexingStarted is a partybus event that occurs when the directory resolver begins indexing a filesystem - FileIndexingStarted partybus.EventType = "syft-file-indexing-started-event" - - // Exit is a partybus event that occurs when an analysis result is ready for final presentation - Exit partybus.EventType = "syft-exit-event" - - // ImportStarted is a partybus event that occurs when an SBOM upload process has begun - ImportStarted partybus.EventType = "syft-import-started-event" + FileIndexingStarted partybus.EventType = typePrefix + "-file-indexing-started-event" // AttestationStarted is a partybus event that occurs when starting an SBOM attestation process - AttestationStarted partybus.EventType = "syft-attestation-started-event" + AttestationStarted partybus.EventType = typePrefix + "-attestation-started-event" // CatalogerTaskStarted is a partybus event that occurs when starting a task within a cataloger - CatalogerTaskStarted partybus.EventType = "syft-cataloger-task-started" + CatalogerTaskStarted partybus.EventType = typePrefix + "-cataloger-task-started" + + // Events exclusively for the CLI + + // CLIAppUpdateAvailable is a partybus event that occurs when an application update is available + CLIAppUpdateAvailable partybus.EventType = cliTypePrefix + "-app-update-available" + + // CLIReport is a partybus event that occurs when an analysis result is ready for final presentation to stdout + CLIReport partybus.EventType = cliTypePrefix + "-report" + + // CLINotification is a partybus event that occurs when auxiliary information is ready for presentation to stderr + CLINotification partybus.EventType = cliTypePrefix + "-notification" + + // CLIExit is a partybus event that occurs when an analysis result is ready for final presentation + CLIExit partybus.EventType = cliTypePrefix + "-exit-event" ) diff --git a/syft/event/cataloger_task.go b/syft/event/monitor/cataloger_task.go similarity index 84% rename from syft/event/cataloger_task.go rename to syft/event/monitor/cataloger_task.go index 49fa1cdee..4a06132e7 100644 --- a/syft/event/cataloger_task.go +++ b/syft/event/monitor/cataloger_task.go @@ -1,12 +1,15 @@ -package event +package monitor import ( "github.com/wagoodman/go-partybus" "github.com/wagoodman/go-progress" "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/syft/event" ) +// TODO: this should be refactored to support read-only/write-only access using idioms of the progress lib + type CatalogerTask struct { prog *progress.Manual // Title @@ -25,7 +28,7 @@ func (e *CatalogerTask) init() { e.prog = progress.NewManual(-1) bus.Publish(partybus.Event{ - Type: CatalogerTaskStarted, + Type: event.CatalogerTaskStarted, Source: e, }) } diff --git a/syft/event/monitor/generic_task.go b/syft/event/monitor/generic_task.go index 6deb31e36..cf5a6ea6d 100644 --- a/syft/event/monitor/generic_task.go +++ b/syft/event/monitor/generic_task.go @@ -8,7 +8,7 @@ import ( type ShellProgress struct { io.Reader - *progress.Manual + progress.Progressable } type Title struct { diff --git a/syft/event/parsers/parsers.go b/syft/event/parsers/parsers.go index 3d0c8bfb8..2e6cbeca9 100644 --- a/syft/event/parsers/parsers.go +++ b/syft/event/parsers/parsers.go @@ -23,7 +23,7 @@ type ErrBadPayload struct { } func (e *ErrBadPayload) Error() string { - return fmt.Sprintf("event='%s' has bad event payload field='%v': '%+v'", string(e.Type), e.Field, e.Value) + return fmt.Sprintf("event='%s' has bad event payload field=%q: %q", string(e.Type), e.Field, e.Value) } func newPayloadErr(t partybus.EventType, field string, value interface{}) error { @@ -111,12 +111,12 @@ func ParseFileIndexingStarted(e partybus.Event) (string, progress.StagedProgress return path, prog, nil } -func ParseCatalogerTaskStarted(e partybus.Event) (*event.CatalogerTask, error) { +func ParseCatalogerTaskStarted(e partybus.Event) (*monitor.CatalogerTask, error) { if err := checkEventType(e.Type, event.CatalogerTaskStarted); err != nil { return nil, err } - source, ok := e.Source.(*event.CatalogerTask) + source, ok := e.Source.(*monitor.CatalogerTask) if !ok { return nil, newPayloadErr(e.Type, "Source", e.Source) } @@ -124,50 +124,6 @@ func ParseCatalogerTaskStarted(e partybus.Event) (*event.CatalogerTask, error) { return source, nil } -func ParseExit(e partybus.Event) (func() error, error) { - if err := checkEventType(e.Type, event.Exit); err != nil { - return nil, err - } - - fn, ok := e.Value.(func() error) - if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) - } - - return fn, nil -} - -func ParseAppUpdateAvailable(e partybus.Event) (string, error) { - if err := checkEventType(e.Type, event.AppUpdateAvailable); err != nil { - return "", err - } - - newVersion, ok := e.Value.(string) - if !ok { - return "", newPayloadErr(e.Type, "Value", e.Value) - } - - return newVersion, nil -} - -func ParseImportStarted(e partybus.Event) (string, progress.StagedProgressable, error) { - if err := checkEventType(e.Type, event.ImportStarted); err != nil { - return "", nil, err - } - - host, ok := e.Source.(string) - if !ok { - return "", nil, newPayloadErr(e.Type, "Source", e.Source) - } - - prog, ok := e.Value.(progress.StagedProgressable) - if !ok { - return "", nil, newPayloadErr(e.Type, "Value", e.Value) - } - - return host, prog, nil -} - func ParseAttestationStartedEvent(e partybus.Event) (io.Reader, progress.Progressable, *monitor.GenericTask, error) { if err := checkEventType(e.Type, event.AttestationStarted); err != nil { return nil, nil, nil, err @@ -183,5 +139,71 @@ func ParseAttestationStartedEvent(e partybus.Event) (io.Reader, progress.Progres return nil, nil, nil, newPayloadErr(e.Type, "Value", e.Value) } - return sp.Reader, sp.Manual, &source, nil + return sp.Reader, sp.Progressable, &source, nil +} + +// CLI event types + +func ParseCLIExit(e partybus.Event) (func() error, error) { + if err := checkEventType(e.Type, event.CLIExit); err != nil { + return nil, err + } + + fn, ok := e.Value.(func() error) + if !ok { + return nil, newPayloadErr(e.Type, "Value", e.Value) + } + + return fn, nil +} + +func ParseCLIAppUpdateAvailable(e partybus.Event) (string, error) { + if err := checkEventType(e.Type, event.CLIAppUpdateAvailable); err != nil { + return "", err + } + + newVersion, ok := e.Value.(string) + if !ok { + return "", newPayloadErr(e.Type, "Value", e.Value) + } + + return newVersion, nil +} + +func ParseCLIReport(e partybus.Event) (string, string, error) { + if err := checkEventType(e.Type, event.CLIReport); err != nil { + return "", "", err + } + + context, ok := e.Source.(string) + if !ok { + // this is optional + context = "" + } + + report, ok := e.Value.(string) + if !ok { + return "", "", newPayloadErr(e.Type, "Value", e.Value) + } + + return context, report, nil +} + +func ParseCLINotification(e partybus.Event) (string, string, error) { + if err := checkEventType(e.Type, event.CLINotification); err != nil { + return "", "", err + } + + context, ok := e.Source.(string) + if !ok { + // this is optional + context = "" + } + + notification, ok := e.Value.(string) + if !ok { + return "", "", newPayloadErr(e.Type, "Value", e.Value) + } + + return context, notification, nil } diff --git a/syft/internal/fileresolver/directory_indexer.go b/syft/internal/fileresolver/directory_indexer.go index 01f332a3d..c9b5567a9 100644 --- a/syft/internal/fileresolver/directory_indexer.go +++ b/syft/internal/fileresolver/directory_indexer.go @@ -332,14 +332,15 @@ func (r directoryIndexer) addFileToIndex(p string, info os.FileInfo) error { func (r directoryIndexer) addSymlinkToIndex(p string, info os.FileInfo) (string, error) { linkTarget, err := os.Readlink(p) if err != nil { - if runtime.GOOS == WindowsOS { - p = posixToWindows(p) + isOnWindows := windows.HostRunningOnWindows() + if isOnWindows { + p = windows.FromPosix(p) } linkTarget, err = filepath.EvalSymlinks(p) - if runtime.GOOS == WindowsOS { - p = windowsToPosix(p) + if isOnWindows { + p = windows.ToPosix(p) } if err != nil { diff --git a/syft/lib.go b/syft/lib.go index 849584ab7..b45307012 100644 --- a/syft/lib.go +++ b/syft/lib.go @@ -94,10 +94,10 @@ func newSourceRelationshipsFromCatalog(src source.Source, c *pkg.Collection) []a // SetLogger sets the logger object used for all syft logging calls. func SetLogger(logger logger.Logger) { - log.Log = logger + log.Set(logger) } // SetBus sets the event bus for all syft library bus publish events onto (in-library subscriptions are not allowed). func SetBus(b *partybus.Bus) { - bus.SetPublisher(b) + bus.Set(b) } diff --git a/syft/pkg/cataloger/golang/cataloger.go b/syft/pkg/cataloger/golang/cataloger.go index bde2a9b57..ee936da96 100644 --- a/syft/pkg/cataloger/golang/cataloger.go +++ b/syft/pkg/cataloger/golang/cataloger.go @@ -6,7 +6,7 @@ package golang import ( "github.com/anchore/syft/internal" "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/monitor" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" @@ -37,7 +37,7 @@ func NewGoModuleBinaryCataloger(opts GoCatalogerOpts) pkg.Cataloger { } type progressingCataloger struct { - progress *event.CatalogerTask + progress *monitor.CatalogerTask cataloger *generic.Cataloger } diff --git a/syft/pkg/cataloger/golang/licenses.go b/syft/pkg/cataloger/golang/licenses.go index 829a73dd3..cce84772e 100644 --- a/syft/pkg/cataloger/golang/licenses.go +++ b/syft/pkg/cataloger/golang/licenses.go @@ -21,7 +21,7 @@ import ( "github.com/anchore/syft/internal/licenses" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/monitor" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/fileresolver" "github.com/anchore/syft/syft/pkg" @@ -30,14 +30,14 @@ import ( type goLicenses struct { opts GoCatalogerOpts localModCacheResolver file.WritableResolver - progress *event.CatalogerTask + progress *monitor.CatalogerTask } func newGoLicenses(opts GoCatalogerOpts) goLicenses { return goLicenses{ opts: opts, localModCacheResolver: modCacheResolver(opts.localModCacheDir), - progress: &event.CatalogerTask{ + progress: &monitor.CatalogerTask{ SubStatus: true, RemoveOnCompletion: true, Title: "Downloading go mod", @@ -195,7 +195,7 @@ func processCaps(s string) string { }) } -func getModule(progress *event.CatalogerTask, proxies []string, moduleName, moduleVersion string) (fsys fs.FS, err error) { +func getModule(progress *monitor.CatalogerTask, proxies []string, moduleName, moduleVersion string) (fsys fs.FS, err error) { for _, proxy := range proxies { u, _ := url.Parse(proxy) if proxy == "direct" { @@ -217,7 +217,7 @@ func getModule(progress *event.CatalogerTask, proxies []string, moduleName, modu return } -func getModuleProxy(progress *event.CatalogerTask, proxy string, moduleName string, moduleVersion string) (out fs.FS, _ error) { +func getModuleProxy(progress *monitor.CatalogerTask, proxy string, moduleName string, moduleVersion string) (out fs.FS, _ error) { u := fmt.Sprintf("%s/%s/@v/%s.zip", proxy, moduleName, moduleVersion) progress.SetValue(u) // get the module zip @@ -265,7 +265,7 @@ func findVersionPath(f fs.FS, dir string) string { return "" } -func getModuleRepository(progress *event.CatalogerTask, moduleName string, moduleVersion string) (fs.FS, error) { +func getModuleRepository(progress *monitor.CatalogerTask, moduleName string, moduleVersion string) (fs.FS, error) { repoName := moduleName parts := strings.Split(moduleName, "/") if len(parts) > 2 { diff --git a/syft/pkg/cataloger/rust/parse_audit_binary.go b/syft/pkg/cataloger/rust/parse_audit_binary.go index de894006b..5b7438928 100644 --- a/syft/pkg/cataloger/rust/parse_audit_binary.go +++ b/syft/pkg/cataloger/rust/parse_audit_binary.go @@ -49,8 +49,7 @@ func parseAuditBinaryEntry(reader unionreader.UnionReader, filename string) []ru // binary, we should not show warnings/logs in this case. return nil } - // Use an Info level log here like golang/scan_bin.go - log.Infof("rust cataloger: unable to read dependency information (file=%q): %v", filename, err) + log.Tracef("rust cataloger: unable to read dependency information (file=%q): %v", filename, err) return nil } diff --git a/ui/event_handlers.go b/ui/deprecated.go similarity index 84% rename from ui/event_handlers.go rename to ui/deprecated.go index 66f6277d0..fb542ca5d 100644 --- a/ui/event_handlers.go +++ b/ui/deprecated.go @@ -1,3 +1,8 @@ +/* +Package ui provides all public UI elements intended to be repurposed in other applications. Specifically, a single +Handler object is provided to allow consuming applications (such as grype) to check if there are UI elements the handler +can respond to (given a specific event type) and handle the event in context of the given screen frame object. +*/ package ui import ( @@ -18,15 +23,16 @@ import ( "github.com/wagoodman/go-progress/format" "github.com/wagoodman/jotframe/pkg/frame" + stereoscopeEvent "github.com/anchore/stereoscope/pkg/event" stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers" "github.com/anchore/stereoscope/pkg/image/docker" "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/ui/components" + syftEvent "github.com/anchore/syft/syft/event" syftEventParsers "github.com/anchore/syft/syft/event/parsers" ) const maxBarWidth = 50 -const statusSet = components.SpinnerDotSet +const statusSet = SpinnerDotSet const completedStatus = "✔" const failedStatus = "✘" const titleFormat = color.Bold @@ -46,16 +52,118 @@ var ( subStatusTitleTemplate = fmt.Sprintf(" └── %%-%ds ", StatusTitleColumn-3) ) +// Handler is an aggregated event handler for the set of supported events (PullDockerImage, ReadImage, FetchImage, PackageCatalogerStarted) +// Deprecated: use the bubbletea event handler in cmd/syft/ui/handler.go instead. +type Handler struct { +} + +// NewHandler returns an empty Handler +// Deprecated: use the bubbletea event handler in cmd/syft/ui/handler.go instead. +func NewHandler() *Handler { + return &Handler{} +} + +// RespondsTo indicates if the handler is capable of handling the given event. +// Deprecated: use the bubbletea event handler in cmd/syft/ui/handler.go instead. +func (r *Handler) RespondsTo(event partybus.Event) bool { + switch event.Type { + case stereoscopeEvent.PullDockerImage, + stereoscopeEvent.ReadImage, + stereoscopeEvent.FetchImage, + syftEvent.PackageCatalogerStarted, + syftEvent.SecretsCatalogerStarted, + syftEvent.FileDigestsCatalogerStarted, + syftEvent.FileMetadataCatalogerStarted, + syftEvent.FileIndexingStarted, + syftEvent.AttestationStarted, + syftEvent.CatalogerTaskStarted: + return true + default: + return false + } +} + +// Handle calls the specific event handler for the given event within the context of the screen frame. +// Deprecated: use the bubbletea event handler in cmd/syft/ui/handler.go instead. +func (r *Handler) Handle(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { + switch event.Type { + case stereoscopeEvent.PullDockerImage: + return PullDockerImageHandler(ctx, fr, event, wg) + + case stereoscopeEvent.ReadImage: + return ReadImageHandler(ctx, fr, event, wg) + + case stereoscopeEvent.FetchImage: + return FetchImageHandler(ctx, fr, event, wg) + + case syftEvent.PackageCatalogerStarted: + return PackageCatalogerStartedHandler(ctx, fr, event, wg) + + case syftEvent.SecretsCatalogerStarted: + return SecretsCatalogerStartedHandler(ctx, fr, event, wg) + + case syftEvent.FileDigestsCatalogerStarted: + return FileDigestsCatalogerStartedHandler(ctx, fr, event, wg) + + case syftEvent.FileMetadataCatalogerStarted: + return FileMetadataCatalogerStartedHandler(ctx, fr, event, wg) + + case syftEvent.FileIndexingStarted: + return FileIndexingStartedHandler(ctx, fr, event, wg) + + case syftEvent.AttestationStarted: + return AttestationStartedHandler(ctx, fr, event, wg) + + case syftEvent.CatalogerTaskStarted: + return CatalogerTaskStartedHandler(ctx, fr, event, wg) + } + return nil +} + +const ( + SpinnerDotSet = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" +) + +type spinner struct { + index int + charset []string + lock sync.Mutex +} + +func newSpinner(charset string) spinner { + return spinner{ + charset: strings.Split(charset, ""), + } +} + +func (s *spinner) Current() string { + s.lock.Lock() + defer s.lock.Unlock() + + return s.charset[s.index] +} + +func (s *spinner) Next() string { + s.lock.Lock() + defer s.lock.Unlock() + c := s.charset[s.index] + s.index++ + if s.index >= len(s.charset) { + s.index = 0 + } + return c +} + // startProcess is a helper function for providing common elements for long-running UI elements (such as a // progress bar formatter and status spinner) -func startProcess() (format.Simple, *components.Spinner) { +func startProcess() (format.Simple, *spinner) { width, _ := frame.GetTerminalSize() barWidth := int(0.25 * float64(width)) if barWidth > maxBarWidth { barWidth = maxBarWidth } formatter := format.NewSimpleWithTheme(barWidth, format.HeavyNoBarTheme, format.ColorCompleted, format.ColorTodo) - spinner := components.NewSpinner(statusSet) + spinner := newSpinner(statusSet) return formatter, &spinner } @@ -82,7 +190,7 @@ func formatDockerPullPhase(phase docker.PullPhase, inputStr string) string { } // formatDockerImagePullStatus writes the docker image pull status summarized into a single line for the given state. -func formatDockerImagePullStatus(pullStatus *docker.PullStatus, spinner *components.Spinner, line *frame.Line) { +func formatDockerImagePullStatus(pullStatus *docker.PullStatus, spinner *spinner, line *frame.Line) { var size, current uint64 title := titleFormat.Sprint("Pulling image") @@ -491,50 +599,6 @@ func FileDigestsCatalogerStartedHandler(ctx context.Context, fr *frame.Frame, ev return err } -// ImportStartedHandler shows the intermittent upload progress to Anchore Enterprise. -func ImportStartedHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { - host, prog, err := syftEventParsers.ParseImportStarted(event) - if err != nil { - return fmt.Errorf("bad %s event: %w", event.Type, err) - } - - line, err := fr.Append() - if err != nil { - return err - } - wg.Add(1) - - formatter, spinner := startProcess() - stream := progress.Stream(ctx, prog, interval) - title := titleFormat.Sprint("Uploading image") - - formatFn := func(p progress.Progress) { - progStr, err := formatter.Format(p) - spin := color.Magenta.Sprint(spinner.Next()) - if err != nil { - _, _ = io.WriteString(line, fmt.Sprintf("Error: %+v", err)) - } else { - auxInfo := auxInfoFormat.Sprintf("[%s]", prog.Stage()) - _, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s %s", spin, title, progStr, auxInfo)) - } - } - - go func() { - defer wg.Done() - - formatFn(progress.Progress{}) - for p := range stream { - formatFn(p) - } - - spin := color.Green.Sprint(completedStatus) - title = titleFormat.Sprint("Uploaded image") - auxInfo := auxInfoFormat.Sprintf("[%s]", host) - _, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s", spin, title, auxInfo)) - }() - return err -} - // AttestationStartedHandler takes bytes from a event.ShellOutput and publishes them to the frame. // //nolint:funlen,gocognit diff --git a/ui/handler.go b/ui/handler.go deleted file mode 100644 index b5e3a8fd6..000000000 --- a/ui/handler.go +++ /dev/null @@ -1,85 +0,0 @@ -/* -Package ui provides all public UI elements intended to be repurposed in other applications. Specifically, a single -Handler object is provided to allow consuming applications (such as grype) to check if there are UI elements the handler -can respond to (given a specific event type) and handle the event in context of the given screen frame object. -*/ -package ui - -import ( - "context" - "sync" - - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/jotframe/pkg/frame" - - stereoscopeEvent "github.com/anchore/stereoscope/pkg/event" - syftEvent "github.com/anchore/syft/syft/event" -) - -// Handler is an aggregated event handler for the set of supported events (PullDockerImage, ReadImage, FetchImage, PackageCatalogerStarted) -type Handler struct { -} - -// NewHandler returns an empty Handler -func NewHandler() *Handler { - return &Handler{} -} - -// RespondsTo indicates if the handler is capable of handling the given event. -func (r *Handler) RespondsTo(event partybus.Event) bool { - switch event.Type { - case stereoscopeEvent.PullDockerImage, - stereoscopeEvent.ReadImage, - stereoscopeEvent.FetchImage, - syftEvent.PackageCatalogerStarted, - syftEvent.SecretsCatalogerStarted, - syftEvent.FileDigestsCatalogerStarted, - syftEvent.FileMetadataCatalogerStarted, - syftEvent.FileIndexingStarted, - syftEvent.ImportStarted, - syftEvent.AttestationStarted, - syftEvent.CatalogerTaskStarted: - return true - default: - return false - } -} - -// Handle calls the specific event handler for the given event within the context of the screen frame. -func (r *Handler) Handle(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error { - switch event.Type { - case stereoscopeEvent.PullDockerImage: - return PullDockerImageHandler(ctx, fr, event, wg) - - case stereoscopeEvent.ReadImage: - return ReadImageHandler(ctx, fr, event, wg) - - case stereoscopeEvent.FetchImage: - return FetchImageHandler(ctx, fr, event, wg) - - case syftEvent.PackageCatalogerStarted: - return PackageCatalogerStartedHandler(ctx, fr, event, wg) - - case syftEvent.SecretsCatalogerStarted: - return SecretsCatalogerStartedHandler(ctx, fr, event, wg) - - case syftEvent.FileDigestsCatalogerStarted: - return FileDigestsCatalogerStartedHandler(ctx, fr, event, wg) - - case syftEvent.FileMetadataCatalogerStarted: - return FileMetadataCatalogerStartedHandler(ctx, fr, event, wg) - - case syftEvent.FileIndexingStarted: - return FileIndexingStartedHandler(ctx, fr, event, wg) - - case syftEvent.ImportStarted: - return ImportStartedHandler(ctx, fr, event, wg) - - case syftEvent.AttestationStarted: - return AttestationStartedHandler(ctx, fr, event, wg) - - case syftEvent.CatalogerTaskStarted: - return CatalogerTaskStartedHandler(ctx, fr, event, wg) - } - return nil -}