diff --git a/cmd/event_loop.go b/cmd/event_loop.go index 72cddb9718e..95707d071e1 100644 --- a/cmd/event_loop.go +++ b/cmd/event_loop.go @@ -2,6 +2,7 @@ package cmd import ( "errors" + "fmt" "os" "github.com/anchore/syft/internal/log" @@ -14,11 +15,13 @@ import ( // signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until // an eventual graceful exit. // nolint:gocognit,funlen -func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, ux ui.UI, cleanupFn func()) error { +func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...ui.UI) error { defer cleanupFn() events := subscription.Events() var err error - if ux, err = setupUI(subscription.Unsubscribe, ux); err != nil { + var ux ui.UI + + if ux, err = setupUI(subscription.Unsubscribe, uxs...); err != nil { return err } @@ -78,15 +81,18 @@ func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription * 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() +// setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use +// 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) { + for _, ux := range uis { if err := ux.Setup(unsubscribe); err != nil { - // something is very wrong, bail. - return ux, err + log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err) + continue } - log.Errorf("unable to setup given UI, falling back to logger: %+v", err) + + return ux, nil } - return ux, nil + return nil, fmt.Errorf("unable to setup any UI") } diff --git a/cmd/event_loop_test.go b/cmd/event_loop_test.go index f1c96222176..c816c444efa 100644 --- a/cmd/event_loop_test.go +++ b/cmd/event_loop_test.go @@ -96,8 +96,8 @@ func Test_eventLoop_gracefulExit(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), ) @@ -159,8 +159,8 @@ func Test_eventLoop_workerError(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), workerErr, "should have seen a worker error, but did not", @@ -230,8 +230,8 @@ func Test_eventLoop_unsubscribeError(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), ) @@ -300,8 +300,8 @@ func Test_eventLoop_handlerError(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), finalEvent.Error, "should have seen a event error, but did not", @@ -355,8 +355,8 @@ func Test_eventLoop_signalsStopExecution(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), ) @@ -425,8 +425,8 @@ func Test_eventLoop_uiTeardownError(t *testing.T) { worker(), signaler(), subscription, - ux, cleanupFn, + ux, ), teardownError, "should have seen a UI teardown error, but did not", diff --git a/cmd/packages.go b/cmd/packages.go index 931640ba06d..4078b66c753 100644 --- a/cmd/packages.go +++ b/cmd/packages.go @@ -113,6 +113,11 @@ func setPackageFlags(flags *pflag.FlagSet) { fmt.Sprintf("report output formatter, options=%v", packages.AllPresenters), ) + flags.StringP( + "file", "", "", + "file to write the report output to (default is STDOUT)", + ) + ///////// Upload options ////////////////////////////////////////////////////////// flags.StringP( "host", "H", "", @@ -156,6 +161,10 @@ func bindPackagesConfigOptions(flags *pflag.FlagSet) error { return err } + if err := viper.BindPFlag("file", flags.Lookup("file")); err != nil { + return err + } + ///////// Upload options ////////////////////////////////////////////////////////// if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil { @@ -188,12 +197,24 @@ func bindPackagesConfigOptions(flags *pflag.FlagSet) error { func packagesExec(_ *cobra.Command, args []string) error { // could be an image or a directory, with or without a scheme userInput := args[0] + + reporter, closer, err := reportWriter() + defer func() { + if err := closer(); err != nil { + log.Warnf("unable to write to report destination: %+v", err) + } + }() + + if err != nil { + return err + } + return eventLoop( packagesExecWorker(userInput), setupSignals(), eventSubscription, - ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet), stereoscope.Cleanup, + ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet, reporter)..., ) } diff --git a/cmd/power_user.go b/cmd/power_user.go index 4121322024d..3b1eacff597 100644 --- a/cmd/power_user.go +++ b/cmd/power_user.go @@ -7,6 +7,7 @@ import ( "github.com/anchore/stereoscope" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/presenter/poweruser" "github.com/anchore/syft/internal/ui" "github.com/anchore/syft/syft/event" @@ -73,12 +74,24 @@ func init() { func powerUserExec(_ *cobra.Command, args []string) error { // could be an image or a directory, with or without a scheme userInput := args[0] + + reporter, closer, err := reportWriter() + defer func() { + if err := closer(); err != nil { + log.Warnf("unable to write to report destination: %+v", err) + } + }() + + if err != nil { + return err + } + return eventLoop( powerUserExecWorker(userInput), setupSignals(), eventSubscription, - ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet), stereoscope.Cleanup, + ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet, reporter)..., ) } diff --git a/cmd/report_writer.go b/cmd/report_writer.go new file mode 100644 index 00000000000..bed18fb4f99 --- /dev/null +++ b/cmd/report_writer.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/anchore/syft/internal/log" +) + +func reportWriter() (io.Writer, func() error, error) { + nop := func() error { return nil } + + path := strings.TrimSpace(appConfig.File) + switch len(path) { + case 0: + return os.Stdout, nop, nil + default: + reportFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return nil, nop, fmt.Errorf("unable to create report file: %w", err) + } + return reportFile, func() error { + log.Infof("report written to file=%q", path) + return reportFile.Close() + }, nil + } +} diff --git a/internal/config/application.go b/internal/config/application.go index c8f7a1c4846..839d7a0e709 100644 --- a/internal/config/application.go +++ b/internal/config/application.go @@ -29,6 +29,7 @@ type parser interface { type Application struct { ConfigPath string `yaml:",omitempty" json:"configPath"` // the location where the application config was read from (either from -c or discovered while loading) Output string `yaml:"output" json:"output" mapstructure:"output"` // -o, the Presenter hint string to use for report formatting + File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` // -q, indicates to not show any status output to stderr (ETUI or logging UI) CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not Anchore anchore `yaml:"anchore" json:"anchore" mapstructure:"anchore"` // options for interacting with Anchore Engine/Enterprise diff --git a/internal/presenter/poweruser/test-fixtures/snapshot/TestJSONPresenter.golden b/internal/presenter/poweruser/test-fixtures/snapshot/TestJSONPresenter.golden index d3ebc02531c..2f2002886bd 100644 --- a/internal/presenter/poweruser/test-fixtures/snapshot/TestJSONPresenter.golden +++ b/internal/presenter/poweruser/test-fixtures/snapshot/TestJSONPresenter.golden @@ -168,6 +168,7 @@ "configuration": { "configPath": "", "output": "", + "file": "", "quiet": false, "check-for-app-update": false, "anchore": { diff --git a/internal/ui/ephemeral_terminal_ui.go b/internal/ui/ephemeral_terminal_ui.go index 1d0d8028486..289b34a05ca 100644 --- a/internal/ui/ephemeral_terminal_ui.go +++ b/internal/ui/ephemeral_terminal_ui.go @@ -33,25 +33,28 @@ import ( // 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 - output *os.File + unsubscribe func() error + handler *ui.Handler + waitGroup *sync.WaitGroup + frame *frame.Frame + logBuffer *bytes.Buffer + uiOutput *os.File + reportOutput io.Writer } -func NewEphemeralTerminalUI() UI { +// NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer. +func NewEphemeralTerminalUI(reportWriter io.Writer) UI { return &ephemeralTerminalUI{ - handler: ui.NewHandler(), - waitGroup: &sync.WaitGroup{}, - output: os.Stderr, + handler: ui.NewHandler(), + waitGroup: &sync.WaitGroup{}, + uiOutput: os.Stderr, + reportOutput: reportWriter, } } func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error { h.unsubscribe = unsubscribe - hideCursor(h.output) + hideCursor(h.uiOutput) // prep the logger to not clobber the screen from now on (logrus only) h.logBuffer = bytes.NewBufferString("") @@ -81,7 +84,7 @@ func (h *ephemeralTerminalUI) Handle(event partybus.Event) error { // are about to write bytes to stdout, so we should reset the terminal state first h.closeScreen(false) - if err := handleCatalogerPresenterReady(event); err != nil { + if err := handleCatalogerPresenterReady(event, h.reportOutput); err != nil { log.Errorf("unable to show %s event: %+v", event.Type, err) } @@ -95,7 +98,7 @@ func (h *ephemeralTerminalUI) openScreen() error { config := frame.Config{ PositionPolicy: frame.PolicyFloatForward, // only report output to stderr, reserve report output for stdout - Output: h.output, + Output: h.uiOutput, } fr, err := frame.New(config) @@ -128,15 +131,15 @@ func (h *ephemeralTerminalUI) flushLog() { logWrapper, ok := log.Log.(*logger.LogrusLogger) if ok { fmt.Fprint(logWrapper.Output, h.logBuffer.String()) - logWrapper.Logger.SetOutput(h.output) + logWrapper.Logger.SetOutput(h.uiOutput) } else { - fmt.Fprint(h.output, h.logBuffer.String()) + fmt.Fprint(h.uiOutput, h.logBuffer.String()) } } func (h *ephemeralTerminalUI) Teardown(force bool) error { h.closeScreen(force) - showCursor(h.output) + showCursor(h.uiOutput) return nil } diff --git a/internal/ui/event_handlers.go b/internal/ui/event_handlers.go index 252f2b919ce..8daf706880c 100644 --- a/internal/ui/event_handlers.go +++ b/internal/ui/event_handlers.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "os" "sync" "github.com/anchore/syft/internal" @@ -17,14 +16,14 @@ import ( // 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 { +func handleCatalogerPresenterReady(event partybus.Event, reportOutput io.Writer) 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 { + if err := pres.Present(reportOutput); err != nil { return fmt.Errorf("unable to show package catalog report: %w", err) } return nil diff --git a/internal/ui/logger_ui.go b/internal/ui/logger_ui.go index 7108711b9bd..49a8431ad4b 100644 --- a/internal/ui/logger_ui.go +++ b/internal/ui/logger_ui.go @@ -1,17 +1,23 @@ package ui import ( + "io" + "github.com/anchore/syft/internal/log" syftEvent "github.com/anchore/syft/syft/event" "github.com/wagoodman/go-partybus" ) type loggerUI struct { - unsubscribe func() error + unsubscribe func() error + reportOutput io.Writer } -func NewLoggerUI() UI { - return &loggerUI{} +// NewLoggerUI writes all events to the common application logger and writes the final report to the given writer. +func NewLoggerUI(reportWriter io.Writer) UI { + return &loggerUI{ + reportOutput: reportWriter, + } } func (l *loggerUI) Setup(unsubscribe func() error) error { @@ -25,7 +31,7 @@ func (l loggerUI) Handle(event partybus.Event) error { return nil } - if err := handleCatalogerPresenterReady(event); err != nil { + if err := handleCatalogerPresenterReady(event, l.reportOutput); err != nil { log.Warnf("unable to show catalog image finished event: %+v", err) } diff --git a/internal/ui/select.go b/internal/ui/select.go index 37969efb162..863f1d30d0b 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -1,6 +1,7 @@ package ui import ( + "io" "os" "runtime" @@ -10,20 +11,21 @@ import ( // TODO: build tags to exclude options from windows // 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). -func Select(verbose, quiet bool) UI { - var ui UI - +// 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, reportWriter io.Writer) (uis []UI) { isStdoutATty := terminal.IsTerminal(int(os.Stdout.Fd())) isStderrATty := terminal.IsTerminal(int(os.Stderr.Fd())) notATerminal := !isStderrATty && !isStdoutATty switch { case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: - ui = NewLoggerUI() + uis = append(uis, NewLoggerUI(reportWriter)) default: - ui = NewEphemeralTerminalUI() + uis = append(uis, NewEphemeralTerminalUI(reportWriter), NewLoggerUI(reportWriter)) } - return ui + return uis }