Add option to output SBOM report to a file (#530)

* add output to file option

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* log errors on close of the report destination

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* remove file option from persistent args

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* update file option comments and logging

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* allow for multiple UI fallback options

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>

* update UI select signatures + tests

Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
Alex Goodman 2021-10-05 14:47:24 -04:00 committed by GitHub
parent f47a6a88b1
commit 1b23a94015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 129 additions and 48 deletions

View File

@ -2,6 +2,7 @@ package cmd
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"github.com/anchore/syft/internal/log" "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 // signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until
// an eventual graceful exit. // an eventual graceful exit.
// nolint:gocognit,funlen // 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() defer cleanupFn()
events := subscription.Events() events := subscription.Events()
var err error 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 return err
} }
@ -78,15 +81,18 @@ func eventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *
return retErr return retErr
} }
func setupUI(unsubscribe func() error, ux ui.UI) (ui.UI, error) { // setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use
if err := ux.Setup(unsubscribe); err != nil { // during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error
// replace the existing UI with a (simpler) logger UI // will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks
ux = ui.NewLoggerUI() // 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 { if err := ux.Setup(unsubscribe); err != nil {
// something is very wrong, bail. log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
return ux, 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")
} }

View File

@ -96,8 +96,8 @@ func Test_eventLoop_gracefulExit(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
) )
@ -159,8 +159,8 @@ func Test_eventLoop_workerError(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
workerErr, workerErr,
"should have seen a worker error, but did not", "should have seen a worker error, but did not",
@ -230,8 +230,8 @@ func Test_eventLoop_unsubscribeError(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
) )
@ -300,8 +300,8 @@ func Test_eventLoop_handlerError(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
finalEvent.Error, finalEvent.Error,
"should have seen a event error, but did not", "should have seen a event error, but did not",
@ -355,8 +355,8 @@ func Test_eventLoop_signalsStopExecution(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
) )
@ -425,8 +425,8 @@ func Test_eventLoop_uiTeardownError(t *testing.T) {
worker(), worker(),
signaler(), signaler(),
subscription, subscription,
ux,
cleanupFn, cleanupFn,
ux,
), ),
teardownError, teardownError,
"should have seen a UI teardown error, but did not", "should have seen a UI teardown error, but did not",

View File

@ -113,6 +113,11 @@ func setPackageFlags(flags *pflag.FlagSet) {
fmt.Sprintf("report output formatter, options=%v", packages.AllPresenters), fmt.Sprintf("report output formatter, options=%v", packages.AllPresenters),
) )
flags.StringP(
"file", "", "",
"file to write the report output to (default is STDOUT)",
)
///////// Upload options ////////////////////////////////////////////////////////// ///////// Upload options //////////////////////////////////////////////////////////
flags.StringP( flags.StringP(
"host", "H", "", "host", "H", "",
@ -156,6 +161,10 @@ func bindPackagesConfigOptions(flags *pflag.FlagSet) error {
return err return err
} }
if err := viper.BindPFlag("file", flags.Lookup("file")); err != nil {
return err
}
///////// Upload options ////////////////////////////////////////////////////////// ///////// Upload options //////////////////////////////////////////////////////////
if err := viper.BindPFlag("anchore.host", flags.Lookup("host")); err != nil { 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 { func packagesExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme // could be an image or a directory, with or without a scheme
userInput := args[0] 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( return eventLoop(
packagesExecWorker(userInput), packagesExecWorker(userInput),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet),
stereoscope.Cleanup, stereoscope.Cleanup,
ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet, reporter)...,
) )
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/anchore/stereoscope" "github.com/anchore/stereoscope"
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/presenter/poweruser" "github.com/anchore/syft/internal/presenter/poweruser"
"github.com/anchore/syft/internal/ui" "github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event"
@ -73,12 +74,24 @@ func init() {
func powerUserExec(_ *cobra.Command, args []string) error { func powerUserExec(_ *cobra.Command, args []string) error {
// could be an image or a directory, with or without a scheme // could be an image or a directory, with or without a scheme
userInput := args[0] 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( return eventLoop(
powerUserExecWorker(userInput), powerUserExecWorker(userInput),
setupSignals(), setupSignals(),
eventSubscription, eventSubscription,
ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet),
stereoscope.Cleanup, stereoscope.Cleanup,
ui.Select(appConfig.CliOptions.Verbosity > 0, appConfig.Quiet, reporter)...,
) )
} }

29
cmd/report_writer.go Normal file
View File

@ -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
}
}

View File

@ -29,6 +29,7 @@ type parser interface {
type Application struct { 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) 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 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) 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 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 Anchore anchore `yaml:"anchore" json:"anchore" mapstructure:"anchore"` // options for interacting with Anchore Engine/Enterprise

View File

