mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
Switch UI to bubbletea (#1888)
* add bubbletea UI Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * swap pipeline to go 1.20.x and add attest guard for cosign binary Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * update note in developing.md about the required golang version Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix merge conflict for windows path handling Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * temp test for attest handler Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add addtional test iterations for background reader Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <alex.goodman@anchore.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
+9
-9
@@ -16,20 +16,20 @@ package bus
|
||||
import "github.com/wagoodman/go-partybus"
|
||||
|
||||
var publisher partybus.Publisher
|
||||
var active bool
|
||||
|
||||
// SetPublisher sets the singleton event bus publisher. This is optional; if no bus is provided, the library will
|
||||
// Set sets the singleton event bus publisher. This is optional; if no bus is provided, the library will
|
||||
// behave no differently than if a bus had been provided.
|
||||
func SetPublisher(p partybus.Publisher) {
|
||||
func Set(p partybus.Publisher) {
|
||||
publisher = p
|
||||
if p != nil {
|
||||
active = true
|
||||
}
|
||||
}
|
||||
|
||||
func Get() partybus.Publisher {
|
||||
return publisher
|
||||
}
|
||||
|
||||
// Publish an event onto the bus. If there is no bus set by the calling application, this does nothing.
|
||||
func Publish(event partybus.Event) {
|
||||
if active {
|
||||
publisher.Publish(event)
|
||||
func Publish(e partybus.Event) {
|
||||
if publisher != nil {
|
||||
publisher.Publish(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
func Exit() {
|
||||
Publish(partybus.Event{
|
||||
Type: event.CLIExit,
|
||||
})
|
||||
}
|
||||
|
||||
func Report(report string) {
|
||||
if len(report) == 0 {
|
||||
return
|
||||
}
|
||||
report = log.Redactor.RedactString(report)
|
||||
Publish(partybus.Event{
|
||||
Type: event.CLIReport,
|
||||
Value: report,
|
||||
})
|
||||
}
|
||||
|
||||
func Notify(message string) {
|
||||
Publish(partybus.Event{
|
||||
Type: event.CLINotification,
|
||||
Value: message,
|
||||
})
|
||||
}
|
||||
+33
-14
@@ -6,67 +6,86 @@ package log
|
||||
import (
|
||||
"github.com/anchore/go-logger"
|
||||
"github.com/anchore/go-logger/adapter/discard"
|
||||
"github.com/anchore/go-logger/adapter/redact"
|
||||
)
|
||||
|
||||
// Log is the singleton used to facilitate logging internally within syft
|
||||
var Log logger.Logger = discard.New()
|
||||
var (
|
||||
// log is the singleton used to facilitate logging internally within
|
||||
log = discard.New()
|
||||
|
||||
store = redact.NewStore()
|
||||
|
||||
Redactor = store.(redact.Redactor)
|
||||
)
|
||||
|
||||
func Set(l logger.Logger) {
|
||||
log = redact.New(l, store)
|
||||
}
|
||||
|
||||
func Get() logger.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func Redact(values ...string) {
|
||||
store.Add(values...)
|
||||
}
|
||||
|
||||
// Errorf takes a formatted template string and template arguments for the error logging level.
|
||||
func Errorf(format string, args ...interface{}) {
|
||||
Log.Errorf(format, args...)
|
||||
log.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Error logs the given arguments at the error logging level.
|
||||
func Error(args ...interface{}) {
|
||||
Log.Error(args...)
|
||||
log.Error(args...)
|
||||
}
|
||||
|
||||
// Warnf takes a formatted template string and template arguments for the warning logging level.
|
||||
func Warnf(format string, args ...interface{}) {
|
||||
Log.Warnf(format, args...)
|
||||
log.Warnf(format, args...)
|
||||
}
|
||||
|
||||
// Warn logs the given arguments at the warning logging level.
|
||||
func Warn(args ...interface{}) {
|
||||
Log.Warn(args...)
|
||||
log.Warn(args...)
|
||||
}
|
||||
|
||||
// Infof takes a formatted template string and template arguments for the info logging level.
|
||||
func Infof(format string, args ...interface{}) {
|
||||
Log.Infof(format, args...)
|
||||
log.Infof(format, args...)
|
||||
}
|
||||
|
||||
// Info logs the given arguments at the info logging level.
|
||||
func Info(args ...interface{}) {
|
||||
Log.Info(args...)
|
||||
log.Info(args...)
|
||||
}
|
||||
|
||||
// Debugf takes a formatted template string and template arguments for the debug logging level.
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
Log.Debugf(format, args...)
|
||||
log.Debugf(format, args...)
|
||||
}
|
||||
|
||||
// Debug logs the given arguments at the debug logging level.
|
||||
func Debug(args ...interface{}) {
|
||||
Log.Debug(args...)
|
||||
log.Debug(args...)
|
||||
}
|
||||
|
||||
// Tracef takes a formatted template string and template arguments for the trace logging level.
|
||||
func Tracef(format string, args ...interface{}) {
|
||||
Log.Tracef(format, args...)
|
||||
log.Tracef(format, args...)
|
||||
}
|
||||
|
||||
// Trace logs the given arguments at the trace logging level.
|
||||
func Trace(args ...interface{}) {
|
||||
Log.Trace(args...)
|
||||
log.Trace(args...)
|
||||
}
|
||||
|
||||
// WithFields returns a message logger with multiple key-value fields.
|
||||
func WithFields(fields ...interface{}) logger.MessageLogger {
|
||||
return Log.WithFields(fields...)
|
||||
return log.WithFields(fields...)
|
||||
}
|
||||
|
||||
// Nested returns a new logger with hard coded key-value pairs
|
||||
func Nested(fields ...interface{}) logger.Logger {
|
||||
return Log.Nested(fields...)
|
||||
return log.Nested(fields...)
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
// handleExit is a UI function for processing the Exit bus event,
|
||||
// and calling the given function to output the contents.
|
||||
func handleExit(event partybus.Event) error {
|
||||
// show the report to stdout
|
||||
fn, err := syftEventParsers.ParseExit(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad CatalogerFinished event: %w", err)
|
||||
}
|
||||
|
||||
if err := fn(); err != nil {
|
||||
return fmt.Errorf("unable to show package catalog report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// TODO: move me to a common module (used in multiple repos)
|
||||
|
||||
const (
|
||||
SpinnerDotSet = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
)
|
||||
|
||||
type Spinner struct {
|
||||
index int
|
||||
charset []string
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func NewSpinner(charset string) Spinner {
|
||||
return Spinner{
|
||||
charset: strings.Split(charset, ""),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Spinner) Current() string {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
return s.charset[s.index]
|
||||
}
|
||||
|
||||
func (s *Spinner) Next() string {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
c := s.charset[s.index]
|
||||
s.index++
|
||||
if s.index >= len(s.charset) {
|
||||
s.index = 0
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
//go:build linux || darwin || netbsd
|
||||
// +build linux darwin netbsd
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/jotframe/pkg/frame"
|
||||
|
||||
"github.com/anchore/go-logger"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/ui"
|
||||
)
|
||||
|
||||
// ephemeralTerminalUI provides an "ephemeral" terminal user interface to display the application state dynamically.
|
||||
// The terminal is placed into raw mode and the cursor is manipulated to allow for a dynamic, multi-line
|
||||
// UI (provided by the jotframe lib), for this reason all other application mechanisms that write to the screen
|
||||
// must be suppressed before starting (such as logs); since bytes in the device and in application memory combine to make
|
||||
// a shared state, bytes coming from elsewhere to the screen will disrupt this state.
|
||||
//
|
||||
// This UI is primarily driven off of events from the event bus, creating single-line terminal widgets to represent a
|
||||
// published element on the event bus, typically polling the element for the latest state. This allows for the UI to
|
||||
// control update frequency to the screen, provide "liveness" indications that are interpolated between bus events,
|
||||
// and overall loosely couple the bus events from screen interactions.
|
||||
//
|
||||
// By convention, all elements published on the bus should be treated as read-only, and publishers on the bus should
|
||||
// attempt to enforce this when possible by wrapping complex objects with interfaces to prescribe interactions. Also by
|
||||
// convention, each new event that the UI should respond to should be added either in this package as a handler function,
|
||||
// or in the shared ui package as a function on the main handler object. All handler functions should be completed
|
||||
// processing an event before the ETUI exits (coordinated with a sync.WaitGroup)
|
||||
type ephemeralTerminalUI struct {
|
||||
unsubscribe func() error
|
||||
handler *ui.Handler
|
||||
waitGroup *sync.WaitGroup
|
||||
frame *frame.Frame
|
||||
logBuffer *bytes.Buffer
|
||||
uiOutput *os.File
|
||||
}
|
||||
|
||||
// NewEphemeralTerminalUI writes all events to a TUI and writes the final report to the given writer.
|
||||
func NewEphemeralTerminalUI() UI {
|
||||
return &ephemeralTerminalUI{
|
||||
handler: ui.NewHandler(),
|
||||
waitGroup: &sync.WaitGroup{},
|
||||
uiOutput: os.Stderr,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) Setup(unsubscribe func() error) error {
|
||||
h.unsubscribe = unsubscribe
|
||||
hideCursor(h.uiOutput)
|
||||
|
||||
// prep the logger to not clobber the screen from now on (logrus only)
|
||||
h.logBuffer = bytes.NewBufferString("")
|
||||
logController, ok := log.Log.(logger.Controller)
|
||||
if ok {
|
||||
logController.SetOutput(h.logBuffer)
|
||||
}
|
||||
|
||||
return h.openScreen()
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) Handle(event partybus.Event) error {
|
||||
ctx := context.Background()
|
||||
switch {
|
||||
case h.handler.RespondsTo(event):
|
||||
if err := h.handler.Handle(ctx, h.frame, event, h.waitGroup); err != nil {
|
||||
log.Errorf("unable to show %s event: %+v", event.Type, err)
|
||||
}
|
||||
|
||||
case event.Type == syftEvent.AppUpdateAvailable:
|
||||
if err := handleAppUpdateAvailable(ctx, h.frame, event, h.waitGroup); err != nil {
|
||||
log.Errorf("unable to show %s event: %+v", event.Type, err)
|
||||
}
|
||||
|
||||
case event.Type == syftEvent.Exit:
|
||||
// we need to close the screen now since signaling the sbom is ready means that we
|
||||
// are about to write bytes to stdout, so we should reset the terminal state first
|
||||
h.closeScreen(false)
|
||||
|
||||
if err := handleExit(event); err != nil {
|
||||
log.Errorf("unable to show %s event: %+v", event.Type, err)
|
||||
}
|
||||
|
||||
// this is the last expected event, stop listening to events
|
||||
return h.unsubscribe()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) openScreen() error {
|
||||
config := frame.Config{
|
||||
PositionPolicy: frame.PolicyFloatForward,
|
||||
// only report output to stderr, reserve report output for stdout
|
||||
Output: h.uiOutput,
|
||||
}
|
||||
|
||||
fr, err := frame.New(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create the screen object: %w", err)
|
||||
}
|
||||
h.frame = fr
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) closeScreen(force bool) {
|
||||
// we may have other background processes still displaying progress, wait for them to
|
||||
// finish before discontinuing dynamic content and showing the final report
|
||||
if !h.frame.IsClosed() {
|
||||
if !force {
|
||||
h.waitGroup.Wait()
|
||||
}
|
||||
h.frame.Close()
|
||||
// TODO: there is a race condition within frame.Close() that sometimes leads to an extra blank line being output
|
||||
frame.Close()
|
||||
|
||||
// only flush the log on close
|
||||
h.flushLog()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) flushLog() {
|
||||
// flush any errors to the screen before the report
|
||||
logController, ok := log.Log.(logger.Controller)
|
||||
if ok {
|
||||
fmt.Fprint(logController.GetOutput(), h.logBuffer.String())
|
||||
logController.SetOutput(h.uiOutput)
|
||||
} else {
|
||||
fmt.Fprint(h.uiOutput, h.logBuffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ephemeralTerminalUI) Teardown(force bool) error {
|
||||
h.closeScreen(force)
|
||||
showCursor(h.uiOutput)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hideCursor(output io.Writer) {
|
||||
fmt.Fprint(output, "\x1b[?25l")
|
||||
}
|
||||
|
||||
func showCursor(output io.Writer) {
|
||||
fmt.Fprint(output, "\x1b[?25h")
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//go:build linux || darwin || netbsd
|
||||
// +build linux darwin netbsd
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/gookit/color"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
"github.com/wagoodman/jotframe/pkg/frame"
|
||||
|
||||
"github.com/anchore/syft/internal"
|
||||
syftEventParsers "github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
// handleAppUpdateAvailable is a UI handler function to display a new application version to the top of the screen.
|
||||
func handleAppUpdateAvailable(_ context.Context, fr *frame.Frame, event partybus.Event, _ *sync.WaitGroup) error {
|
||||
newVersion, err := syftEventParsers.ParseAppUpdateAvailable(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad AppUpdateAvailable event: %w", err)
|
||||
}
|
||||
|
||||
line, err := fr.Prepend()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message := color.Magenta.Sprintf("New version of %s is available: %s", internal.ApplicationName, newVersion)
|
||||
_, _ = io.WriteString(line, message)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
syftEvent "github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
type loggerUI struct {
|
||||
unsubscribe func() error
|
||||
}
|
||||
|
||||
// NewLoggerUI writes all events to the common application logger and writes the final report to the given writer.
|
||||
func NewLoggerUI() UI {
|
||||
return &loggerUI{}
|
||||
}
|
||||
|
||||
func (l *loggerUI) Setup(unsubscribe func() error) error {
|
||||
l.unsubscribe = unsubscribe
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l loggerUI) Handle(event partybus.Event) error {
|
||||
// ignore all events except for the final event
|
||||
if event.Type != syftEvent.Exit {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := handleExit(event); err != nil {
|
||||
log.Warnf("unable to show catalog image finished event: %+v", err)
|
||||
}
|
||||
|
||||
// this is the last expected event, stop listening to events
|
||||
return l.unsubscribe()
|
||||
}
|
||||
|
||||
func (l loggerUI) Teardown(_ bool) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//go:build linux || darwin || netbsd
|
||||
// +build linux darwin netbsd
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Select is responsible for determining the specific UI function given select user option, the current platform
|
||||
// config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
|
||||
// is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
|
||||
// are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
|
||||
// the final SBOM report.
|
||||
func Select(verbose, quiet bool) (uis []UI) {
|
||||
isStdoutATty := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
isStderrATty := term.IsTerminal(int(os.Stderr.Fd()))
|
||||
notATerminal := !isStderrATty && !isStdoutATty
|
||||
|
||||
switch {
|
||||
case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
|
||||
uis = append(uis, NewLoggerUI())
|
||||
default:
|
||||
uis = append(uis, NewEphemeralTerminalUI())
|
||||
}
|
||||
|
||||
return uis
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package ui
|
||||
|
||||
// Select is responsible for determining the specific UI function given select user option, the current platform
|
||||
// config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
|
||||
// is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
|
||||
// are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
|
||||
// the final SBOM report.
|
||||
func Select(verbose, quiet bool) (uis []UI) {
|
||||
return append(uis, NewLoggerUI())
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
type UI interface {
|
||||
Setup(unsubscribe func() error) error
|
||||
partybus.Handler
|
||||
Teardown(force bool) error
|
||||
}
|
||||
Reference in New Issue
Block a user