From 962e82297c329f728018335858ad619ef3e00648 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 29 Jun 2021 14:28:09 -0400 Subject: [PATCH] Split UI from event handling (#448) * split UI from event handling Signed-off-by: Alex Goodman * add event loop tests Signed-off-by: Alex Goodman * use stereoscope cleanup function during signal handling Signed-off-by: Alex Goodman * correct error wrapping in packages cmd Signed-off-by: Alex Goodman * migrate ui event handlers to ui package Signed-off-by: Alex Goodman * clarify command worker input var + remove dead comments Signed-off-by: Alex Goodman --- Makefile | 2 +- cmd/event_loop.go | 90 ++++ cmd/event_loop_test.go | 455 ++++++++++++++++++++ cmd/packages.go | 25 +- cmd/power_user.go | 14 +- cmd/signals.go | 20 + go.mod | 5 +- go.sum | 8 +- internal/ui/common/event_handlers.go | 24 -- internal/ui/common/spinner.go | 72 ---- internal/ui/components/spinner.go | 42 ++ internal/ui/ephemeral_terminal_ui.go | 137 ++++++ internal/ui/etui/ephemeral_tui.go | 180 -------- internal/ui/etui/internal_event_handlers.go | 32 -- internal/ui/event_handlers.go | 49 +++ internal/ui/logger_output.go | 40 -- internal/ui/logger_ui.go | 38 ++ internal/ui/select.go | 5 +- internal/ui/ui.go | 8 +- syft/source/source.go | 4 +- ui/event_handlers.go | 13 +- 21 files changed, 881 insertions(+), 382 deletions(-) create mode 100644 cmd/event_loop.go create mode 100644 cmd/event_loop_test.go create mode 100644 cmd/signals.go delete mode 100644 internal/ui/common/event_handlers.go delete mode 100644 internal/ui/common/spinner.go create mode 100644 internal/ui/components/spinner.go create mode 100644 internal/ui/ephemeral_terminal_ui.go delete mode 100644 internal/ui/etui/ephemeral_tui.go delete mode 100644 internal/ui/etui/internal_event_handlers.go create mode 100644 internal/ui/event_handlers.go delete mode 100644 internal/ui/logger_output.go create mode 100644 internal/ui/logger_ui.go diff --git a/Makefile b/Makefile index 8af5f5465..8213d5b33 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ RESET := $(shell tput -T linux sgr0) TITLE := $(BOLD)$(PURPLE) SUCCESS := $(BOLD)$(GREEN) # the quality gate lower threshold for unit test total % coverage (by function statements) -COVERAGE_THRESHOLD := 70 +COVERAGE_THRESHOLD := 65 # CI cache busting values; change these if you want CI to not use previous stored cache INTEGRATION_CACHE_BUSTER="88738d2f" CLI_CACHE_BUSTER="789bacdf" diff --git a/cmd/event_loop.go b/cmd/event_loop.go new file mode 100644 index 000000000..0c7518942 --- /dev/null +++ b/cmd/event_loop.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/ui" + "github.com/hashicorp/go-multierror" + "github.com/wagoodman/go-partybus" +) + +// 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. +// nolint:gocognit +func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, ux ui.UI, cleanupFn func()) error { + defer cleanupFn() + events := subscription.Events() + var err error + if ux, err = setupUI(subscription.Unsubscribe, ux); err != nil { + return err + } + + var retErr error + + for { + if workerErrs == nil && events == nil { + break + } + select { + case err, isOpen := <-workerErrs: + if !isOpen { + workerErrs = nil + continue + } + if err != nil { + // capture the error from the worker and unsubscribe to complete a graceful shutdown + retErr = multierror.Append(retErr, err) + if err := subscription.Unsubscribe(); err != nil { + retErr = multierror.Append(retErr, err) + } + } + case e, isOpen := <-events: + if !isOpen { + events = nil + continue + } + + if err := ux.Handle(e); err != nil { + if errors.Is(err, partybus.ErrUnsubscribe) { + log.Warnf("unable to unsubscribe from the event bus") + events = nil + } else { + retErr = multierror.Append(retErr, err) + // TODO: should we unsubscribe? should we try to halt execution? or continue? + } + } + case <-signals: + // ignore further results from any event source and exit ASAP, but ensure that all cache is cleaned up. + // we ignore further errors since cleaning up the tmp directories will affect running catalogers that are + // reading/writing from/to their nested temp dirs. This is acceptable since we are bailing without result. + + // TODO: potential future improvement would be to pass context into workers with a cancel function that is + // to the event loop. In this way we can have a more controlled shutdown even at the most nested levels + // of processing. + events = nil + workerErrs = nil + } + } + + if err := ux.Teardown(); err != nil { + retErr = multierror.Append(retErr, err) + } + + return retErr +} + +func setupUI(unsubscribe func() error, ux ui.UI) (ui.UI, error) { + if err := ux.Setup(unsubscribe); err != nil { + // replace the existing UI with a (simpler) logger UI + ux = ui.NewLoggerUI() + if err := ux.Setup(unsubscribe); err != nil { + // something is very wrong, bail. + return ux, err + } + log.Errorf("unable to setup given UI, falling back to logger: %+v", err) + } + return ux, nil +} diff --git a/cmd/event_loop_test.go b/cmd/event_loop_test.go new file mode 100644 index 000000000..aa69ec73f --- /dev/null +++ b/cmd/event_loop_test.go @@ -0,0 +1,455 @@ +package cmd + +import ( + "fmt" + "os" + "syscall" + "testing" + "time" + + "github.com/anchore/syft/internal/ui" + "github.com/anchore/syft/syft/event" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/wagoodman/go-partybus" +) + +var _ ui.UI = (*uiMock)(nil) + +type uiMock struct { + t *testing.T + finalEvent partybus.Event + unsubscribe func() error + mock.Mock +} + +func (u *uiMock) Setup(unsubscribe func() error) error { + u.t.Logf("UI Setup called") + u.unsubscribe = unsubscribe + return u.Called(unsubscribe).Error(0) +} + +func (u *uiMock) Handle(event partybus.Event) error { + u.t.Logf("UI Handle called: %+v", event.Type) + if event == u.finalEvent { + assert.NoError(u.t, u.unsubscribe()) + } + return u.Called(event).Error(0) +} + +func (u *uiMock) Teardown() error { + u.t.Logf("UI Teardown called") + return u.Called().Error(0) +} + +func Test_eventLoop_gracefulExit(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + finalEvent := partybus.Event{ + Type: event.PresenterReady, + } + + worker := func() <-chan error { + ret := make(chan error) + go func() { + t.Log("worker running") + // send an empty item (which is ignored) ensuring we've entered the select statement, + // then close (a partial shutdown). + ret <- nil + t.Log("worker sent nothing") + close(ret) + t.Log("worker closed") + // do the other half of the shutdown + testBus.Publish(finalEvent) + t.Log("worker published final event") + }() + return ret + } + + signaler := func() <-chan os.Signal { + return nil + } + + ux := &uiMock{ + t: t, + finalEvent: finalEvent, + } + + // ensure the mock sees at least the final event + ux.On("Handle", finalEvent).Return(nil) + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(nil) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + assert.NoError(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func Test_eventLoop_workerError(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + workerErr := fmt.Errorf("worker error") + + worker := func() <-chan error { + ret := make(chan error) + go func() { + t.Log("worker running") + // send an empty item (which is ignored) ensuring we've entered the select statement, + // then close (a partial shutdown). + ret <- nil + t.Log("worker sent nothing") + ret <- workerErr + t.Log("worker sent error") + close(ret) + t.Log("worker closed") + // note: NO final event is fired + }() + return ret + } + + signaler := func() <-chan os.Signal { + return nil + } + + ux := &uiMock{ + t: t, + } + + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(nil) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + // ensure we see an error returned + assert.ErrorIs(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + workerErr, + "should have seen a worker error, but did not", + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func Test_eventLoop_unsubscribeError(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + finalEvent := partybus.Event{ + Type: event.PresenterReady, + } + + worker := func() <-chan error { + ret := make(chan error) + go func() { + t.Log("worker running") + // send an empty item (which is ignored) ensuring we've entered the select statement, + // then close (a partial shutdown). + ret <- nil + t.Log("worker sent nothing") + close(ret) + t.Log("worker closed") + // do the other half of the shutdown + testBus.Publish(finalEvent) + t.Log("worker published final event") + }() + return ret + } + + signaler := func() <-chan os.Signal { + return nil + } + + ux := &uiMock{ + t: t, + finalEvent: finalEvent, + } + + // ensure the mock sees at least the final event... note the unsubscribe error here + ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe) + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(nil) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + // unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that + // this case is handled as a controlled shutdown (this test should not timeout) + assert.NoError(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func Test_eventLoop_handlerError(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + finalEvent := partybus.Event{ + Type: event.PresenterReady, + Error: fmt.Errorf("unable to create presenter"), + } + + worker := func() <-chan error { + ret := make(chan error) + go func() { + t.Log("worker running") + // send an empty item (which is ignored) ensuring we've entered the select statement, + // then close (a partial shutdown). + ret <- nil + t.Log("worker sent nothing") + close(ret) + t.Log("worker closed") + // do the other half of the shutdown + testBus.Publish(finalEvent) + t.Log("worker published final event") + }() + return ret + } + + signaler := func() <-chan os.Signal { + return nil + } + + ux := &uiMock{ + t: t, + finalEvent: finalEvent, + } + + // ensure the mock sees at least the final event... note the event error is propagated + ux.On("Handle", finalEvent).Return(finalEvent.Error) + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(nil) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + // handle errors SHOULD propagate the event loop. We are additionally asserting that this case is + // handled as a controlled shutdown (this test should not timeout) + assert.ErrorIs(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + finalEvent.Error, + "should have seen a event error, but did not", + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func Test_eventLoop_signalsStopExecution(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + worker := func() <-chan error { + // the worker will never return work and the event loop will always be waiting... + return make(chan error) + } + + signaler := func() <-chan os.Signal { + ret := make(chan os.Signal) + go func() { + ret <- syscall.SIGINT + // note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit + }() + return ret + } + + ux := &uiMock{ + t: t, + } + + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(nil) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + assert.NoError(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func Test_eventLoop_uiTeardownError(t *testing.T) { + test := func(t *testing.T) { + + testBus := partybus.NewBus() + subscription := testBus.Subscribe() + t.Cleanup(testBus.Close) + + finalEvent := partybus.Event{ + Type: event.PresenterReady, + } + + worker := func() <-chan error { + ret := make(chan error) + go func() { + t.Log("worker running") + // send an empty item (which is ignored) ensuring we've entered the select statement, + // then close (a partial shutdown). + ret <- nil + t.Log("worker sent nothing") + close(ret) + t.Log("worker closed") + // do the other half of the shutdown + testBus.Publish(finalEvent) + t.Log("worker published final event") + }() + return ret + } + + signaler := func() <-chan os.Signal { + return nil + } + + ux := &uiMock{ + t: t, + finalEvent: finalEvent, + } + + teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down") + + // ensure the mock sees at least the final event... note the event error is propagated + ux.On("Handle", finalEvent).Return(nil) + // ensure the mock sees basic setup/teardown events + ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) + ux.On("Teardown").Return(teardownError) + + var cleanupCalled bool + cleanupFn := func() { + t.Log("cleanup called") + cleanupCalled = true + } + + // ensure we see an error returned + assert.ErrorIs(t, + eventLoop( + worker(), + signaler(), + subscription, + ux, + cleanupFn, + ), + teardownError, + "should have seen a UI teardown error, but did not", + ) + + assert.True(t, cleanupCalled, "cleanup function not called") + ux.AssertExpectations(t) + } + + // if there is a bug, then there is a risk of the event loop never returning + testWithTimeout(t, 5*time.Second, test) +} + +func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) { + done := make(chan bool) + go func() { + test(t) + done <- true + }() + + select { + case <-time.After(timeout): + t.Fatal("test timed out") + case <-done: + } +} diff --git a/cmd/packages.go b/cmd/packages.go index b4882db0d..c0769dc64 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -6,10 +6,7 @@ import ( "io/ioutil" "os" - "github.com/anchore/syft/syft/presenter/packages" - - "github.com/spf13/viper" - + "github.com/anchore/stereoscope" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/anchore" "github.com/anchore/syft/internal/bus" @@ -19,10 +16,12 @@ import ( "github.com/anchore/syft/syft/distro" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/presenter/packages" "github.com/anchore/syft/syft/source" "github.com/pkg/profile" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/spf13/viper" "github.com/wagoodman/go-partybus" ) @@ -186,9 +185,15 @@ func bindPackagesConfigOptions(flags *pflag.FlagSet) error { } func packagesExec(_ *cobra.Command, args []string) error { - errs := packagesExecWorker(args[0]) - ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet) - return ux(errs, eventSubscription) + // could be an image or a directory, with or without a scheme + userInput := args[0] + return eventLoop( + packagesExecWorker(userInput), + setupSignals(), + eventSubscription, + ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet), + stereoscope.Cleanup, + ) } func packagesExecWorker(userInput string) <-chan error { @@ -200,14 +205,14 @@ func packagesExecWorker(userInput string) <-chan error { src, cleanup, err := source.New(userInput, appConfig.Registry.ToOptions()) if err != nil { - errs <- fmt.Errorf("failed to determine image source: %+v", err) + errs <- fmt.Errorf("failed to determine image source: %w", err) return } defer cleanup() catalog, d, err := syft.CatalogPackages(src, appConfig.Package.Cataloger.ScopeOpt) if err != nil { - errs <- fmt.Errorf("failed to catalog input: %+v", err) + errs <- fmt.Errorf("failed to catalog input: %w", err) return } @@ -261,7 +266,7 @@ func runPackageSbomUpload(src source.Source, s source.Metadata, catalog *pkg.Cat Password: appConfig.Anchore.Password, }) if err != nil { - return fmt.Errorf("failed to create anchore client: %+v", err) + return fmt.Errorf("failed to create anchore client: %w", err) } importCfg := anchore.ImportConfig{ diff --git a/cmd/power_user.go b/cmd/power_user.go index 2234d5675..fb2082cd2 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -4,8 +4,8 @@ import ( "fmt" "sync" + "github.com/anchore/stereoscope" "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/presenter/poweruser" "github.com/anchore/syft/internal/ui" @@ -63,9 +63,15 @@ func init() { } func powerUserExec(_ *cobra.Command, args []string) error { - errs := powerUserExecWorker(args[0]) - ux := ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet) - return ux(errs, eventSubscription) + // could be an image or a directory, with or without a scheme + userInput := args[0] + return eventLoop( + powerUserExecWorker(userInput), + setupSignals(), + eventSubscription, + ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet), + stereoscope.Cleanup, + ) } func powerUserExecWorker(userInput string) <-chan error { diff --git a/cmd/signals.go b/cmd/signals.go new file mode 100644 index 000000000..f20379d1e --- /dev/null +++ b/cmd/signals.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "os" + "os/signal" + "syscall" +) + +func setupSignals() <-chan os.Signal { + c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify + + interruptions := []os.Signal{ + syscall.SIGINT, + syscall.SIGTERM, + } + + signal.Notify(c, interruptions...) + + return c +} diff --git a/go.mod b/go.mod index 0d309c351..daec9789a 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/gookit/color v1.2.7 github.com/hashicorp/go-multierror v1.1.0 github.com/hashicorp/go-version v1.2.0 + github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.3.1 github.com/olekukonko/tablewriter v0.0.4 @@ -35,8 +36,8 @@ require ( github.com/spf13/cobra v1.0.1-0.20200909172742-8a63648dd905 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.0 - github.com/stretchr/testify v1.6.0 - github.com/wagoodman/go-partybus v0.0.0-20200526224238-eb215533f07d + github.com/stretchr/testify v1.7.0 + github.com/wagoodman/go-partybus v0.0.0-20210627031916-db1f5573bbc5 github.com/wagoodman/go-progress v0.0.0-20200731105512-1020f39e6240 github.com/wagoodman/jotframe v0.0.0-20200730190914-3517092dd163 github.com/x-cray/logrus-prefixed-formatter v0.5.2 diff --git a/go.sum b/go.sum index d58de9377..c408405a1 100644 --- a/go.sum +++ b/go.sum @@ -466,6 +466,7 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= @@ -703,6 +704,7 @@ github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -710,8 +712,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tdakkota/asciicheck v0.0.0-20200416190851-d7f85be797a2/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= @@ -744,8 +747,9 @@ github.com/valyala/quicktemplate v1.2.0/go.mod h1:EH+4AkTd43SvgIbQHYu59/cJyxDoOV github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= github.com/vdemeester/k8s-pkg-credentialprovider v1.17.4/go.mod h1:inCTmtUdr5KJbreVojo06krnTgaeAz/Z7lynpPk/Q2c= github.com/vmware/govmomi v0.20.3/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= -github.com/wagoodman/go-partybus v0.0.0-20200526224238-eb215533f07d h1:KOxOL6qpmqwoPloNwi+CEgc1ayjHNOFNrvoOmeDOjDg= github.com/wagoodman/go-partybus v0.0.0-20200526224238-eb215533f07d/go.mod h1:JPirS5jde/CF5qIjcK4WX+eQmKXdPc6vcZkJ/P0hfPw= +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-progress v0.0.0-20200621122631-1a2120f0695a/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= github.com/wagoodman/go-progress v0.0.0-20200731105512-1020f39e6240 h1:r6BlIP7CVZtMlxUQhT40h1IE1TzEgKVqwmsVGuscvdk= github.com/wagoodman/go-progress v0.0.0-20200731105512-1020f39e6240/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= diff --git a/internal/ui/common/event_handlers.go b/internal/ui/common/event_handlers.go deleted file mode 100644 index b601e6ac7..000000000 --- a/internal/ui/common/event_handlers.go +++ /dev/null @@ -1,24 +0,0 @@ -package common - -import ( - "fmt" - "os" - - syftEventParsers "github.com/anchore/syft/syft/event/parsers" - "github.com/wagoodman/go-partybus" -) - -// CatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog -// via the given presenter to stdout. -func CatalogerPresenterReady(event partybus.Event) error { - // show the report to stdout - pres, err := syftEventParsers.ParsePresenterReady(event) - if err != nil { - return fmt.Errorf("bad CatalogerFinished event: %w", err) - } - - if err := pres.Present(os.Stdout); err != nil { - return fmt.Errorf("unable to show package catalog report: %w", err) - } - return nil -} diff --git a/internal/ui/common/spinner.go b/internal/ui/common/spinner.go deleted file mode 100644 index fa86c670e..000000000 --- a/internal/ui/common/spinner.go +++ /dev/null @@ -1,72 +0,0 @@ -package common - -import ( - "strings" - "sync" -) - -// TODO: move me to a common module (used in multiple repos) - -const ( - SpinnerCircleOutlineSet = "◜◠◯◎◉●◉◎◯◡◞" - SpinnerCircleSet = "◌◯◎◉●◉◎◯" - SpinnerDotSet = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" - SpinnerHorizontalBarSet = "▉▊▋▌▍▎▏▎▍▌▋▊▉" - SpinnerVerticalBarSet = "▁▃▄▅▆▇█▇▆▅▄▃▁" - SpinnerDoubleBarSet = "▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▏▎▍▌▋▊▉█▇▆▅▄▃▂▁" - SpinnerArrowSet = "←↖↑↗→↘↓↙" -) - -var SpinnerCircleDotSet = []string{ - "⠈⠁", - "⠈⠑", - "⠈⠱", - "⠈⡱", - "⢀⡱", - "⢄⡱", - "⢄⡱", - "⢆⡱", - "⢎⡱", - "⢎⡰", - "⢎⡠", - "⢎⡀", - "⢎⠁", - "⠎⠁", - "⠊⠁", -} - -type Spinner struct { - index int - charset []string - lock sync.Mutex -} - -func NewSpinner(charset string) Spinner { - return Spinner{ - charset: strings.Split(charset, ""), - } -} - -func NewSpinnerFromSlice(charset []string) Spinner { - return Spinner{ - charset: 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/components/spinner.go b/internal/ui/components/spinner.go new file mode 100644 index 000000000..debc8cb96 --- /dev/null +++ b/internal/ui/components/spinner.go @@ -0,0 +1,42 @@ +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 new file mode 100644 index 000000000..3684fdeb0 --- /dev/null +++ b/internal/ui/ephemeral_terminal_ui.go @@ -0,0 +1,137 @@ +package ui + +import ( + "bytes" + "context" + "fmt" + "os" + "sync" + + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/logger" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/ui" + "github.com/k0kubun/go-ansi" + "github.com/wagoodman/go-partybus" + "github.com/wagoodman/jotframe/pkg/frame" +) + +// 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 +} + +func NewEphemeralTerminalUI() UI { + return &ephemeralTerminalUI{ + handler: ui.NewHandler(), + waitGroup: &sync.WaitGroup{}, + } +} + +func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error { + h.unsubscribe = unsubscribe + ansi.CursorHide() + + // prep the logger to not clobber the screen from now on (logrus only) + h.logBuffer = bytes.NewBufferString("") + logWrapper, ok := log.Log.(*logger.LogrusLogger) + if ok { + logWrapper.Logger.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.PresenterReady: + // we need to close the screen now since signaling the the presenter is ready means that we + // are about to write bytes to stdout, so we should reset the terminal state first + h.closeScreen() + + if err := handleCatalogerPresenterReady(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: os.Stderr, + } + + 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() { + // 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() { + 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 + logWrapper, ok := log.Log.(*logger.LogrusLogger) + if ok { + fmt.Fprint(logWrapper.Output, h.logBuffer.String()) + logWrapper.Logger.SetOutput(os.Stderr) + } else { + fmt.Fprint(os.Stderr, h.logBuffer.String()) + } +} + +func (h *ephemeralTerminalUI) Teardown() error { + h.closeScreen() + ansi.CursorShow() + return nil +} diff --git a/internal/ui/etui/ephemeral_tui.go b/internal/ui/etui/ephemeral_tui.go deleted file mode 100644 index fa5a0a61b..000000000 --- a/internal/ui/etui/ephemeral_tui.go +++ /dev/null @@ -1,180 +0,0 @@ -/* -Package etui 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) -*/ -package etui - -import ( - "bytes" - "context" - "fmt" - "os" - "os/signal" - "sync" - "syscall" - - "github.com/anchore/syft/internal/logger" - - "github.com/anchore/syft/internal/ui/common" - "github.com/anchore/syft/ui" - - "github.com/anchore/syft/internal/log" - syftEvent "github.com/anchore/syft/syft/event" - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/jotframe/pkg/frame" -) - -// TODO: specify per-platform implementations with build tags (needed when windows is supported by syft) - -// setupScreen creates a new jotframe object to manage specific screen lines dynamically, preparing the screen device -// as needed (i.e. setting the terminal to raw mode). -func setupScreen(output *os.File) *frame.Frame { - config := frame.Config{ - PositionPolicy: frame.PolicyFloatForward, - // only report output to stderr, reserve report output for stdout - Output: output, - } - - fr, err := frame.New(config) - if err != nil { - log.Errorf("failed to create screen object: %+v", err) - return nil - } - return fr -} - -// nolint:funlen,gocognit -// OutputToEphemeralTUI is a UI function that provides a terminal UI experience without a separate, in-application -// screen buffer. All logging is suppressed, buffered to a string, and output after the ETUI has been torn down. -func OutputToEphemeralTUI(workerErrs <-chan error, subscription *partybus.Subscription) error { - output := os.Stderr - - // prep the logger to not clobber the screen from now on (logrus only) - logBuffer := bytes.NewBufferString("") - logWrapper, ok := log.Log.(*logger.LogrusLogger) - if ok { - logWrapper.Logger.SetOutput(logBuffer) - } - - // hide cursor - _, _ = fmt.Fprint(output, "\x1b[?25l") - // show cursor - defer fmt.Fprint(output, "\x1b[?25h") - - fr := setupScreen(output) - if fr == nil { - return fmt.Errorf("unable to setup screen") - } - var isClosed bool - defer func() { - if !isClosed { - fr.Close() - frame.Close() - // flush any errors to the screen before the report - logWrapper, ok := log.Log.(*logger.LogrusLogger) - if ok { - fmt.Fprint(logWrapper.Output, logBuffer.String()) - } else { - fmt.Fprint(output, logBuffer.String()) - } - } - logWrapper, ok := log.Log.(*logger.LogrusLogger) - if ok { - logWrapper.Logger.SetOutput(output) - } - }() - - var err error - var wg = &sync.WaitGroup{} - events := subscription.Events() - ctx := context.Background() - syftUIHandler := ui.NewHandler() - - signals := interruptingSignals() - -eventLoop: - for { - select { - case err := <-workerErrs: - // TODO: we should show errors more explicitly in the ETUI - if err != nil { - return err - } - case e, ok := <-events: - if !ok { - break eventLoop - } - switch { - case syftUIHandler.RespondsTo(e): - if err = syftUIHandler.Handle(ctx, fr, e, wg); err != nil { - log.Errorf("unable to show %s event: %+v", e.Type, err) - } - - case e.Type == syftEvent.AppUpdateAvailable: - if err = appUpdateAvailableHandler(ctx, fr, e, wg); err != nil { - log.Errorf("unable to show %s event: %+v", e.Type, err) - } - - case e.Type == syftEvent.PresenterReady: - // we may have other background processes still displaying progress, wait for them to - // finish before discontinuing dynamic content and showing the final report - wg.Wait() - fr.Close() - // TODO: there is a race condition within frame.Close() that sometimes leads to an extra blank line being output - frame.Close() - isClosed = true - - // flush any errors to the screen before the report - logWrapper, ok := log.Log.(*logger.LogrusLogger) - if ok { - fmt.Fprint(logWrapper.Output, logBuffer.String()) - } else { - fmt.Fprint(output, logBuffer.String()) - } - - if err := common.CatalogerPresenterReady(e); err != nil { - log.Errorf("unable to show %s event: %+v", e.Type, err) - } - - // this is the last expected event - break eventLoop - } - case <-ctx.Done(): - if ctx.Err() != nil { - log.Errorf("cancelled (%+v)", err) - } - break eventLoop - case <-signals: - break eventLoop - } - } - - return nil -} - -func interruptingSignals() chan os.Signal { - c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify - - interruptions := []os.Signal{ - syscall.SIGINT, - syscall.SIGTERM, - } - - signal.Notify(c, interruptions...) - - return c -} diff --git a/internal/ui/etui/internal_event_handlers.go b/internal/ui/etui/internal_event_handlers.go deleted file mode 100644 index 3df361a66..000000000 --- a/internal/ui/etui/internal_event_handlers.go +++ /dev/null @@ -1,32 +0,0 @@ -package etui - -import ( - "context" - "fmt" - "io" - "sync" - - "github.com/anchore/syft/internal" - syftEventParsers "github.com/anchore/syft/syft/event/parsers" - "github.com/gookit/color" - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/jotframe/pkg/frame" -) - -// appUpdateAvailableHandler is a UI handler function to display a new application version to the top of the screen. -func appUpdateAvailableHandler(_ 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/event_handlers.go b/internal/ui/event_handlers.go new file mode 100644 index 000000000..252f2b919 --- /dev/null +++ b/internal/ui/event_handlers.go @@ -0,0 +1,49 @@ +package ui + +import ( + "context" + "fmt" + "io" + "os" + "sync" + + "github.com/anchore/syft/internal" + "github.com/gookit/color" + "github.com/wagoodman/jotframe/pkg/frame" + + syftEventParsers "github.com/anchore/syft/syft/event/parsers" + "github.com/wagoodman/go-partybus" +) + +// handleCatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog +// via the given presenter to stdout. +func handleCatalogerPresenterReady(event partybus.Event) error { + // show the report to stdout + pres, err := syftEventParsers.ParsePresenterReady(event) + if err != nil { + return fmt.Errorf("bad CatalogerFinished event: %w", err) + } + + if err := pres.Present(os.Stdout); err != nil { + return fmt.Errorf("unable to show package catalog report: %w", err) + } + return nil +} + +// 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_output.go b/internal/ui/logger_output.go deleted file mode 100644 index d402958fe..000000000 --- a/internal/ui/logger_output.go +++ /dev/null @@ -1,40 +0,0 @@ -package ui - -import ( - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/ui/common" - syftEvent "github.com/anchore/syft/syft/event" - "github.com/wagoodman/go-partybus" -) - -// LoggerUI is a UI function that leverages the displays all application logs to the screen. -func LoggerUI(workerErrs <-chan error, subscription *partybus.Subscription) error { - events := subscription.Events() -eventLoop: - for { - select { - case err := <-workerErrs: - if err != nil { - return err - } - case e, ok := <-events: - if !ok { - // event bus closed... - break eventLoop - } - - // ignore all events except for the final event - if e.Type == syftEvent.PresenterReady { - err := common.CatalogerPresenterReady(e) - if err != nil { - log.Errorf("unable to show catalog image finished event: %+v", err) - } - - // this is the last expected event - break eventLoop - } - } - } - - return nil -} diff --git a/internal/ui/logger_ui.go b/internal/ui/logger_ui.go new file mode 100644 index 000000000..ae2c59d12 --- /dev/null +++ b/internal/ui/logger_ui.go @@ -0,0 +1,38 @@ +package ui + +import ( + "github.com/anchore/syft/internal/log" + syftEvent "github.com/anchore/syft/syft/event" + "github.com/wagoodman/go-partybus" +) + +type loggerUI struct { + unsubscribe func() error +} + +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.PresenterReady { + return nil + } + + if err := handleCatalogerPresenterReady(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() error { + return nil +} diff --git a/internal/ui/select.go b/internal/ui/select.go index b0da99633..37969efb1 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -4,7 +4,6 @@ import ( "os" "runtime" - "github.com/anchore/syft/internal/ui/etui" "golang.org/x/crypto/ssh/terminal" ) @@ -21,9 +20,9 @@ func Select(verbose, quiet bool) UI { switch { case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: - ui = LoggerUI + ui = NewLoggerUI() default: - ui = etui.OutputToEphemeralTUI + ui = NewEphemeralTerminalUI() } return ui diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c9b77af3e..5e41b400f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -4,6 +4,8 @@ import ( "github.com/wagoodman/go-partybus" ) -// UI is a function that takes a channel of errors from the main() worker and a event bus subscription and is -// responsible for displaying pertinent events to the user, on the screen or otherwise. -type UI func(<-chan error, *partybus.Subscription) error +type UI interface { + Setup(unsubscribe func() error) error + partybus.Handler + Teardown() error +} diff --git a/syft/source/source.go b/syft/source/source.go index 1d79dea4b..71cb77fd3 100644 --- a/syft/source/source.go +++ b/syft/source/source.go @@ -49,9 +49,7 @@ func New(userInput string, registryOptions *image.RegistryOptions) (Source, func case ImageScheme: img, err := stereoscope.GetImageFromSource(location, imageSource, registryOptions) - cleanup := func() { - stereoscope.Cleanup() - } + cleanup := stereoscope.Cleanup if err != nil || img == nil { return Source{}, cleanup, fmt.Errorf("could not fetch image '%s': %w", location, err) diff --git a/ui/event_handlers.go b/ui/event_handlers.go index b7a6363e9..1d8f1aa2b 100644 --- a/ui/event_handlers.go +++ b/ui/event_handlers.go @@ -8,11 +8,12 @@ import ( "sync" "time" + "github.com/anchore/syft/internal/ui/components" + "github.com/anchore/stereoscope/pkg/image/docker" "github.com/dustin/go-humanize" stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers" - "github.com/anchore/syft/internal/ui/common" syftEventParsers "github.com/anchore/syft/syft/event/parsers" "github.com/gookit/color" "github.com/wagoodman/go-partybus" @@ -22,8 +23,8 @@ import ( ) const maxBarWidth = 50 -const statusSet = common.SpinnerDotSet // SpinnerCircleOutlineSet -const completedStatus = "✔" // "●" +const statusSet = components.SpinnerDotSet +const completedStatus = "✔" const tileFormat = color.Bold const interval = 150 * time.Millisecond @@ -41,14 +42,14 @@ var ( // 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, *common.Spinner) { +func startProcess() (format.Simple, *components.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 := common.NewSpinner(statusSet) + spinner := components.NewSpinner(statusSet) return formatter, &spinner } @@ -76,7 +77,7 @@ func formatDockerPullPhase(phase docker.PullPhase, inputStr string) string { // nolint:funlen // formatDockerImagePullStatus writes the docker image pull status summarized into a single line for the given state. -func formatDockerImagePullStatus(pullStatus *docker.PullStatus, spinner *common.Spinner, line *frame.Line) { +func formatDockerImagePullStatus(pullStatus *docker.PullStatus, spinner *components.Spinner, line *frame.Line) { var size, current uint64 title := tileFormat.Sprint("Pulling image")