@ -168,6 +168,7 @@
"configuration": { "configuration": {
"configPath": "", "configPath": "",
"output": "", "output": "",
"file": "",
"quiet": false, "quiet": false,
"check-for-app-update": false, "check-for-app-update": false,
"anchore": { "anchore": {

View File

@ -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 // 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) // processing an event before the ETUI exits (coordinated with a sync.WaitGroup)
type ephemeralTerminalUI struct { type ephemeralTerminalUI struct {
unsubscribe func() error unsubscribe func() error
handler *ui.Handler handler *ui.Handler
waitGroup *sync.WaitGroup waitGroup *sync.WaitGroup
frame *frame.Frame frame *frame.Frame
logBuffer *bytes.Buffer logBuffer *bytes.Buffer
output *os.File 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{ return &ephemeralTerminalUI{
handler: ui.NewHandler(), handler: ui.NewHandler(),
waitGroup: &sync.WaitGroup{}, waitGroup: &sync.WaitGroup{},
output: os.Stderr, uiOutput: os.Stderr,
reportOutput: reportWriter,
} }
} }
func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error { func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error {
h.unsubscribe = unsubscribe h.unsubscribe = unsubscribe
hideCursor(h.output) hideCursor(h.uiOutput)
// prep the logger to not clobber the screen from now on (logrus only) // prep the logger to not clobber the screen from now on (logrus only)
h.logBuffer = bytes.NewBufferString("") 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 // are about to write bytes to stdout, so we should reset the terminal state first
h.closeScreen(false) 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) log.Errorf("unable to show %s event: %+v", event.Type, err)
} }
@ -95,7 +98,7 @@ func (h *ephemeralTerminalUI) openScreen() error {
config := frame.Config{ config := frame.Config{
PositionPolicy: frame.PolicyFloatForward, PositionPolicy: frame.PolicyFloatForward,
// only report output to stderr, reserve report output for stdout // only report output to stderr, reserve report output for stdout
Output: h.output, Output: h.uiOutput,
} }
fr, err := frame.New(config) fr, err := frame.New(config)
@ -128,15 +131,15 @@ func (h *ephemeralTerminalUI) flushLog() {
logWrapper, ok := log.Log.(*logger.LogrusLogger) logWrapper, ok := log.Log.(*logger.LogrusLogger)
if ok { if ok {
fmt.Fprint(logWrapper.Output, h.logBuffer.String()) fmt.Fprint(logWrapper.Output, h.logBuffer.String())
logWrapper.Logger.SetOutput(h.output) logWrapper.Logger.SetOutput(h.uiOutput)
} else { } else {
fmt.Fprint(h.output, h.logBuffer.String()) fmt.Fprint(h.uiOutput, h.logBuffer.String())
} }
} }
func (h *ephemeralTerminalUI) Teardown(force bool) error { func (h *ephemeralTerminalUI) Teardown(force bool) error {
h.closeScreen(force) h.closeScreen(force)
showCursor(h.output) showCursor(h.uiOutput)
return nil return nil
} }

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"sync" "sync"
"github.com/anchore/syft/internal" "github.com/anchore/syft/internal"
@ -17,14 +16,14 @@ import (
// handleCatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog // handleCatalogerPresenterReady is a UI function for processing the CatalogerFinished bus event, displaying the catalog
// via the given presenter to stdout. // 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 // show the report to stdout
pres, err := syftEventParsers.ParsePresenterReady(event) pres, err := syftEventParsers.ParsePresenterReady(event)
if err != nil { if err != nil {
return fmt.Errorf("bad CatalogerFinished event: %w", err) 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 fmt.Errorf("unable to show package catalog report: %w", err)
} }
return nil return nil

View File

@ -1,17 +1,23 @@
package ui package ui
import ( import (
"io"
"github.com/anchore/syft/internal/log" "github.com/anchore/syft/internal/log"
syftEvent "github.com/anchore/syft/syft/event" syftEvent "github.com/anchore/syft/syft/event"
"github.com/wagoodman/go-partybus" "github.com/wagoodman/go-partybus"
) )
type loggerUI struct { type loggerUI struct {
unsubscribe func() error unsubscribe func() error
reportOutput io.Writer
} }
func NewLoggerUI() UI { // NewLoggerUI writes all events to the common application logger and writes the final report to the given writer.
return &loggerUI{} func NewLoggerUI(reportWriter io.Writer) UI {
return &loggerUI{
reportOutput: reportWriter,
}
} }
func (l *loggerUI) Setup(unsubscribe func() error) error { func (l *loggerUI) Setup(unsubscribe func() error) error {
@ -25,7 +31,7 @@ func (l loggerUI) Handle(event partybus.Event) error {
return nil 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) log.Warnf("unable to show catalog image finished event: %+v", err)
} }

View File

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"io"
"os" "os"
"runtime" "runtime"
@ -10,20 +11,21 @@ import (
// TODO: build tags to exclude options from windows // TODO: build tags to exclude options from windows
// Select is responsible for determining the specific UI function given select user option, the current platform // 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). // config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
func Select(verbose, quiet bool) UI { // is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
var ui UI // 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())) isStdoutATty := terminal.IsTerminal(int(os.Stdout.Fd()))
isStderrATty := terminal.IsTerminal(int(os.Stderr.Fd())) isStderrATty := terminal.IsTerminal(int(os.Stderr.Fd()))
notATerminal := !isStderrATty && !isStdoutATty notATerminal := !isStderrATty && !isStdoutATty
switch { switch {
case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty: case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
ui = NewLoggerUI() uis = append(uis, NewLoggerUI(reportWriter))
default: default:
ui = NewEphemeralTerminalUI() uis = append(uis, NewEphemeralTerminalUI(reportWriter), NewLoggerUI(reportWriter))
} }
return ui return uis
} }