diff options
Diffstat (limited to 'runtime/ci')
-rw-r--r-- | runtime/ci/evaluator.go | 187 | ||||
-rw-r--r-- | runtime/ci/evaluator_test.go | 61 | ||||
-rw-r--r-- | runtime/ci/reference_file.go | 7 | ||||
-rw-r--r-- | runtime/ci/rule.go | 168 |
4 files changed, 423 insertions, 0 deletions
diff --git a/runtime/ci/evaluator.go b/runtime/ci/evaluator.go new file mode 100644 index 0000000..2c3cb93 --- /dev/null +++ b/runtime/ci/evaluator.go @@ -0,0 +1,187 @@ +package ci + +import ( + "fmt" + "github.com/dustin/go-humanize" + "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/utils" + "sort" + "strconv" + "strings" + + "github.com/spf13/viper" + + "github.com/logrusorgru/aurora" +) + +type CiEvaluator struct { + Rules []CiRule + Results map[string]RuleResult + Tally ResultTally + Pass bool + Misconfigured bool + InefficientFiles []ReferenceFile +} + +type ResultTally struct { + Pass int + Fail int + Skip int + Warn int + Total int +} + +func NewCiEvaluator(config *viper.Viper) *CiEvaluator { + return &CiEvaluator{ + Rules: loadCiRules(config), + Results: make(map[string]RuleResult), + Pass: true, + } +} + +func (ci *CiEvaluator) isRuleEnabled(rule CiRule) bool { + return rule.Configuration() != "disabled" +} + +func (ci *CiEvaluator) Evaluate(analysis *image.AnalysisResult) bool { + canEvaluate := true + for _, rule := range ci.Rules { + if !ci.isRuleEnabled(rule) { + ci.Results[rule.Key()] = RuleResult{ + status: RuleConfigured, + message: "rule disabled", + } + continue + } + + err := rule.Validate() + if err != nil { + ci.Results[rule.Key()] = RuleResult{ + status: RuleMisconfigured, + message: err.Error(), + } + canEvaluate = false + } else { + ci.Results[rule.Key()] = RuleResult{ + status: RuleConfigured, + message: "test", + } + } + + } + + if !canEvaluate { + ci.Pass = false + ci.Misconfigured = true + return ci.Pass + } + + // capture inefficient files + for idx := 0; idx < len(analysis.Inefficiencies); idx++ { + fileData := analysis.Inefficiencies[len(analysis.Inefficiencies)-1-idx] + + ci.InefficientFiles = append(ci.InefficientFiles, ReferenceFile{ + References: len(fileData.Nodes), + SizeBytes: uint64(fileData.CumulativeSize), + Path: fileData.Path, + }) + } + + // evaluate results against the configured CI rules + for _, rule := range ci.Rules { + if !ci.isRuleEnabled(rule) { + ci.Results[rule.Key()] = RuleResult{ + status: RuleDisabled, + message: "rule disabled", + } + continue + } + + status, message := rule.Evaluate(analysis) + + if value, exists := ci.Results[rule.Key()]; exists && value.status != RuleConfigured && value.status != RuleMisconfigured { + panic(fmt.Errorf("CI rule result recorded twice: %s", rule.Key())) + } + + if status == RuleFailed { + ci.Pass = false + } + + ci.Results[rule.Key()] = RuleResult{ + status: status, + message: message, + } + + } + + ci.Tally.Total = len(ci.Results) + for rule, result := range ci.Results { + switch result.status { + case RulePassed: + ci.Tally.Pass++ + case RuleFailed: + ci.Tally.Fail++ + case RuleWarning: + ci.Tally.Warn++ + case RuleDisabled: + ci.Tally.Skip++ + default: + panic(fmt.Errorf("unknown test status (rule='%v'): %v", rule, result.status)) + } + } + + return ci.Pass +} + +func (ci *CiEvaluator) Report() { + fmt.Println(utils.TitleFormat("Inefficient Files:")) + + template := "%5s %12s %-s\n" + fmt.Printf(template, "Count", "Wasted Space", "File Path") + + if len(ci.InefficientFiles) == 0 { + fmt.Println("None") + } else { + for _, file := range ci.InefficientFiles { + fmt.Printf(template, strconv.Itoa(file.References), humanize.Bytes(file.SizeBytes), file.Path) + } + } + + fmt.Println(utils.TitleFormat("Results:")) + + status := "PASS" + + rules := make([]string, 0, len(ci.Results)) + for name := range ci.Results { + rules = append(rules, name) + } + sort.Strings(rules) + + if ci.Tally.Fail > 0 { + status = "FAIL" + } + + for _, rule := range rules { + result := ci.Results[rule] + name := strings.TrimPrefix(rule, "rules.") + if result.message != "" { + fmt.Printf(" %s: %s: %s\n", result.status.String(), name, result.message) + } else { + fmt.Printf(" %s: %s\n", result.status.String(), name) + } + } + + if ci.Misconfigured { + fmt.Println(aurora.Red("CI Misconfigured")) + return + } + + 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)) + } +} diff --git a/runtime/ci/evaluator_test.go b/runtime/ci/evaluator_test.go new file mode 100644 index 0000000..acd6b9b --- /dev/null +++ b/runtime/ci/evaluator_test.go @@ -0,0 +1,61 @@ +package ci + +import ( + "github.com/wagoodman/dive/dive/image/docker" + "strings" + "testing" + + "github.com/spf13/viper" +) + +func Test_Evaluator(t *testing.T) { + + result, err := docker.TestLoadDockerImageTar("../../.data/test-docker-image.tar") + if err != nil { + t.Fatalf("Test_Export: unable to fetch analysis: %v", err) + } + + table := map[string]struct { + efficiency string + wastedBytes string + wastedPercent string + expectedPass bool + expectedResult map[string]RuleStatus + }{ + "allFail": {"0.99", "1B", "0.01", false, map[string]RuleStatus{"lowestEfficiency": RuleFailed, "highestWastedBytes": RuleFailed, "highestUserWastedPercent": RuleFailed}}, + "allPass": {"0.9", "50kB", "0.1", true, map[string]RuleStatus{"lowestEfficiency": RulePassed, "highestWastedBytes": RulePassed, "highestUserWastedPercent": RulePassed}}, + "allDisabled": {"disabled", "disabled", "disabled", true, map[string]RuleStatus{"lowestEfficiency": RuleDisabled, "highestWastedBytes": RuleDisabled, "highestUserWastedPercent": RuleDisabled}}, + "misconfiguredHigh": {"1.1", "1BB", "10", false, map[string]RuleStatus{"lowestEfficiency": RuleMisconfigured, "highestWastedBytes": RuleMisconfigured, "highestUserWastedPercent": RuleMisconfigured}}, + "misconfiguredLow": {"-9", "-1BB", "-0.1", false, map[string]RuleStatus{"lowestEfficiency": RuleMisconfigured, "highestWastedBytes": RuleMisconfigured, "highestUserWastedPercent": RuleMisconfigured}}, + } + + for name, test := range table { + ciConfig := viper.New() + ciConfig.SetDefault("rules.lowestEfficiency", test.efficiency) + ciConfig.SetDefault("rules.highestWastedBytes", test.wastedBytes) + ciConfig.SetDefault("rules.highestUserWastedPercent", test.wastedPercent) + + evaluator := NewCiEvaluator(ciConfig) + + pass := evaluator.Evaluate(result) + + if test.expectedPass != pass { + t.Logf("Test: %s", name) + t.Errorf("Test_Evaluator: expected pass=%v, got %v", test.expectedPass, pass) + } + + if len(test.expectedResult) != len(evaluator.Results) { + t.Logf("Test: %s", name) + t.Errorf("Test_Evaluator: expected %v results, got %v", len(test.expectedResult), len(evaluator.Results)) + } + + for rule, actualResult := range evaluator.Results { + expectedStatus := test.expectedResult[strings.TrimPrefix(rule, "rules.")] + if expectedStatus != actualResult.status { + t.Errorf(" %v: expected %v rule failures, got %v: %v", rule, expectedStatus, actualResult.status, actualResult) + } + } + + } + +} diff --git a/runtime/ci/reference_file.go b/runtime/ci/reference_file.go new file mode 100644 index 0000000..c5891c7 --- /dev/null +++ b/runtime/ci/reference_file.go @@ -0,0 +1,7 @@ +package ci + +type ReferenceFile struct { + References int `json:"count"` + SizeBytes uint64 `json:"sizeBytes"` + Path string `json:"file"` +} diff --git a/runtime/ci/rule.go b/runtime/ci/rule.go new file mode 100644 index 0000000..60b350d --- /dev/null +++ b/runtime/ci/rule.go @@ -0,0 +1,168 @@ +package ci + +import ( + "fmt" + "github.com/wagoodman/dive/dive/image" + "strconv" + + "github.com/spf13/viper" + + "github.com/dustin/go-humanize" + "github.com/logrusorgru/aurora" +) + +const ( + RuleUnknown = iota + RulePassed + RuleFailed + RuleWarning + RuleDisabled + RuleMisconfigured + RuleConfigured +) + +type CiRule interface { + Key() string + Configuration() string + Validate() error + Evaluate(result *image.AnalysisResult) (RuleStatus, string) +} + +type GenericCiRule struct { + key string + configValue string + configValidator func(string) error + evaluator func(*image.AnalysisResult, string) (RuleStatus, string) +} + +type RuleStatus int + +type RuleResult struct { + status RuleStatus + message string +} + +func newGenericCiRule(key string, configValue string, validator func(string) error, evaluator func(*image.AnalysisResult, string) (RuleStatus, string)) *GenericCiRule { + return &GenericCiRule{ + key: key, + configValue: configValue, + configValidator: validator, + evaluator: evaluator, + } +} + +func (rule *GenericCiRule) Key() string { + return rule.key +} + +func (rule *GenericCiRule) Configuration() string { + return rule.configValue +} + +func (rule *GenericCiRule) Validate() error { + return rule.configValidator(rule.configValue) +} + +func (rule *GenericCiRule) Evaluate(result *image.AnalysisResult) (RuleStatus, string) { + return rule.evaluator(result, rule.configValue) +} + +func (status RuleStatus) String() string { + switch status { + case RulePassed: + return "PASS" + case RuleFailed: + return aurora.Bold(aurora.Inverse(aurora.Red("FAIL"))).String() + case RuleWarning: + return aurora.Blue("WARN").String() + case RuleDisabled: + return aurora.Blue("SKIP").String() + case RuleMisconfigured: + return aurora.Bold(aurora.Inverse(aurora.Red("MISCONFIGURED"))).String() + case RuleConfigured: + return "CONFIGURED " + default: + return aurora.Inverse("Unknown").String() + } +} + +func loadCiRules(config *viper.Viper) []CiRule { + var rules = make([]CiRule, 0) + var ruleKey = "lowestEfficiency" + rules = append(rules, newGenericCiRule( + ruleKey, + config.GetString(fmt.Sprintf("rules.%s", ruleKey)), + func(value string) error { + lowestEfficiency, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid config value ('%v'): %v", value, err) + } + if lowestEfficiency < 0 || lowestEfficiency > 1 { + return fmt.Errorf("lowestEfficiency config value is outside allowed range (0-1), given '%s'", value) + } + return nil + }, + func(analysis *image.AnalysisResult, value string) (RuleStatus, string) { + lowestEfficiency, err := strconv.ParseFloat(value, 64) + if err != nil { + return RuleFailed, fmt.Sprintf("invalid config value ('%v'): %v", value, err) + } + if lowestEfficiency > analysis.Efficiency { + return RuleFailed, fmt.Sprintf("image efficiency is too low (efficiency=%v < threshold=%v)", analysis.Efficiency, lowestEfficiency) + } + return RulePassed, "" + }, + )) + + ruleKey = "highestWastedBytes" + rules = append(rules, newGenericCiRule( + ruleKey, + config.GetString(fmt.Sprintf("rules.%s", ruleKey)), + func(value string) error { + _, err := humanize.ParseBytes(value) + if err != nil { + return fmt.Errorf("invalid config value ('%v'): %v", value, err) + } + return nil + }, + func(analysis *image.AnalysisResult, value string) (RuleStatus, string) { + highestWastedBytes, err := humanize.ParseBytes(value) + if err != nil { + return RuleFailed, fmt.Sprintf("invalid config value ('%v'): %v", value, err) + } + if analysis.WastedBytes > highestWastedBytes { + return RuleFailed, fmt.Sprintf("too many bytes wasted (wasted-bytes=%v > threshold=%v)", analysis.WastedBytes, highestWastedBytes) + } + return RulePassed, "" + }, + )) + + ruleKey = "highestUserWastedPercent" + rules = append(rules, newGenericCiRule( + ruleKey, + config.GetString(fmt.Sprintf("rules.%s", ruleKey)), + func(value string) error { + highestUserWastedPercent, err := strconv.ParseFloat(value, 64) + if err != nil { + return fmt.Errorf("invalid config value ('%v'): %v", value, err) + } + if highestUserWastedPercent < 0 || highestUserWastedPercent > 1 { + return fmt.Errorf("highestUserWastedPercent config value is outside allowed range (0-1), given '%s'", value) + } + return nil + }, + func(analysis *image.AnalysisResult, value string) (RuleStatus, string) { + highestUserWastedPercent, err := strconv.ParseFloat(value, 64) + if err != nil { + return RuleFailed, fmt.Sprintf("invalid config value ('%v'): %v", value, err) + } + if highestUserWastedPercent < analysis.WastedUserPercent { + return RuleFailed, fmt.Sprintf("too many bytes wasted, relative to the user bytes added (%%-user-wasted-bytes=%v > threshold=%v)", analysis.WastedUserPercent, highestUserWastedPercent) + } + + return RulePassed, "" + }, + )) + + return rules +} |