Skip to content

Commit

Permalink
Switch UI to bubbletea (#1888)
Browse files Browse the repository at this point in the history
* add bubbletea UI

Signed-off-by: Alex Goodman <[email protected]>

* swap pipeline to go 1.20.x and add attest guard for cosign binary

Signed-off-by: Alex Goodman <[email protected]>

* update note in developing.md about the required golang version

Signed-off-by: Alex Goodman <[email protected]>

* fix merge conflict for windows path handling

Signed-off-by: Alex Goodman <[email protected]>

* temp test for attest handler

Signed-off-by: Alex Goodman <[email protected]>

* add addtional test iterations for background reader

Signed-off-by: Alex Goodman <[email protected]>

---------

Signed-off-by: Alex Goodman <[email protected]>
Signed-off-by: Alex Goodman <[email protected]>
  • Loading branch information
wagoodman authored Jul 6, 2023
1 parent a00a3df commit f8b832e
Show file tree
Hide file tree
Showing 77 changed files with 3,225 additions and 593 deletions.
2 changes: 1 addition & 1 deletion .github/actions/bootstrap/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ inputs:
go-version:
description: "Go version to install"
required: true
default: "1.19.x"
default: "1.20.x"
use-go-cache:
description: "Restore go cache"
required: true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
go.work
go.work.sum
/bin
/.bin
CHANGELOG.md
VERSION
Expand Down
1 change: 1 addition & 0 deletions cmd/syft/cli/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions, po
RunE: func(cmd *cobra.Command, args []string) error {
if app.CheckForAppUpdate {
checkForApplicationUpdate()
// TODO: this is broke, the bus isn't available yet
}

return attest.Run(cmd.Context(), app, args)
Expand Down
15 changes: 11 additions & 4 deletions cmd/syft/cli/attest/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import (
"github.com/anchore/syft/cmd/syft/cli/eventloop"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/cli/packages"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config"
"github.com/anchore/syft/internal/file"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/event"
"github.com/anchore/syft/syft/event/monitor"
Expand All @@ -39,6 +39,13 @@ func Run(_ context.Context, app *config.Application, args []string) error {
// note: must be a container image
userInput := args[0]

_, err = exec.LookPath("cosign")
if err != nil {
// when cosign is not installed the error will be rendered like so:
// 2023/06/30 08:31:52 error during command execution: 'syft attest' requires cosign to be installed: exec: "cosign": executable file not found in $PATH
return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err)
}

eventBus := partybus.NewBus()
stereoscope.SetBus(eventBus)
syft.SetBus(eventBus)
Expand Down Expand Up @@ -119,7 +126,7 @@ func execWorker(app *config.Application, userInput string) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
defer bus.Publish(partybus.Event{Type: event.Exit})
defer bus.Exit()

s, err := buildSBOM(app, userInput, errs)
if err != nil {
Expand Down Expand Up @@ -207,8 +214,8 @@ func execWorker(app *config.Application, userInput string) <-chan error {
Context: "cosign",
},
Value: &monitor.ShellProgress{
Reader: r,
Manual: mon,
Reader: r,
Progressable: mon,
},
},
)
Expand Down
2 changes: 1 addition & 1 deletion cmd/syft/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func checkForApplicationUpdate() {
log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version)

bus.Publish(partybus.Event{
Type: event.AppUpdateAvailable,
Type: event.CLIAppUpdateAvailable,
Value: newVersion,
})
} else {
Expand Down
1 change: 1 addition & 0 deletions cmd/syft/cli/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func Convert(v *viper.Viper, app *config.Application, ro *options.RootOptions, p
RunE: func(cmd *cobra.Command, args []string) error {
if app.CheckForAppUpdate {
checkForApplicationUpdate()
// TODO: this is broke, the bus isn't available yet
}
return convert.Run(cmd.Context(), app, args)
},
Expand Down
51 changes: 45 additions & 6 deletions cmd/syft/cli/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@ import (
"io"
"os"

"github.com/wagoodman/go-partybus"

"github.com/anchore/stereoscope"
"github.com/anchore/syft/cmd/syft/cli/eventloop"
"github.com/anchore/syft/cmd/syft/cli/options"
"github.com/anchore/syft/cmd/syft/internal/ui"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/config"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/sbom"
)

func Run(_ context.Context, app *config.Application, args []string) error {
log.Warn("convert is an experimental feature, run `syft convert -h` for help")

writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath)
if err != nil {
return err
}

// this can only be a SBOM file
// could be an image or a directory, with or without a scheme
userInput := args[0]

var reader io.ReadCloser
Expand All @@ -37,10 +46,40 @@ func Run(_ context.Context, app *config.Application, args []string) error {
reader = f
}

sbom, _, err := formats.Decode(reader)
if err != nil {
return fmt.Errorf("failed to decode SBOM: %w", err)
}
eventBus := partybus.NewBus()
stereoscope.SetBus(eventBus)
syft.SetBus(eventBus)
subscription := eventBus.Subscribe()

return eventloop.EventLoop(
execWorker(reader, writer),
eventloop.SetupSignals(),
subscription,
stereoscope.Cleanup,
ui.Select(options.IsVerbose(app), app.Quiet)...,
)
}

func execWorker(reader io.Reader, writer sbom.Writer) <-chan error {
errs := make(chan error)
go func() {
defer close(errs)
defer bus.Exit()

return writer.Write(*sbom)
s, _, err := formats.Decode(reader)
if err != nil {
errs <- fmt.Errorf("failed to decode SBOM: %w", err)
return
}

if s == nil {
errs <- fmt.Errorf("no SBOM produced")
return
}

if err := writer.Write(*s); err != nil {
errs <- fmt.Errorf("failed to write SBOM: %w", err)
}
}()
return errs
}
14 changes: 7 additions & 7 deletions cmd/syft/cli/eventloop/event_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/wagoodman/go-partybus"

"github.com/anchore/clio"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/ui"
)

// eventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and
// EventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and
// signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until
// an eventual graceful exit.
func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...ui.UI) error {
func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...clio.UI) error {
defer cleanupFn()
events := subscription.Events()
var err error
var ux ui.UI
var ux clio.UI

if ux, err = setupUI(subscription.Unsubscribe, uxs...); err != nil {
if ux, err = setupUI(subscription, uxs...); err != nil {
return err
}

Expand Down Expand Up @@ -85,9 +85,9 @@ func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *
// 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) {
func setupUI(subscription *partybus.Subscription, uis ...clio.UI) (clio.UI, error) {
for _, ux := range uis {
if err := ux.Setup(unsubscribe); err != nil {
if err := ux.Setup(subscription); err != nil {
log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err)
continue
}
Expand Down
29 changes: 16 additions & 13 deletions cmd/syft/cli/eventloop/event_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,37 @@ import (
"github.com/stretchr/testify/mock"
"github.com/wagoodman/go-partybus"

"github.com/anchore/syft/internal/ui"
"github.com/anchore/clio"
"github.com/anchore/syft/syft/event"
)

var _ ui.UI = (*uiMock)(nil)
var _ clio.UI = (*uiMock)(nil)

type uiMock struct {
t *testing.T
finalEvent partybus.Event
unsubscribe func() error
t *testing.T
finalEvent partybus.Event
subscription partybus.Unsubscribable
mock.Mock
}

func (u *uiMock) Setup(unsubscribe func() error) error {
func (u *uiMock) Setup(unsubscribe partybus.Unsubscribable) error {
u.t.Helper()
u.t.Logf("UI Setup called")
u.unsubscribe = unsubscribe
return u.Called(unsubscribe).Error(0)
u.subscription = unsubscribe
return u.Called(unsubscribe.Unsubscribe).Error(0)
}

func (u *uiMock) Handle(event partybus.Event) error {
u.t.Helper()
u.t.Logf("UI Handle called: %+v", event.Type)
if event == u.finalEvent {
assert.NoError(u.t, u.unsubscribe())
assert.NoError(u.t, u.subscription.Unsubscribe())
}
return u.Called(event).Error(0)
}

func (u *uiMock) Teardown(_ bool) error {
u.t.Helper()
u.t.Logf("UI Teardown called")
return u.Called().Error(0)
}
Expand All @@ -51,7 +54,7 @@ func Test_EventLoop_gracefulExit(t *testing.T) {
t.Cleanup(testBus.Close)

finalEvent := partybus.Event{
Type: event.Exit,
Type: event.CLIExit,
}

worker := func() <-chan error {
Expand Down Expand Up @@ -183,7 +186,7 @@ func Test_EventLoop_unsubscribeError(t *testing.T) {
t.Cleanup(testBus.Close)

finalEvent := partybus.Event{
Type: event.Exit,
Type: event.CLIExit,
}

worker := func() <-chan error {
Expand Down Expand Up @@ -252,7 +255,7 @@ func Test_EventLoop_handlerError(t *testing.T) {
t.Cleanup(testBus.Close)

finalEvent := partybus.Event{
Type: event.Exit,
Type: event.CLIExit,
Error: fmt.Errorf("an exit error occured"),
}

Expand Down Expand Up @@ -377,7 +380,7 @@ func Test_EventLoop_uiTeardownError(t *testing.T) {
t.Cleanup(testBus.Close)

finalEvent := partybus.Event{
Type: event.Exit,
Type: event.CLIExit,
}

worker := func() <-chan error {
Expand Down
29 changes: 19 additions & 10 deletions cmd/syft/cli/options/writer.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package options

import (
"bytes"
"fmt"
"io"
"os"
Expand All @@ -10,6 +11,7 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/mitchellh/go-homedir"

"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/formats"
"github.com/anchore/syft/syft/formats/table"
Expand Down Expand Up @@ -114,14 +116,6 @@ type sbomMultiWriter struct {
writers []sbom.Writer
}

type nopWriteCloser struct {
io.Writer
}

func (n nopWriteCloser) Close() error {
return nil
}

// newSBOMMultiWriter create all report writers from input options; if a file is not specified the given defaultWriter is used
func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, err error) {
if len(options) == 0 {
Expand All @@ -133,9 +127,8 @@ func newSBOMMultiWriter(options ...sbomWriterDescription) (_ *sbomMultiWriter, e
for _, option := range options {
switch len(option.Path) {
case 0:
out.writers = append(out.writers, &sbomStreamWriter{
out.writers = append(out.writers, &sbomPublisher{
format: option.Format,
out: nopWriteCloser{Writer: os.Stdout},
})
default:
// create any missing subdirectories
Expand Down Expand Up @@ -195,3 +188,19 @@ func (w *sbomStreamWriter) Close() error {
}
return nil
}

// sbomPublisher implements sbom.Writer that publishes results to the event bus
type sbomPublisher struct {
format sbom.Format
}

// Write the provided SBOM to the data stream
func (w *sbomPublisher) Write(s sbom.SBOM) error {
buf := &bytes.Buffer{}
if err := w.format.Encode(buf, s); err != nil {
return fmt.Errorf("unable to encode SBOM: %w", err)
}

bus.Report(buf.String())
return nil
}
2 changes: 2 additions & 0 deletions cmd/syft/cli/options/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ func Test_newSBOMMultiWriter(t *testing.T) {
if e.file != "" {
assert.FileExists(t, tmp+e.file)
}
case *sbomPublisher:
assert.Equal(t, string(w.format.ID()), e.format)
default:
t.Fatalf("unknown writer type: %T", w)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/syft/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func Packages(v *viper.Viper, app *config.Application, ro *options.RootOptions,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
if app.CheckForAppUpdate {
// TODO: this is broke, the bus isn't available yet
checkForApplicationUpdate()
}
return packages.Run(cmd.Context(), app, args)
Expand Down
Loading

0 comments on commit f8b832e

Please sign in to comment.