summaryrefslogtreecommitdiffstats
path: root/runtime/ci
diff options
context:
space:
mode:
Diffstat (limited to 'runtime/ci')
-rw-r--r--runtime/ci/evaluator.go187
-rw-r--r--runtime/ci/evaluator_test.go61
-rw-r--r--runtime/ci/reference_file.go7
-rw-r--r--runtime/ci/rule.go168
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
+}