add tests to cover runtime entrypoint

This commit is contained in:
Alex Goodman 2019-10-09 14:49:59 -04:00
parent 11a2473807
commit c8ab7098d8
No known key found for this signature in database
GPG Key ID: 98AF011C5C78EB7E
7 changed files with 444 additions and 111 deletions

1
go.mod
View File

@ -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

View File

@ -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()
}

31
runtime/event.go Normal file
View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

285
runtime/run_test.go Normal file
View File

@ -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)
}
}
}
}
}