diff --git a/go.mod b/go.mod index 6a0edf1..a40b48f 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee github.com/sergi/go-diff v1.0.0 github.com/sirupsen/logrus v1.4.2 + github.com/spf13/afero v1.2.2 github.com/spf13/cobra v0.0.5 github.com/spf13/viper v1.4.0 github.com/wagoodman/keybinding v0.0.0-20181213133715-6a824da6df05 diff --git a/runtime/ci/evaluator.go b/runtime/ci/evaluator.go index 2c3cb93..f8baeec 100644 --- a/runtime/ci/evaluator.go +++ b/runtime/ci/evaluator.go @@ -133,21 +133,22 @@ func (ci *CiEvaluator) Evaluate(analysis *image.AnalysisResult) bool { return ci.Pass } -func (ci *CiEvaluator) Report() { - fmt.Println(utils.TitleFormat("Inefficient Files:")) +func (ci *CiEvaluator) Report() string { + var sb strings.Builder + fmt.Fprintln(&sb, utils.TitleFormat("Inefficient Files:")) template := "%5s %12s %-s\n" - fmt.Printf(template, "Count", "Wasted Space", "File Path") + fmt.Fprintf(&sb, template, "Count", "Wasted Space", "File Path") if len(ci.InefficientFiles) == 0 { - fmt.Println("None") + fmt.Fprintln(&sb, "None") } else { for _, file := range ci.InefficientFiles { - fmt.Printf(template, strconv.Itoa(file.References), humanize.Bytes(file.SizeBytes), file.Path) + fmt.Fprintf(&sb, template, strconv.Itoa(file.References), humanize.Bytes(file.SizeBytes), file.Path) } } - fmt.Println(utils.TitleFormat("Results:")) + fmt.Fprintln(&sb, utils.TitleFormat("Results:")) status := "PASS" @@ -165,23 +166,24 @@ func (ci *CiEvaluator) Report() { result := ci.Results[rule] name := strings.TrimPrefix(rule, "rules.") if result.message != "" { - fmt.Printf(" %s: %s: %s\n", result.status.String(), name, result.message) + fmt.Fprintf(&sb, " %s: %s: %s\n", result.status.String(), name, result.message) } else { - fmt.Printf(" %s: %s\n", result.status.String(), name) + fmt.Fprintf(&sb, " %s: %s\n", result.status.String(), name) } } if ci.Misconfigured { - fmt.Println(aurora.Red("CI Misconfigured")) - return - } + fmt.Fprintln(&sb, aurora.Red("CI Misconfigured")) - summary := fmt.Sprintf("Result:%s [Total:%d] [Passed:%d] [Failed:%d] [Warn:%d] [Skipped:%d]", status, ci.Tally.Total, ci.Tally.Pass, ci.Tally.Fail, ci.Tally.Warn, ci.Tally.Skip) - if ci.Pass { - fmt.Println(aurora.Green(summary)) - } else if ci.Pass && ci.Tally.Warn > 0 { - fmt.Println(aurora.Blue(summary)) } else { - fmt.Println(aurora.Red(summary)) + summary := fmt.Sprintf("Result:%s [Total:%d] [Passed:%d] [Failed:%d] [Warn:%d] [Skipped:%d]", status, ci.Tally.Total, ci.Tally.Pass, ci.Tally.Fail, ci.Tally.Warn, ci.Tally.Skip) + if ci.Pass { + fmt.Fprintln(&sb, aurora.Green(summary)) + } else if ci.Pass && ci.Tally.Warn > 0 { + fmt.Fprintln(&sb, aurora.Blue(summary)) + } else { + fmt.Fprintln(&sb, aurora.Red(summary)) + } } + return sb.String() } diff --git a/runtime/event.go b/runtime/event.go new file mode 100644 index 0000000..b9ab931 --- /dev/null +++ b/runtime/event.go @@ -0,0 +1,31 @@ +package runtime + +type eventChannel chan event + +type event struct { + stdout string + stderr string + err error + errorOnExit bool +} + +func (ec eventChannel) message(msg string) { + ec <- event{ + stdout: msg, + } +} + +func (ec eventChannel) exitWithError(err error) { + ec <- event{ + err: err, + errorOnExit: true, + } +} + +func (ec eventChannel) exitWithErrorMessage(msg string, err error) { + ec <- event{ + stderr: msg, + err: err, + errorOnExit: true, + } +} diff --git a/runtime/export/export.go b/runtime/export/export.go index 4c32b67..8085afc 100644 --- a/runtime/export/export.go +++ b/runtime/export/export.go @@ -3,7 +3,6 @@ package export import ( "encoding/json" diveImage "github.com/wagoodman/dive/dive/image" - "io/ioutil" ) type export struct { @@ -47,14 +46,6 @@ func NewExport(analysis *diveImage.AnalysisResult) *export { return &data } -func (exp *export) marshal() ([]byte, error) { +func (exp *export) Marshal() ([]byte, error) { return json.MarshalIndent(&exp, "", " ") } - -func (exp *export) ToFile(exportFilePath string) error { - payload, err := exp.marshal() - if err != nil { - return err - } - return ioutil.WriteFile(exportFilePath, payload, 0644) -} diff --git a/runtime/export/export_test.go b/runtime/export/export_test.go index f3028a5..3d6d1d2 100644 --- a/runtime/export/export_test.go +++ b/runtime/export/export_test.go @@ -10,7 +10,7 @@ func Test_Export(t *testing.T) { result := docker.TestAnalysisFromArchive(t, "../../.data/test-docker-image.tar") export := NewExport(result) - payload, err := export.marshal() + payload, err := export.Marshal() if err != nil { t.Errorf("Test_Export: unable to export analysis: %v", err) } diff --git a/runtime/run.go b/runtime/run.go index a55f583..0dbcfa8 100644 --- a/runtime/run.go +++ b/runtime/run.go @@ -2,126 +2,149 @@ package runtime import ( "fmt" - "github.com/sirupsen/logrus" - "github.com/wagoodman/dive/dive" - "github.com/wagoodman/dive/runtime/ci" - "github.com/wagoodman/dive/runtime/export" - "os" - "time" - "github.com/dustin/go-humanize" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/wagoodman/dive/dive" "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/runtime/ci" + "github.com/wagoodman/dive/runtime/export" "github.com/wagoodman/dive/runtime/ui" "github.com/wagoodman/dive/utils" + "os" + "time" ) -func runCi(analysis *image.AnalysisResult, options Options) { - - fmt.Printf(" efficiency: %2.4f %%\n", analysis.Efficiency*100) - fmt.Printf(" wastedBytes: %d bytes (%s)\n", analysis.WastedBytes, humanize.Bytes(analysis.WastedBytes)) - fmt.Printf(" userWastedPercent: %2.4f %%\n", analysis.WastedUserPercent*100) - - evaluator := ci.NewCiEvaluator(options.CiConfig) - - pass := evaluator.Evaluate(analysis) - - // todo: report should return a string? - evaluator.Report() - - if pass { - os.Exit(0) - } - os.Exit(1) -} - -// todo: give channel of strings which the caller uses for fmt.print? or a more complex type? -// todo: return err? or treat like a go routine? -// todo: should there be a run() so that Run() can do the above and run() be the go routine? Then we test the behavior of run() (not Run()) -func Run(options Options) { +func run(enableUi bool, options Options, imageResolver image.Resolver, events eventChannel, filesystem afero.Fs) { + var img *image.Image var err error + defer close(events) + doExport := options.ExportFile != "" doBuild := len(options.BuildArgs) > 0 - // if an image option was provided, parse it and determine the container image... - // otherwise, use the configs default value. - - // if build is given, get the handler based off of either the explicit runtime - - imageResolver, err := dive.GetImageResolver(options.Source) - if err != nil { - fmt.Printf("cannot determine image provider: %v\n", err) - os.Exit(1) - } - - var img *image.Image - if doBuild { - fmt.Println(utils.TitleFormat("Building image...")) + events.message(utils.TitleFormat("Building image...")) img, err = imageResolver.Build(options.BuildArgs) if err != nil { - fmt.Printf("cannot build image: %v\n", err) - os.Exit(1) + events.exitWithErrorMessage("cannot build image", err) + return } } else { - fmt.Println(utils.TitleFormat("Fetching image...") + " (this can take a while for large images)") + events.message(utils.TitleFormat("Fetching image...") + " (this can take a while for large images)") img, err = imageResolver.Fetch(options.Image) if err != nil { - fmt.Printf("cannot fetch image: %v\n", err) - os.Exit(1) + events.exitWithErrorMessage("cannot fetch image", err) + return } } - // todo, cleanup on error - // todo: image get should return error for cleanup? - - if doExport { - fmt.Println(utils.TitleFormat(fmt.Sprintf("Analyzing image... (export to '%s')", options.ExportFile))) - } else { - fmt.Println(utils.TitleFormat("Analyzing image...")) - } - - result, err := img.Analyze() + events.message(utils.TitleFormat("Analyzing image...")) + analysis, err := img.Analyze() if err != nil { - fmt.Printf("cannot analyze image: %v\n", err) - os.Exit(1) + events.exitWithErrorMessage("cannot analyze image", err) + return } if doExport { - err = export.NewExport(result).ToFile(options.ExportFile) + events.message(utils.TitleFormat(fmt.Sprintf("Exporting image to '%s'...", options.ExportFile))) + bytes, err := export.NewExport(analysis).Marshal() if err != nil { - fmt.Printf("cannot write export file: %v\n", err) - os.Exit(1) + events.exitWithErrorMessage("cannot marshal export payload", err) + return } + + file, err := filesystem.OpenFile(options.ExportFile, os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + events.exitWithErrorMessage("cannot open export file", err) + return + } + defer file.Close() + + _, err = file.Write(bytes) + if err != nil { + events.exitWithErrorMessage("cannot write to export file", err) + } + return } if options.Ci { - runCi(result, options) - } else { - if doExport { - os.Exit(0) + events.message(fmt.Sprintf(" efficiency: %2.4f %%\n", analysis.Efficiency*100)) + events.message(fmt.Sprintf(" wastedBytes: %d bytes (%s)\n", analysis.WastedBytes, humanize.Bytes(analysis.WastedBytes))) + events.message(fmt.Sprintf(" userWastedPercent: %2.4f %%\n", analysis.WastedUserPercent*100)) + + evaluator := ci.NewCiEvaluator(options.CiConfig) + pass := evaluator.Evaluate(analysis) + events.message(evaluator.Report()) + + if !pass { + events.exitWithError(nil) } - fmt.Println(utils.TitleFormat("Building cache...")) - cache := filetree.NewFileTreeCache(result.RefTrees) + return + + } else { + events.message(utils.TitleFormat("Building cache...")) + cache := filetree.NewFileTreeCache(analysis.RefTrees) err := cache.Build() if err != nil { - logrus.Error(err) - os.Exit(1) + events.exitWithErrorMessage("cannot build cache tree", err) + return } - // it appears there is a race condition where termbox.Init() will - // block nearly indefinitely when running as the first process in - // a Docker container when started within ~25ms of container startup. - // I can't seem to determine the exact root cause, however, a large - // enough sleep will prevent this behavior (todo: remove this hack) - time.Sleep(100 * time.Millisecond) + if enableUi { + // it appears there is a race condition where termbox.Init() will + // block nearly indefinitely when running as the first process in + // a Docker container when started within ~25ms of container startup. + // I can't seem to determine the exact root cause, however, a large + // enough sleep will prevent this behavior (todo: remove this hack) + time.Sleep(100 * time.Millisecond) - err = ui.Run(result, cache) - if err != nil { - logrus.Error(err) - os.Exit(1) + err = ui.Run(analysis, cache) + if err != nil { + events.exitWithErrorMessage("runtime error", err) + return + } } - os.Exit(0) } } + +func Run(options Options) { + var exitCode int + var events = make(eventChannel) + + imageResolver, err := dive.GetImageResolver(options.Source) + if err != nil { + message := "cannot determine image provider" + logrus.Error(message) + logrus.Error(err) + fmt.Fprintf(os.Stderr, "%s: %+v\n", message, err) + os.Exit(1) + } + + go run(true, options, imageResolver, events, afero.NewOsFs()) + + for event := range events { + if event.stdout != "" { + fmt.Println(event.stdout) + } + + if event.stderr != "" { + logrus.Error(event.stderr) + _, err := fmt.Fprintln(os.Stderr, event.stderr) + if err != nil { + fmt.Println("error: could not write to buffer:", err) + } + } + + if event.err != nil { + logrus.Error(event.err) + } + + if event.errorOnExit { + exitCode = 1 + } + } + os.Exit(exitCode) +} diff --git a/runtime/run_test.go b/runtime/run_test.go new file mode 100644 index 0000000..9f71472 --- /dev/null +++ b/runtime/run_test.go @@ -0,0 +1,285 @@ +package runtime + +import ( + "fmt" + "github.com/lunixbochs/vtclean" + "github.com/spf13/afero" + "github.com/spf13/viper" + "github.com/wagoodman/dive/dive" + "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/dive/image/docker" + "os" + "testing" +) + +type defaultResolver struct{} + +func (r *defaultResolver) Fetch(id string) (*image.Image, error) { + archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") + if err != nil { + return nil, err + } + return archive.ToImage() +} + +func (r *defaultResolver) Build(args []string) (*image.Image, error) { + return r.Fetch("") +} + +type failedBuildResolver struct{} + +func (r *failedBuildResolver) Fetch(id string) (*image.Image, error) { + archive, err := docker.TestLoadArchive("../.data/test-docker-image.tar") + if err != nil { + return nil, err + } + return archive.ToImage() +} + +func (r *failedBuildResolver) Build(args []string) (*image.Image, error) { + return nil, fmt.Errorf("some build failure") +} + +type failedFetchResolver struct{} + +func (r *failedFetchResolver) Fetch(id string) (*image.Image, error) { + return nil, fmt.Errorf("some fetch failure") +} + +func (r *failedFetchResolver) Build(args []string) (*image.Image, error) { + return nil, fmt.Errorf("some build failure") +} + +// func showEvents(events []testEvent) { +// for _, e := range events { +// fmt.Printf("{stdout:\"%s\", stderr:\"%s\", errorOnExit: %v, errMessage: \"%s\"},\n", +// strings.Replace(vtclean.Clean(e.stdout, false), "\n", "\\n", -1), +// strings.Replace(vtclean.Clean(e.stderr, false), "\n", "\\n", -1), +// e.errorOnExit, +// e.errMessage) +// } +// } + +type testEvent struct { + stdout string + stderr string + errMessage string + errorOnExit bool +} + +func newTestEvent(e event) testEvent { + var errMsg string + if e.err != nil { + errMsg = e.err.Error() + } + return testEvent{ + stdout: e.stdout, + stderr: e.stderr, + errMessage: errMsg, + errorOnExit: e.errorOnExit, + } +} + +func configureCi() *viper.Viper { + ciConfig := viper.New() + ciConfig.SetDefault("rules.lowestEfficiency", "0.9") + ciConfig.SetDefault("rules.highestWastedBytes", "1000") + ciConfig.SetDefault("rules.highestUserWastedPercent", "0.1") + return ciConfig +} + +func TestRun(t *testing.T) { + table := map[string]struct { + resolver image.Resolver + options Options + events []testEvent + }{ + "fetch-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: false, + Image: "dive-example", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: nil, + BuildArgs: nil, + }, + events: []testEvent{ + {stdout: "Fetching image... (this can take a while for large images)", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Building cache...", stderr: "", errorOnExit: false, errMessage: ""}, + }, + }, + "fetch-with-no-build-options-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: false, + Image: "dive-example", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: nil, + // note: empty slice is passed + BuildArgs: []string{}, + }, + events: []testEvent{ + {stdout: "Fetching image... (this can take a while for large images)", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Building cache...", stderr: "", errorOnExit: false, errMessage: ""}, + }, + }, + "build-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: false, + Image: "dive-example", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: nil, + BuildArgs: []string{"an-option"}, + }, + events: []testEvent{ + {stdout: "Building image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Building cache...", stderr: "", errorOnExit: false, errMessage: ""}, + }, + }, + "failed-fetch": { + resolver: &failedFetchResolver{}, + options: Options{ + Ci: false, + Image: "dive-example", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: nil, + BuildArgs: nil, + }, + events: []testEvent{ + {stdout: "Fetching image... (this can take a while for large images)", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "", stderr: "cannot fetch image", errorOnExit: true, errMessage: "some fetch failure"}, + }, + }, + "failed-build": { + resolver: &failedBuildResolver{}, + options: Options{ + Ci: false, + Image: "doesn't-matter", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: nil, + BuildArgs: []string{"an-option"}, + }, + events: []testEvent{ + {stdout: "Building image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "", stderr: "cannot build image", errorOnExit: true, errMessage: "some build failure"}, + }, + }, + "ci-go-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: true, + Image: "doesn't-matter", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: configureCi(), + BuildArgs: []string{"an-option"}, + }, + events: []testEvent{ + {stdout: "Building image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " efficiency: 98.4421 %\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " wastedBytes: 32025 bytes (32 kB)\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " userWastedPercent: 48.3491 %\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Inefficient Files:\nCount Wasted Space File Path\n 2 13 kB /root/saved.txt\n 2 13 kB /root/example/somefile1.txt\n 2 6.4 kB /root/example/somefile3.txt\nResults:\n FAIL: highestUserWastedPercent: too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.4834911001404049 > threshold=0.1)\n FAIL: highestWastedBytes: too many bytes wasted (wasted-bytes=32025 > threshold=1000)\n PASS: lowestEfficiency\nResult:FAIL [Total:3] [Passed:1] [Failed:2] [Warn:0] [Skipped:0]\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "", stderr: "", errorOnExit: true, errMessage: ""}, + }, + }, + "empty-ci-config-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: true, + Image: "doesn't-matter", + Source: dive.SourceDockerEngine, + ExportFile: "", + CiConfig: viper.New(), + BuildArgs: []string{"an-option"}, + }, + events: []testEvent{ + {stdout: "Building image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " efficiency: 98.4421 %\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " wastedBytes: 32025 bytes (32 kB)\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: " userWastedPercent: 48.3491 %\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Inefficient Files:\nCount Wasted Space File Path\nNone\nResults:\n MISCONFIGURED: highestUserWastedPercent: invalid config value (''): strconv.ParseFloat: parsing \"\": invalid syntax\n MISCONFIGURED: highestWastedBytes: invalid config value (''): strconv.ParseFloat: parsing \"\": invalid syntax\n MISCONFIGURED: lowestEfficiency: invalid config value (''): strconv.ParseFloat: parsing \"\": invalid syntax\nCI Misconfigured\n", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "", stderr: "", errorOnExit: true, errMessage: ""}, + }, + }, + "export-go-case": { + resolver: &defaultResolver{}, + options: Options{ + Ci: true, + Image: "doesn't-matter", + Source: dive.SourceDockerEngine, + ExportFile: "some-file.json", + CiConfig: configureCi(), + BuildArgs: []string{"an-option"}, + }, + events: []testEvent{ + {stdout: "Building image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Analyzing image...", stderr: "", errorOnExit: false, errMessage: ""}, + {stdout: "Exporting image to 'some-file.json'...", stderr: "", errorOnExit: false, errMessage: ""}, + }, + }, + } + + for name, test := range table { + var ec = make(eventChannel) + var events = make([]testEvent, 0) + var filesystem = afero.NewMemMapFs() + + go run(false, test.options, test.resolver, ec, filesystem) + + for event := range ec { + events = append(events, newTestEvent(event)) + } + + // fmt.Println(name) + // showEvents(events) + // fmt.Println() + + if len(test.events) != len(events) { + t.Fatalf("%s.%s: expected # events='%v', got '%v'", t.Name(), name, len(test.events), len(events)) + } + + for idx, actualEvent := range events { + expectedEvent := test.events[idx] + + if expectedEvent.errorOnExit != actualEvent.errorOnExit { + t.Errorf("%s.%s: expected errorOnExit='%v', got '%v'", t.Name(), name, expectedEvent.errorOnExit, actualEvent.errorOnExit) + } + + actualEventStdoutClean := vtclean.Clean(actualEvent.stdout, false) + expectedEventStdoutClean := vtclean.Clean(expectedEvent.stdout, false) + + if expectedEventStdoutClean != actualEventStdoutClean { + t.Errorf("%s.%s: expected stdout='%v', got '%v'", t.Name(), name, expectedEventStdoutClean, actualEventStdoutClean) + } + + actualEventStderrClean := vtclean.Clean(actualEvent.stderr, false) + expectedEventStderrClean := vtclean.Clean(expectedEvent.stderr, false) + + if expectedEventStderrClean != actualEventStderrClean { + t.Errorf("%s.%s: expected stderr='%v', got '%v'", t.Name(), name, expectedEventStderrClean, actualEventStderrClean) + } + + if expectedEvent.errMessage != actualEvent.errMessage { + t.Errorf("%s.%s: expected error='%v', got '%v'", t.Name(), name, expectedEvent.errMessage, actualEvent.errMessage) + } + + if test.options.ExportFile != "" { + if _, err := filesystem.Stat(test.options.ExportFile); os.IsNotExist(err) { + t.Errorf("%s.%s: expected export file but did not find one", t.Name(), name) + } + } + } + } +}