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:
Alex Goodman
2023-07-06 09:00:46 -04:00
committed by GitHub
parent a00a3df10c
commit f8b832e6c3
77 changed files with 3250 additions and 618 deletions
+9 -9
View File
@@ -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)
}
}
+32
View File
@@ -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
View File
@@ -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...)
}
-24
View File
@@ -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
}
-42
View File
@@ -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
}
-154
View File
@@ -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")
}
-36
View File
@@ -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
}
-40
View File
@@ -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
}
-31
View File
@@ -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
}
-13
View File
@@ -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())
}
-11
View File
@@ -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
}