Expose CI validation options to the CLI (#212)

* expose ci config to cli switches; addresses #198

* added/updated tests for ci configuration
This commit is contained in:
Alex Goodman 2019-07-26 10:27:00 -04:00 committed by GitHub
parent efc67354ac
commit e4ddfb411c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 256 additions and 128 deletions

3
.gitignore vendored
View File

@ -21,3 +21,6 @@
/dist
.cover
coverage.txt
# ignore the binary
dive

View File

@ -12,6 +12,7 @@ import (
// image analysis to the screen
func doAnalyzeCmd(cmd *cobra.Command, args []string) {
defer utils.Cleanup()
if len(args) == 0 {
printVersionFlag, err := cmd.PersistentFlags().GetBool("version")
if err == nil && printVersionFlag {
@ -20,22 +21,28 @@ func doAnalyzeCmd(cmd *cobra.Command, args []string) {
}
fmt.Println("No image argument given")
_ = cmd.Help()
utils.Exit(1)
}
userImage := args[0]
if userImage == "" {
fmt.Println("No image argument given")
_ = cmd.Help()
utils.Exit(1)
}
initLogging()
isCi, ciConfig, err := configureCi()
if err != nil {
fmt.Printf("ci configuration error: %v\n", err)
utils.Exit(1)
}
runtime.Run(runtime.Options{
ImageId: userImage,
ExportFile: exportFile,
CiConfigFile: ciConfigFile,
Ci: isCi,
ImageId: userImage,
ExportFile: exportFile,
CiConfig: ciConfig,
})
}

View File

@ -25,7 +25,9 @@ func doBuildCmd(cmd *cobra.Command, args []string) {
initLogging()
runtime.Run(runtime.Options{
Ci: isCi,
BuildArgs: args,
ExportFile: exportFile,
CiConfig: ciConfig,
})
}

39
cmd/ci.go Normal file
View File

@ -0,0 +1,39 @@
package cmd
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"strconv"
"github.com/spf13/viper"
)
func configureCi() (bool, *viper.Viper, error) {
isCiFromEnv, _ := strconv.ParseBool(os.Getenv("CI"))
isCi = isCi || isCiFromEnv
if isCi {
ciConfig.SetConfigType("yaml")
if _, err := os.Stat(ciConfigFile); !os.IsNotExist(err) {
fmt.Printf(" Using CI config: %s\n", ciConfigFile)
fileBytes, err := ioutil.ReadFile(ciConfigFile)
if err != nil {
return isCi, nil, err
}
err = ciConfig.ReadConfig(bytes.NewBuffer(fileBytes))
if err != nil {
return isCi, nil, err
}
} else {
fmt.Println(" Using default CI config")
}
}
return isCi, ciConfig, nil
}

View File

@ -18,6 +18,8 @@ import (
var cfgFile string
var exportFile string
var ciConfigFile string
var ciConfig = viper.New()
var isCi bool
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
@ -39,13 +41,26 @@ func Execute() {
}
func init() {
initCli()
cobra.OnInitialize(initConfig)
}
func initCli() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.dive.yaml, ~/.config/dive/*.yaml, or $XDG_CONFIG_HOME/dive.yaml)")
rootCmd.PersistentFlags().BoolP("version", "v", false, "display version number")
rootCmd.Flags().BoolVar(&isCi, "ci", false, "Skip the interactive TUI and validate against CI rules (same as env var CI=true)")
rootCmd.Flags().StringVarP(&exportFile, "json", "j", "", "Skip the interactive TUI and write the layer analysis statistics to a given file.")
rootCmd.Flags().StringVar(&ciConfigFile, "ci-config", ".dive-ci", "If CI=true in the environment, use the given yaml to drive validation rules.")
rootCmd.Flags().String("lowestEfficiency", "0.9", "(only valid with --ci given) lowest allowable image efficiency, otherwise CI validation will fail.")
rootCmd.Flags().String("highestWastedBytes", "disabled", "(only valid with --ci given) highest allowable bytes wasted, otherwise CI validation will fail.")
rootCmd.Flags().String("highestUserWastedPercent", "0.1", "(only valid with --ci given) highest allowable percentage of bytes wasted, otherwise CI validation will fail.")
for _, key := range []string{"lowestEfficiency", "highestWastedBytes", "highestUserWastedPercent"} {
if err := ciConfig.BindPFlag(fmt.Sprintf("rules.%s", key), rootCmd.Flags().Lookup(key)); err != nil {
log.Fatal("Unable to bind flag:", err)
}
}
}
// initConfig reads in config file and ENV variables if set.
@ -55,7 +70,7 @@ func initConfig() {
viper.SetDefault("log.level", log.InfoLevel.String())
viper.SetDefault("log.path", "./dive.log")
viper.SetDefault("log.enabled", true)
viper.SetDefault("log.enabled", false)
// keybindings: status view / global
viper.SetDefault("keybinding.quit", "ctrl+c")
viper.SetDefault("keybinding.toggle-view", "tab")

View File

@ -1,76 +1,101 @@
package ci
import (
"bytes"
"fmt"
"io/ioutil"
"sort"
"strings"
"github.com/logrusorgru/aurora"
"github.com/spf13/viper"
"github.com/logrusorgru/aurora"
"github.com/wagoodman/dive/image"
)
func NewEvaluator() *Evaluator {
ciConfig := viper.New()
ciConfig.SetConfigType("yaml")
type Evaluator struct {
Rules []Rule
Results map[string]RuleResult
Tally ResultTally
Pass bool
Misconfigured bool
}
ciConfig.SetDefault("rules.lowestEfficiency", 0.9)
ciConfig.SetDefault("rules.highestWastedBytes", "disabled")
ciConfig.SetDefault("rules.highestUserWastedPercent", 0.1)
type ResultTally struct {
Pass int
Fail int
Skip int
Warn int
Total int
}
func NewEvaluator(config *viper.Viper) *Evaluator {
return &Evaluator{
Config: ciConfig,
Rules: loadCiRules(),
Rules: loadCiRules(config),
Results: make(map[string]RuleResult),
Pass: true,
}
}
func (ci *Evaluator) LoadConfig(configFile string) error {
fileBytes, err := ioutil.ReadFile(configFile)
if err != nil {
return err
}
err = ci.Config.ReadConfig(bytes.NewBuffer(fileBytes))
if err != nil {
return err
}
return nil
}
func (ci *Evaluator) isRuleEnabled(rule Rule) bool {
value := ci.Config.GetString(rule.Key())
return value != "disabled"
return rule.Configuration() != "disabled"
}
func (ci *Evaluator) Evaluate(analysis *image.AnalysisResult) bool {
canEvaluate := true
for _, rule := range ci.Rules {
if ci.isRuleEnabled(rule) {
value := ci.Config.GetString(rule.Key())
status, message := rule.Evaluate(analysis, value)
if _, exists := ci.Results[rule.Key()]; exists {
panic(fmt.Errorf("CI rule result recorded twice: %s", rule.Key()))
}
if status == RuleFailed {
ci.Pass = false
}
if !ci.isRuleEnabled(rule) {
ci.Results[rule.Key()] = RuleResult{
status: status,
message: message,
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
}
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)
@ -115,6 +140,11 @@ func (ci *Evaluator) Report() {
}
}
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))

View File

@ -22,34 +22,37 @@ func Test_Evaluator(t *testing.T) {
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}},
"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 _, test := range table {
evaluator := NewEvaluator()
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.Config = ciConfig
evaluator := NewEvaluator(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", rule, expectedStatus, actualResult.status)
t.Errorf(" %v: expected %v rule failures, got %v: %v", rule, expectedStatus, actualResult.status, actualResult)
}
}

View File

@ -4,15 +4,50 @@ import (
"fmt"
"strconv"
"github.com/spf13/viper"
"github.com/dustin/go-humanize"
"github.com/logrusorgru/aurora"
"github.com/wagoodman/dive/image"
)
func newGenericCiRule(key string, evaluator func(*image.AnalysisResult, string) (RuleStatus, string)) *GenericCiRule {
const (
RuleUnknown = iota
RulePassed
RuleFailed
RuleWarning
RuleDisabled
RuleMisconfigured
RuleConfigured
)
type Rule interface {
Key() string
Configuration() string
Validate() error
Evaluate(*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,
evaluator: evaluator,
key: key,
configValue: configValue,
configValidator: validator,
evaluator: evaluator,
}
}
@ -20,8 +55,16 @@ func (rule *GenericCiRule) Key() string {
return rule.key
}
func (rule *GenericCiRule) Evaluate(result *image.AnalysisResult, value string) (RuleStatus, string) {
return rule.evaluator(result, value)
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 {
@ -34,16 +77,31 @@ func (status RuleStatus) String() string {
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() []Rule {
func loadCiRules(config *viper.Viper) []Rule {
var rules = make([]Rule, 0)
var ruleKey = "lowestEfficiency"
rules = append(rules, newGenericCiRule(
"rules.lowestEfficiency",
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 {
@ -56,8 +114,17 @@ func loadCiRules() []Rule {
},
))
ruleKey = "highestWastedBytes"
rules = append(rules, newGenericCiRule(
"rules.highestWastedBytes",
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 {
@ -70,8 +137,20 @@ func loadCiRules() []Rule {
},
))
ruleKey = "highestUserWastedPercent"
rules = append(rules, newGenericCiRule(
"rules.highestUserWastedPercent",
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 {

View File

@ -1,47 +0,0 @@
package ci
import (
"github.com/spf13/viper"
"github.com/wagoodman/dive/image"
)
type RuleStatus int
type RuleResult struct {
status RuleStatus
message string
}
const (
RuleUnknown = iota
RulePassed
RuleFailed
RuleWarning
RuleDisabled
)
type Rule interface {
Key() string
Evaluate(*image.AnalysisResult, string) (RuleStatus, string)
}
type GenericCiRule struct {
key string
evaluator func(*image.AnalysisResult, string) (RuleStatus, string)
}
type Evaluator struct {
Config *viper.Viper
Rules []Rule
Results map[string]RuleResult
Tally ResultTally
Pass bool
}
type ResultTally struct {
Pass int
Fail int
Skip int
Warn int
Total int
}

View File

@ -5,7 +5,6 @@ import (
"io/ioutil"
"log"
"os"
"strconv"
"github.com/dustin/go-humanize"
"github.com/logrusorgru/aurora"
@ -25,15 +24,9 @@ func runCi(analysis *image.AnalysisResult, options Options) {
fmt.Printf(" wastedBytes: %d bytes (%s)\n", analysis.WastedBytes, humanize.Bytes(analysis.WastedBytes))
fmt.Printf(" userWastedPercent: %2.4f %%\n", analysis.WastedUserPercent*100)
fmt.Println(title("Run CI Validations..."))
evaluator := ci.NewEvaluator()
evaluator := ci.NewEvaluator(options.CiConfig)
err := evaluator.LoadConfig(options.CiConfigFile)
if err != nil {
fmt.Println(" Using default CI config")
} else {
fmt.Printf(" Using CI config: %s\n", options.CiConfigFile)
}
fmt.Println(title("Run CI Validations..."))
pass := evaluator.Evaluate(analysis)
evaluator.Report()
@ -71,7 +64,6 @@ func runBuild(buildArgs []string) string {
func Run(options Options) {
doExport := options.ExportFile != ""
doBuild := len(options.BuildArgs) > 0
isCi, _ := strconv.ParseBool(os.Getenv("CI"))
if doBuild {
fmt.Println(title("Building image..."))
@ -115,7 +107,7 @@ func Run(options Options) {
}
}
if isCi {
if options.Ci {
runCi(result, options)
} else {
if doExport {

View File

@ -1,10 +1,15 @@
package runtime
import (
"github.com/spf13/viper"
)
type Options struct {
ImageId string
ExportFile string
CiConfigFile string
BuildArgs []string
Ci bool
ImageId string
ExportFile string
CiConfig *viper.Viper
BuildArgs []string
}
type export struct {