mirror of
https://github.com/anchore/syft.git
synced 2025-11-18 00:43:20 +01:00
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:
parent
f47a6a88b1
commit
1b23a94015
@ -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) {
|
||||
// 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 {
|
||||
// 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)
|
||||
log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
return ux, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unable to setup any UI")
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)...,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)...,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
29
cmd/report_writer.go
Normal file
29
cmd/report_writer.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -168,6 +168,7 @@
|
||||
"configuration": {
|
||||
"configPath": "",
|
||||
"output": "",
|
||||
"file": "",
|
||||
"quiet": false,
|
||||
"check-for-app-update": false,
|
||||
"anchore": {
|
||||
|
||||
@ -38,20 +38,23 @@ type ephemeralTerminalUI struct {
|
||||
waitGroup *sync.WaitGroup
|
||||
frame *frame.Frame
|
||||
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{
|
||||
handler: ui.NewHandler(),
|
||||
waitGroup: &sync.WaitGroup{},
|
||||
output: os.Stderr,
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
@ -8,10 +10,14 @@ import (
|
||||
|
||||
type loggerUI struct {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user