From e4d7b781839cc727764792405eae7cc449e76118 Mon Sep 17 00:00:00 2001 From: Torsten Walter Date: Tue, 20 Oct 2020 23:10:56 +0200 Subject: [PATCH] Support execution of additional commands during ct lint (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * support execution of additional commands for lint Given that helm unittest is installed locally or mounted into the chart testing container one could use this to run helm unittest for each chart. ``` additional-commands: - helm unittest --helm3 -f tests/*.yaml {{ .Path }} ``` The command is treated as a go template. Like this it's possible to get the path to the chart as in the example. It would also be open for further extension if needed. Signed-off-by: Torsten Walter * Use sh to execute command Signed-off-by: Torsten Walter Co-authored-by: Reinhard Nägele * rename function to NewCmdTemplateExecutor Signed-off-by: Torsten Walter * add error handling Signed-off-by: Torsten Walter * add documentation Signed-off-by: Torsten Walter * use go-shellwords to split rendered command Signed-off-by: Torsten Walter * Update pkg/tool/cmdexecutor.go Signed-off-by: Torsten Walter Co-authored-by: Reinhard Nägele * add unit tests Signed-off-by: Torsten Walter Co-authored-by: Reinhard Nägele --- Dockerfile | 1 + ct/cmd/lint.go | 5 +++ go.mod | 1 + go.sum | 2 + pkg/chart/chart.go | 16 ++++++++ pkg/chart/chart_test.go | 53 +++++++++++++++++++++++++ pkg/config/config.go | 1 + pkg/tool/cmdexecutor.go | 38 ++++++++++++++++++ pkg/tool/cmdexecutor_test.go | 76 ++++++++++++++++++++++++++++++++++++ 9 files changed, 193 insertions(+) create mode 100644 pkg/tool/cmdexecutor.go create mode 100644 pkg/tool/cmdexecutor_test.go diff --git a/Dockerfile b/Dockerfile index 6d2781b..c2b6c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM alpine:3.12 RUN apk --no-cache add \ + bash \ curl \ git \ libc6-compat \ diff --git a/ct/cmd/lint.go b/ct/cmd/lint.go index cc32142..6580db9 100644 --- a/ct/cmd/lint.go +++ b/ct/cmd/lint.go @@ -69,6 +69,11 @@ func addLintFlags(flags *flag.FlagSet) { Enable schema validation of 'Chart.yaml' using Yamale (default: true)`)) flags.Bool("validate-yaml", true, heredoc.Doc(` Enable linting of 'Chart.yaml' and values files (default: true)`)) + flags.StringSlice("additional-commands", []string{}, heredoc.Doc(` + Additional commands to run per chart (default: []) + Commands will be executed in the same order as provided in the list and will + be rendered with go template before being executed. + Example: "helm unittest --helm3 -f tests/*.yaml {{ .Path }}"`)) } func lint(cmd *cobra.Command, args []string) error { diff --git a/go.mod b/go.mod index a311720..a35e549 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/goreleaser/goreleaser v0.129.0 github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/go-retryablehttp v0.6.4 + github.com/mattn/go-shellwords v1.0.10 github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/pelletier/go-toml v1.6.0 // indirect diff --git a/go.sum b/go.sum index d118684..64c1511 100644 --- a/go.sum +++ b/go.sum @@ -219,6 +219,8 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw= +github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-zglob v0.0.1 h1:xsEx/XUoVlI6yXjqBK062zYhRTZltCNmYPx6v+8DNaY= github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= diff --git a/pkg/chart/chart.go b/pkg/chart/chart.go index 07c04f1..12021f4 100644 --- a/pkg/chart/chart.go +++ b/pkg/chart/chart.go @@ -131,6 +131,13 @@ type Linter interface { Yamale(yamlFile string, schemaFile string) error } +// CmdExecutor is the interface +// +// RunCommand renders cmdTemplate as go template using data and executes the resulting command +type CmdExecutor interface { + RunCommand(cmdTemplate string, data interface{}) error +} + // DirectoryLister is the interface // // ListChildDirs lists direct child directories of parentDir given they pass the test function @@ -224,6 +231,7 @@ type Testing struct { kubectl Kubectl git Git linter Linter + cmdExecutor CmdExecutor accountValidator AccountValidator directoryLister DirectoryLister chartUtils ChartUtils @@ -253,6 +261,7 @@ func NewTesting(config config.Configuration) (Testing, error) { git: tool.NewGit(procExec), kubectl: tool.NewKubectl(procExec), linter: tool.NewLinter(procExec), + cmdExecutor: tool.NewCmdTemplateExecutor(procExec), accountValidator: tool.AccountValidator{}, directoryLister: util.DirectoryLister{}, chartUtils: util.ChartUtils{}, @@ -451,6 +460,13 @@ func (t *Testing) LintChart(chart *Chart) TestResult { } } + for _, cmd := range t.config.AdditionalCommands { + if err := t.cmdExecutor.RunCommand(cmd, chart); err != nil { + result.Error = err + return result + } + } + // Lint with defaults if no values files are specified. if len(valuesFiles) == 0 { valuesFiles = append(valuesFiles, "") diff --git a/pkg/chart/chart_test.go b/pkg/chart/chart_test.go index 13a1196..829ccf8 100644 --- a/pkg/chart/chart_test.go +++ b/pkg/chart/chart_test.go @@ -109,6 +109,15 @@ func (h fakeHelm) Version() (string, error) { return "v3.0.0", nil } +type fakeCmdExecutor struct { + mock.Mock +} + +func (c *fakeCmdExecutor) RunCommand(cmdTemplate string, data interface{}) error { + c.Called(cmdTemplate, data) + return nil +} + var ct Testing func init() { @@ -417,3 +426,47 @@ func TestChart_HasCIValuesFile(t *testing.T) { }) } } + +func TestChart_AdditionalCommandsAreRun(t *testing.T) { + type testData struct { + name string + cfg config.Configuration + callsRunCommand int + } + + testCases := []testData{ + { + name: "no additional commands", + cfg: config.Configuration{}, + callsRunCommand: 0, + }, + { + name: "one command", + cfg: config.Configuration{ + AdditionalCommands: []string{"helm unittest --helm3 -f tests/*.yaml {{ .Path }}"}, + }, + callsRunCommand: 1, + }, + { + name: "multiple commands", + cfg: config.Configuration{ + AdditionalCommands: []string{"echo", "helm unittest --helm3 -f tests/*.yaml {{ .Path }}"}, + }, + callsRunCommand: 2, + }, + } + + for _, testData := range testCases { + t.Run(testData.name, func(t *testing.T) { + fakeCmdExecutor := new(fakeCmdExecutor) + fakeCmdExecutor.On("RunCommand", mock.Anything, mock.Anything).Return(nil) + + ct := newTestingMock(testData.cfg) + ct.cmdExecutor = fakeCmdExecutor + + ct.LintChart(&Chart{}) + + fakeCmdExecutor.AssertNumberOfCalls(t, "RunCommand", testData.callsRunCommand) + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ec62abe..a88d402 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,6 +50,7 @@ type Configuration struct { ValidateMaintainers bool `mapstructure:"validate-maintainers"` ValidateChartSchema bool `mapstructure:"validate-chart-schema"` ValidateYaml bool `mapstructure:"validate-yaml"` + AdditionalCommands []string `mapstructure:"additional-commands"` CheckVersionIncrement bool `mapstructure:"check-version-increment"` ProcessAllCharts bool `mapstructure:"all"` Charts []string `mapstructure:"charts"` diff --git a/pkg/tool/cmdexecutor.go b/pkg/tool/cmdexecutor.go new file mode 100644 index 0000000..0449088 --- /dev/null +++ b/pkg/tool/cmdexecutor.go @@ -0,0 +1,38 @@ +package tool + +import ( + "strings" + "text/template" + + "github.com/mattn/go-shellwords" +) + +type ProcessExecutor interface { + RunProcess(executable string, execArgs ...interface{}) error +} + +type CmdTemplateExecutor struct { + exec ProcessExecutor +} + +func NewCmdTemplateExecutor(exec ProcessExecutor) CmdTemplateExecutor { + return CmdTemplateExecutor{ + exec: exec, + } +} + +func (t CmdTemplateExecutor) RunCommand(cmdTemplate string, data interface{}) error { + var template = template.Must(template.New("command").Parse(cmdTemplate)) + var b strings.Builder + if err := template.Execute(&b, data); err != nil { + return err + } + rendered := b.String() + + words, err := shellwords.Parse(rendered) + if err != nil { + return err + } + name, args := words[0], words[1:] + return t.exec.RunProcess(name, args) +} diff --git a/pkg/tool/cmdexecutor_test.go b/pkg/tool/cmdexecutor_test.go new file mode 100644 index 0000000..21cb6ea --- /dev/null +++ b/pkg/tool/cmdexecutor_test.go @@ -0,0 +1,76 @@ +package tool + +import ( + "testing" + + "github.com/stretchr/testify/mock" +) + +type fakeProcessExecutor struct { + mock.Mock +} + +func (c *fakeProcessExecutor) RunProcess(executable string, execArgs ...interface{}) error { + c.Called(executable, execArgs[0]) + return nil +} + +func TestCmdTemplateExecutor_RunCommand(t *testing.T) { + type args struct { + cmdTemplate string + data interface{} + } + tests := []struct { + name string + args args + wantErr bool + validate func(t *testing.T, executor *fakeProcessExecutor) + }{ + { + name: "command without arguments", + args: args{ + cmdTemplate: "echo", + data: nil, + }, + validate: func(t *testing.T, executor *fakeProcessExecutor) { + executor.AssertCalled(t, "RunProcess", "echo", []string{}) + }, + wantErr: false, + }, + { + name: "command with args", + args: args{ + cmdTemplate: "echo hello world", + }, + validate: func(t *testing.T, executor *fakeProcessExecutor) { + executor.AssertCalled(t, "RunProcess", "echo", []string{"hello", "world"}) + }, + wantErr: false, + }, + { + name: "interpolate args", + args: args{ + cmdTemplate: "helm unittest --helm3 -f tests/*.yaml {{ .Path }}", + data: map[string]string{"Path": "charts/my-chart"}, + }, + validate: func(t *testing.T, executor *fakeProcessExecutor) { + executor.AssertCalled(t, "RunProcess", "helm", []string{"unittest", "--helm3", "-f", "tests/*.yaml", "charts/my-chart"}) + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + processExecutor := new(fakeProcessExecutor) + processExecutor.On("RunProcess", mock.Anything, mock.Anything).Return(nil) + templateExecutor := CmdTemplateExecutor{ + exec: processExecutor, + } + if err := templateExecutor.RunCommand(tt.args.cmdTemplate, tt.args.data); (err != nil) != tt.wantErr { + t.Errorf("RunCommand() error = %v, wantErr %v", err, tt.wantErr) + } + tt.validate(t, processExecutor) + + }) + } +}