diff --git a/Gopkg.lock b/Gopkg.lock index 36bd88d..ba7d19d 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -41,6 +41,14 @@ revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" version = "v1.4.7" +[[projects]] + digest = "1:8e3bd93036b4a925fe2250d3e4f38f21cadb8ef623561cd80c3c50c114b13201" + name = "github.com/hashicorp/errwrap" + packages = ["."] + pruneopts = "" + revision = "8a6fb523712970c966eefc6b39ed2c5e74880354" + version = "v1.0.0" + [[projects]] digest = "1:05334858a0cfb538622a066e065287f63f42bee26a7fda93a789674225057201" name = "github.com/hashicorp/go-cleanhttp" @@ -49,6 +57,14 @@ revision = "e8ab9daed8d1ddd2d3c4efba338fe2eeae2e4f18" version = "v0.5.0" +[[projects]] + digest = "1:72308fdd6d5ef61106a95be7ca72349a5565809042b6426a3cfb61d99483b824" + name = "github.com/hashicorp/go-multierror" + packages = ["."] + pruneopts = "" + revision = "886a7fbe3eb1c874d46f623bfa70af45f425b3d1" + version = "v1.0.0" + [[projects]] digest = "1:776139dc18d63ef223ffaca5d8e9a3057174890f84393d3c881e934100b66dbc" name = "github.com/hashicorp/go-retryablehttp" @@ -251,6 +267,7 @@ input-imports = [ "github.com/MakeNowJust/heredoc", "github.com/Masterminds/semver", + "github.com/hashicorp/go-multierror", "github.com/hashicorp/go-retryablehttp", "github.com/mitchellh/go-homedir", "github.com/pkg/errors", diff --git a/ct/cmd/install.go b/ct/cmd/install.go index e08a61d..3c9d6bb 100644 --- a/ct/cmd/install.go +++ b/ct/cmd/install.go @@ -31,13 +31,17 @@ func newInstallCmd() *cobra.Command { Use: "install", Short: "Install and test a chart", Long: heredoc.Doc(` - Run 'helm install' and ' helm test' on + Run 'helm install', 'helm test', and optionally 'helm upgrade' on * changed charts (default) * specific charts (--charts) * all charts (--all) - in given chart directories. + in given chart directories. If upgrade (--upgrade) is true, then this + command will validate that 'helm test' passes for the following upgrade paths: + + * previous chart revision => current chart version (if non-breaking SemVer change) + * current chart version => current chart version Charts may have multiple custom values files matching the glob pattern '*-values.yaml' in a directory named 'ci' in the root of the chart's @@ -61,12 +65,15 @@ func addInstallFlags(flags *flag.FlagSet) { flags.String("helm-extra-args", "", heredoc.Doc(` Additional arguments for Helm. Must be passed as a single quoted string (e.g. "--timeout 500 --tiller-namespace tiller"`)) + flags.Bool("upgrade", false, heredoc.Doc(` + Whether to test an in-place upgrade of each chart from its previous revision if the + current version should not introduce a breaking change according to the SemVer spec`)) flags.String("namespace", "", heredoc.Doc(` Namespace to install the release(s) into. If not specified, each release will be - installed in its own randomly generated namespace.`)) + installed in its own randomly generated namespace`)) flags.String("release-label", "app.kubernetes.io/instance", heredoc.Doc(` The label to be used as a selector when inspecting resources created by charts. - This is only used if namespace is specified.`)) + This is only used if namespace is specified`)) } func install(cmd *cobra.Command, args []string) { diff --git a/ct/cmd/listChanged.go b/ct/cmd/listChanged.go index ebe3a11..404aa35 100644 --- a/ct/cmd/listChanged.go +++ b/ct/cmd/listChanged.go @@ -16,9 +16,10 @@ package cmd import ( "fmt" - "github.com/MakeNowJust/heredoc" "os" + "github.com/MakeNowJust/heredoc" + "github.com/helm/chart-testing/pkg/chart" "github.com/helm/chart-testing/pkg/config" "github.com/spf13/cobra" diff --git a/doc/ct.md b/doc/ct.md index 8759f07..c35b80b 100644 --- a/doc/ct.md +++ b/doc/ct.md @@ -26,4 +26,4 @@ in given chart directories. * [ct list-changed](ct_list-changed.md) - List changed charts * [ct version](ct_version.md) - Print version information -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/doc/ct_install.md b/doc/ct_install.md index b88f9d1..46c704c 100644 --- a/doc/ct_install.md +++ b/doc/ct_install.md @@ -4,13 +4,17 @@ Install and test a chart ### Synopsis -Run 'helm install' and ' helm test' on +Run 'helm install', 'helm test', and optionally 'helm upgrade' on * changed charts (default) * specific charts (--charts) * all charts (--all) -in given chart directories. +in given chart directories. If upgrade (--upgrade) is true, then this +command will validate that 'helm test' passes for the following upgrade paths: + +* previous chart revision => current chart version (if non-breaking SemVer change) +* current chart version => current chart version Charts may have multiple custom values files matching the glob pattern '*-values.yaml' in a directory named 'ci' in the root of the chart's @@ -51,15 +55,17 @@ ct install [flags] multiple times or separate values with commas -h, --help help for install --namespace string Namespace to install the release(s) into. If not specified, each release will be - installed in its own randomly generated namespace. + installed in its own randomly generated namespace --release-label string The label to be used as a selector when inspecting resources created by charts. - This is only used if namespace is specified. (default "app.kubernetes.io/instance") + This is only used if namespace is specified (default "app.kubernetes.io/instance") --remote string The name of the Git remote used to identify changed charts (default "origin") --target-branch string The name of the target branch used to identify changed charts (default "master") + --upgrade Whether to test an in-place upgrade of each chart from its previous revision if the + current version should not introduce a breaking change according to the SemVer spec ``` ### SEE ALSO * [ct](ct.md) - The Helm chart testing tool -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/doc/ct_lint-and-install.md b/doc/ct_lint-and-install.md index 7001bdf..0946559 100644 --- a/doc/ct_lint-and-install.md +++ b/doc/ct_lint-and-install.md @@ -46,11 +46,13 @@ ct lint-and-install [flags] is searched in the current directory, '$HOME/.ct', and '/etc/ct', in that order --namespace string Namespace to install the release(s) into. If not specified, each release will be - installed in its own randomly generated namespace. + installed in its own randomly generated namespace --release-label string The label to be used as a selector when inspecting resources created by charts. - This is only used if namespace is specified. (default "app.kubernetes.io/instance") + This is only used if namespace is specified (default "app.kubernetes.io/instance") --remote string The name of the Git remote used to identify changed charts (default "origin") --target-branch string The name of the target branch used to identify changed charts (default "master") + --upgrade Whether to test an in-place upgrade of each chart from its previous revision if the + current version should not introduce a breaking change according to the SemVer spec --validate-chart-schema Enable schema validation of 'Chart.yaml' using Yamale (default: true) (default true) --validate-maintainers Enable validation of maintainer account names in chart.yml (default: true). Works for GitHub, GitLab, and Bitbucket (default true) @@ -61,4 +63,4 @@ ct lint-and-install [flags] * [ct](ct.md) - The Helm chart testing tool -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/doc/ct_lint.md b/doc/ct_lint.md index e5bf868..c9db76d 100644 --- a/doc/ct_lint.md +++ b/doc/ct_lint.md @@ -65,4 +65,4 @@ ct lint [flags] * [ct](ct.md) - The Helm chart testing tool -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/doc/ct_list-changed.md b/doc/ct_list-changed.md index 76d0bad..1bfac50 100644 --- a/doc/ct_list-changed.md +++ b/doc/ct_list-changed.md @@ -28,4 +28,4 @@ ct list-changed [flags] * [ct](ct.md) - The Helm chart testing tool -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/doc/ct_version.md b/doc/ct_version.md index fd908ea..ac62340 100644 --- a/doc/ct_version.md +++ b/doc/ct_version.md @@ -20,4 +20,4 @@ ct version [flags] * [ct](ct.md) - The Helm chart testing tool -###### Auto generated by spf13/cobra on 31-Jan-2019 +###### Auto generated by spf13/cobra on 26-Feb-2019 diff --git a/pkg/chart/chart.go b/pkg/chart/chart.go index fead0c7..2c3b7d1 100644 --- a/pkg/chart/chart.go +++ b/pkg/chart/chart.go @@ -34,17 +34,27 @@ import ( // // Show returns the contents of file on the specified remote/branch. // +// AddWorkingTree checks out the contents of the repository at a commit ref into the specified path. +// +// RemoveWorkingTree removes the working tree at the specified path. +// // MergeBase returns the SHA1 of the merge base of commit1 and commit2. // // ListChangedFilesInDirs diffs commit against HEAD and returns changed files for the specified dirs. // // GetUrlForRemote returns the repo URL for the specified remote. +// +// ValidateRepository checks that the current working directory is a valid git repository, +// and returns nil if valid. type Git interface { FileExistsOnBranch(file string, remote string, branch string) bool Show(file string, remote string, branch string) (string, error) + AddWorkingTree(path string, ref string) error + RemoveWorkingTree(path string) error MergeBase(commit1 string, commit2 string) (string, error) ListChangedFilesInDirs(commit string, dirs ...string) ([]string, error) GetUrlForRemote(remote string) (string, error) + ValidateRepository() error } // Helm is the interface that wraps Helm operations @@ -61,6 +71,11 @@ type Git interface { // InstallWithValues runs `helm install` for the given chart using the specified values file. // Pass a zero value for valuesFile in order to run install without specifying a values file. // +// Upgrade runs `helm upgrade` against an existing release, and re-uses the previously computed values. +// +// Test runs `helm test` against an existing release. Set the cleanup argument to true in order +// to clean up test pods created by helm after the test command completes. +// // DeleteRelease purges the specified Helm release. type Helm interface { Init() error @@ -68,7 +83,8 @@ type Helm interface { BuildDependencies(chart string) error LintWithValues(chart string, valuesFile string) error InstallWithValues(chart string, valuesFile string, namespace string, release string) error - Test(release string) error + Upgrade(chart string, release string) error + Test(release string, cleanup bool) error DeleteRelease(release string) } @@ -161,7 +177,7 @@ type TestResult struct { func NewTesting(config config.Configuration) Testing { procExec := exec.NewProcessExecutor(config.Debug) extraArgs := strings.Fields(config.HelmExtraArgs) - testing := Testing{ + return Testing{ config: config, helm: tool.NewHelm(procExec, extraArgs), git: tool.NewGit(procExec), @@ -171,7 +187,14 @@ func NewTesting(config config.Configuration) Testing { directoryLister: util.DirectoryLister{}, chartUtils: util.ChartUtils{}, } - return testing +} + +const ctPreviousRevisionTree = "ct_previous_revision" + +// computePreviousRevisionPath converts any file or directory path to the same path in the +// previous revision's working tree. +func computePreviousRevisionPath(dir string) string { + return path.Join(ctPreviousRevisionTree, dir) } func (t *Testing) processCharts(action func(chart string, valuesFiles []string) TestResult) ([]TestResult, error) { @@ -222,6 +245,23 @@ func (t *Testing) processCharts(action func(chart string, valuesFiles []string) TestResults: results, } + // Checkout previous chart revisions and build their dependencies + if t.config.Upgrade { + mergeBase, err := t.computeMergeBase() + if err != nil { + return results, errors.Wrap(err, "Error identifying merge base") + } + t.git.AddWorkingTree(ctPreviousRevisionTree, mergeBase) + defer t.git.RemoveWorkingTree(ctPreviousRevisionTree) + + for _, chart := range charts { + if err := t.helm.BuildDependencies(computePreviousRevisionPath(chart)); err != nil { + // Only print error (don't exit) if building dependencies for previous revision fails. + fmt.Println(errors.Wrapf(err, "Error building dependencies for previous revision of chart '%s'\n", chart)) + } + } + } + for _, chart := range charts { valuesFiles := t.FindValuesFilesForCI(chart) @@ -336,10 +376,57 @@ func (t *Testing) LintChart(chart string, valuesFiles []string) TestResult { // InstallChart installs the specified chart into a new namespace, waits for resources to become ready, and eventually // uninstalls it and deletes the namespace again. func (t *Testing) InstallChart(chart string, valuesFiles []string) TestResult { - fmt.Printf("Installing chart '%s'...\n", chart) + var result TestResult + if t.config.Upgrade { + // Test upgrade from previous version + result = t.UpgradeChart(chart) + if result.Error != nil { + return result + } + // Test upgrade of current version (related: https://github.com/helm/chart-testing/issues/19) + if err := t.doUpgrade(chart, chart, true); err != nil { + result.Error = err + return result + } + } + + result = TestResult{Chart: chart} + if err := t.doInstall(chart); err != nil { + result.Error = err + } + + return result +} + +// UpgradeChart tests in-place upgrades of the specified chart relative to its previous revisions. If the +// initial install or helm test of a previous revision of the chart fails, that release is ignored and no +// error will be returned. If the latest revision of the chart introduces a potentially breaking change +// according to the SemVer specification, upgrade testing will be skipped. +func (t *Testing) UpgradeChart(chart string) TestResult { result := TestResult{Chart: chart} + breakingChangeAllowed, err := t.checkBreakingChangeAllowed(chart) + + if breakingChangeAllowed { + if err != nil { + fmt.Println(errors.Wrap(err, fmt.Sprintf("Skipping upgrade test of '%s' because", chart))) + } + return result + } else if err != nil { + fmt.Printf("Error comparing chart versions for '%s'\n", chart) + result.Error = err + return result + } + + result.Error = t.doUpgrade(computePreviousRevisionPath(chart), chart, false) + return result +} + +func (t *Testing) doInstall(chart string) error { + fmt.Printf("Installing chart '%s'...\n", chart) + valuesFiles := t.FindValuesFilesForCI(chart) + // Test with defaults if no values files are specified. if len(valuesFiles) == 0 { valuesFiles = append(valuesFiles, "") @@ -349,45 +436,104 @@ func (t *Testing) InstallChart(chart string, valuesFiles []string) TestResult { if valuesFile != "" { fmt.Printf("\nInstalling chart with values file '%s'...\n\n", valuesFile) } - var namespace, release, releaseSelector string // Use anonymous function. Otherwise deferred calls would pile up // and be executed in reverse order after the loop. fun := func() error { - if t.config.Namespace != "" { - namespace = t.config.Namespace - release, _ = util.CreateInstallParams(chart, t.config.BuildId) - releaseSelector = fmt.Sprintf("%s=%s", t.config.ReleaseLabel, release) - } else { - release, namespace = util.CreateInstallParams(chart, t.config.BuildId) - defer t.kubectl.DeleteNamespace(namespace) - } - - defer t.helm.DeleteRelease(release) - defer t.PrintPodDetailsAndLogs(namespace, releaseSelector) + namespace, release, releaseSelector, cleanup := t.generateInstallConfig(chart) + defer cleanup() if err := t.helm.InstallWithValues(chart, valuesFile, namespace, release); err != nil { - result.Error = err return err } - if err := t.kubectl.WaitForDeployments(namespace, releaseSelector); err != nil { - result.Error = err - return err - } - if err := t.helm.Test(release); err != nil { - result.Error = err - return err - } - - return nil + return t.testRelease(release, namespace, releaseSelector, false) } if err := fun(); err != nil { - break + return err } } - return result + return nil +} + +func (t *Testing) doUpgrade(oldChart, newChart string, oldChartMustPass bool) error { + fmt.Printf("Testing upgrades of chart '%s' relative to previous revision '%s'...\n", newChart, oldChart) + valuesFiles := t.FindValuesFilesForCI(oldChart) + if len(valuesFiles) == 0 { + valuesFiles = append(valuesFiles, "") + } + for _, valuesFile := range valuesFiles { + if valuesFile != "" { + fmt.Printf("\nInstalling chart '%s' with values file '%s'...\n\n", oldChart, valuesFile) + } + + // Use anonymous function. Otherwise deferred calls would pile up + // and be executed in reverse order after the loop. + fun := func() error { + namespace, release, releaseSelector, cleanup := t.generateInstallConfig(oldChart) + defer cleanup() + + // Install previous version of chart. If installation fails, ignore this release. + if err := t.helm.InstallWithValues(oldChart, valuesFile, namespace, release); err != nil { + if oldChartMustPass { + return err + } + fmt.Println(errors.Wrap(err, fmt.Sprintf("Upgrade testing for release '%s' skipped because of previous revision installation error", release))) + return nil + } + if err := t.testRelease(release, namespace, releaseSelector, true); err != nil { + if oldChartMustPass { + return err + } + fmt.Println(errors.Wrap(err, fmt.Sprintf("Upgrade testing for release '%s' skipped because of previous revision testing error", release))) + return nil + } + + if err := t.helm.Upgrade(oldChart, release); err != nil { + return err + } + + return t.testRelease(release, namespace, releaseSelector, false) + } + + if err := fun(); err != nil { + return err + } + } + + return nil +} + +func (t *Testing) testRelease(release, namespace, releaseSelector string, cleanupHelmTests bool) error { + if err := t.kubectl.WaitForDeployments(namespace, releaseSelector); err != nil { + return err + } + if err := t.helm.Test(release, cleanupHelmTests); err != nil { + return err + } + return nil +} + +func (t *Testing) generateInstallConfig(chart string) (namespace, release, releaseSelector string, cleanup func()) { + if t.config.Namespace != "" { + namespace = t.config.Namespace + release, _ = util.CreateInstallParams(chart, t.config.BuildId) + releaseSelector = fmt.Sprintf("%s=%s", t.config.ReleaseLabel, release) + cleanup = func() { + t.PrintPodDetailsAndLogs(namespace, releaseSelector) + t.helm.DeleteRelease(release) + } + } else { + release, namespace = util.CreateInstallParams(chart, t.config.BuildId) + cleanup = func() { + t.PrintPodDetailsAndLogs(namespace, releaseSelector) + t.helm.DeleteRelease(release) + t.kubectl.DeleteNamespace(namespace) + } + } + + return } // LintAndInstallChart first lints and then installs the specified chart. @@ -418,15 +564,24 @@ func (t *Testing) FindValuesFilesForCI(chart string) []string { return matches } +func (t *Testing) computeMergeBase() (string, error) { + err := t.git.ValidateRepository() + if err != nil { + return "", fmt.Errorf("Must be in a git repository") + } + return t.git.MergeBase(fmt.Sprintf("%s/%s", t.config.Remote, t.config.TargetBranch), "HEAD") +} + // ComputeChangedChartDirectories takes the merge base of HEAD and the configured remote and target branch and computes a // slice of changed charts from that in the configured chart directories excluding those configured to be excluded. func (t *Testing) ComputeChangedChartDirectories() ([]string, error) { cfg := t.config - mergeBase, err := t.git.MergeBase(fmt.Sprintf("%s/%s", cfg.Remote, cfg.TargetBranch), "HEAD") + mergeBase, err := t.computeMergeBase() if err != nil { - return nil, errors.Wrap(err, "Error identifying merge base") + return nil, err } + allChangedChartFiles, err := t.git.ListChangedFilesInDirs(mergeBase, cfg.ChartDirs...) if err != nil { return nil, errors.Wrap(err, "Error creating diff") @@ -509,6 +664,24 @@ func (t *Testing) CheckVersionIncrement(chart string) error { return nil } +func (t *Testing) checkBreakingChangeAllowed(chart string) (allowed bool, err error) { + oldVersion, err := t.GetOldChartVersion(chart) + if err != nil { + return false, err + } + if oldVersion == "" { + // new chart, skip upgrade check + return true, fmt.Errorf("chart has no previous revision") + } + + newVersion, err := t.GetNewChartVersion(chart) + if err != nil { + return false, err + } + + return util.BreakingChangeAllowed(oldVersion, newVersion) +} + // GetOldChartVersion gets the version of the old Chart.yaml file from the target branch. func (t *Testing) GetOldChartVersion(chart string) (string, error) { cfg := t.config diff --git a/pkg/chart/chart_test.go b/pkg/chart/chart_test.go index 605a9c3..c1f2ea3 100644 --- a/pkg/chart/chart_test.go +++ b/pkg/chart/chart_test.go @@ -51,10 +51,22 @@ func (g fakeGit) ListChangedFilesInDirs(commit string, dirs ...string) ([]string }, nil } +func (g fakeGit) AddWorkingTree(path string, ref string) error { + return nil +} + +func (g fakeGit) RemoveWorkingTree(path string) error { + return nil +} + func (g fakeGit) GetUrlForRemote(remote string) (string, error) { return "git@github.com/helm/chart-testing", nil } +func (g fakeGit) ValidateRepository() error { + return nil +} + type fakeAccountValidator struct{} func (v fakeAccountValidator) Validate(repoDomain string, account string) error { @@ -86,7 +98,10 @@ func (h fakeHelm) LintWithValues(chart string, valuesFile string) error { return func (h fakeHelm) InstallWithValues(chart string, valuesFile string, namespace string, release string) error { return nil } -func (h fakeHelm) Test(release string) error { +func (h fakeHelm) Upgrade(chart string, release string) error { + return nil +} +func (h fakeHelm) Test(release string, cleanup bool) error { return nil } func (h fakeHelm) DeleteRelease(release string) {} @@ -99,9 +114,12 @@ func init() { ChartDirs: []string{"test_charts", "."}, } - fakeMockLinter := new(fakeLinter) + ct = newTestingMock(cfg) +} - ct = Testing{ +func newTestingMock(cfg config.Configuration) Testing { + fakeMockLinter := new(fakeLinter) + return Testing{ config: cfg, directoryLister: util.DirectoryLister{}, git: fakeGit{}, @@ -274,3 +292,55 @@ func TestLintYamlValidation(t *testing.T) { runTests(true, 2, 0) runTests(false, 0, 0) } + +func TestGenerateInstallConfig(t *testing.T) { + type testData struct { + name string + cfg config.Configuration + chartDir string + } + + testCases := []testData{ + { + "custom namespace", + config.Configuration{ + Namespace: "default", + ReleaseLabel: "app.kubernetes.io/instance", + }, + "test_charts/bar", + }, + { + "random namespace", + config.Configuration{ + ReleaseLabel: "app.kubernetes.io/instance", + }, + "test_charts/bar", + }, + { + "long chart name", + config.Configuration{ + ReleaseLabel: "app.kubernetes.io/instance", + }, + "test_charts/barbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbarbar", + }, + } + + for _, testData := range testCases { + t.Run(testData.name, func(t *testing.T) { + ct := newTestingMock(testData.cfg) + + namespace, release, releaseSelector, _ := ct.generateInstallConfig(testData.chartDir) + assert.NotEqual(t, "", namespace) + assert.NotEqual(t, "", release) + assert.True(t, len(release) < 64, "release should be less than 64 chars") + assert.True(t, len(namespace) < 64, "namespace should be less than 64 chars") + if testData.cfg.Namespace != "" { + assert.Equal(t, testData.cfg.Namespace, namespace) + assert.Equal(t, fmt.Sprintf("%s=%s", testData.cfg.ReleaseLabel, release), releaseSelector) + } else { + assert.Equal(t, "", releaseSelector) + assert.Contains(t, namespace, release) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index bfd2558..8d8bbad 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -56,6 +56,7 @@ type Configuration struct { HelmExtraArgs string `mapstructure:"helm-extra-args"` HelmRepoExtraArgs []string `mapstructure:"helm-repo-extra-args"` Debug bool `mapstructure:"debug"` + Upgrade bool `mapstructure:"upgrade"` Namespace string `mapstructure:"namespace"` ReleaseLabel string `mapstructure:"release-label"` } @@ -97,6 +98,9 @@ func LoadConfiguration(cfgFile string, cmd *cobra.Command, printConfig bool) (*C } } + isLint := strings.Contains(cmd.Use, "lint") + isInstall := strings.Contains(cmd.Use, "install") + cfg := &Configuration{} if err := v.Unmarshal(cfg); err != nil { return nil, errors.Wrap(err, "Error unmarshaling configuration") @@ -110,7 +114,13 @@ func LoadConfiguration(cfgFile string, cmd *cobra.Command, printConfig bool) (*C return nil, errors.New("specifying '--namespace' without '--release-label' is not allowed") } - isLint := strings.Contains(cmd.Use, "lint") + // Disable upgrade (this does some expensive dependency building on previous revisions) + // when neither "install" nor "lint-and-install" have not been specified. + cfg.Upgrade = isInstall && cfg.Upgrade + if (cfg.TargetBranch == "" || cfg.Remote == "") && cfg.Upgrade { + return nil, errors.New("specifying '--upgrade=true' without '--target-branch' or '--remote', is not allowed") + } + chartYamlSchemaPath := cfg.ChartYamlSchema if chartYamlSchemaPath == "" { var err error diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2fbdf98..2c7a2ff 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -30,7 +30,9 @@ func TestUnmarshalJson(t *testing.T) { } func loadAndAssertConfigFromFile(t *testing.T, configFile string) { - cfg, _ := LoadConfiguration(configFile, &cobra.Command{}, true) + cfg, _ := LoadConfiguration(configFile, &cobra.Command{ + Use: "install", + }, true) require.Equal(t, "origin", cfg.Remote) require.Equal(t, "master", cfg.TargetBranch) @@ -47,6 +49,7 @@ func loadAndAssertConfigFromFile(t *testing.T, configFile string) { require.Equal(t, []string{"stable", "incubator"}, cfg.ChartDirs) require.Equal(t, []string{"common"}, cfg.ExcludedCharts) require.Equal(t, "--timeout 300", cfg.HelmExtraArgs) + require.Equal(t, true, cfg.Upgrade) require.Equal(t, "default", cfg.Namespace) require.Equal(t, "release", cfg.ReleaseLabel) } diff --git a/pkg/config/test_config.json b/pkg/config/test_config.json index ded1516..2f1ab79 100644 --- a/pkg/config/test_config.json +++ b/pkg/config/test_config.json @@ -24,6 +24,7 @@ "common" ], "helm-extra-args": "--timeout 300", + "upgrade": true, "namespace": "default", "release-label": "release" } diff --git a/pkg/config/test_config.yaml b/pkg/config/test_config.yaml index 00c298c..051a93c 100644 --- a/pkg/config/test_config.yaml +++ b/pkg/config/test_config.yaml @@ -19,5 +19,6 @@ chart-dirs: excluded-charts: - common helm-extra-args: --timeout 300 +upgrade: true namespace: default release-label: release diff --git a/pkg/tool/git.go b/pkg/tool/git.go index 40034e7..e60c825 100644 --- a/pkg/tool/git.go +++ b/pkg/tool/git.go @@ -38,6 +38,14 @@ func (g Git) FileExistsOnBranch(file string, remote string, branch string) bool return err == nil } +func (g Git) AddWorkingTree(path string, ref string) error { + return g.exec.RunProcess("git", "worktree", "add", path, ref) +} + +func (g Git) RemoveWorkingTree(path string) error { + return g.exec.RunProcess("git", "worktree", "remove", path) +} + func (g Git) Show(file string, remote string, branch string) (string, error) { fileSpec := fmt.Sprintf("%s/%s:%s", remote, branch, file) return g.exec.RunProcessAndCaptureOutput("git", "show", fileSpec) @@ -62,3 +70,7 @@ func (g Git) ListChangedFilesInDirs(commit string, dirs ...string) ([]string, er func (g Git) GetUrlForRemote(remote string) (string, error) { return g.exec.RunProcessAndCaptureOutput("git", "ls-remote", "--get-url", remote) } + +func (g Git) ValidateRepository() error { + return g.exec.RunProcess("git", "rev-parse", "--is-inside-work-tree") +} diff --git a/pkg/tool/helm.go b/pkg/tool/helm.go index 970a7b8..6093f62 100644 --- a/pkg/tool/helm.go +++ b/pkg/tool/helm.go @@ -67,8 +67,17 @@ func (h Helm) InstallWithValues(chart string, valuesFile string, namespace strin return nil } -func (h Helm) Test(release string) error { - return h.exec.RunProcess("helm", "test", release, h.extraArgs) +func (h Helm) Upgrade(chart string, release string) error { + if err := h.exec.RunProcess("helm", "upgrade", release, chart, "--reuse-values", + "--wait", h.extraArgs); err != nil { + return err + } + + return nil +} + +func (h Helm) Test(release string, cleanup bool) error { + return h.exec.RunProcess("helm", "test", release, h.extraArgs, fmt.Sprintf("--cleanup=%t", cleanup)) } func (h Helm) DeleteRelease(release string) { diff --git a/pkg/tool/kubectl.go b/pkg/tool/kubectl.go index 97c287b..035d5a7 100644 --- a/pkg/tool/kubectl.go +++ b/pkg/tool/kubectl.go @@ -151,21 +151,16 @@ func (k Kubectl) WaitForDeployments(namespace string, selector string) error { // 'kubectl rollout status' does not return a non-zero exit code when rollouts fail. // We, thus, need to double-check here. - - pods, err := k.GetPodsforDeployment(namespace, deployment) + // + // Just after rollout, pods from the previous deployment revision may still be in a + // terminating state. + unavailable, err := k.exec.RunProcessAndCaptureOutput("kubectl", "get", "deployment", deployment, "--namespace", namespace, "--output", + `jsonpath={.status.unavailableReplicas}`) if err != nil { return err } - for _, pod := range pods { - pod = strings.Trim(pod, "'") - ready, err := k.exec.RunProcessAndCaptureOutput("kubectl", "get", "pod", pod, "--namespace", namespace, "--output", - `jsonpath={.status.conditions[?(@.type=="Ready")].status}`) - if err != nil { - return err - } - if ready != "True" { - return errors.New(fmt.Sprintf("Pods '%s' did not reach ready state!", pod)) - } + if unavailable != "" && unavailable != "0" { + return fmt.Errorf("%s replicas unavailable", unavailable) } } diff --git a/pkg/util/util.go b/pkg/util/util.go index 1f7b67c..b9792db 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -16,9 +16,6 @@ package util import ( "fmt" - "github.com/Masterminds/semver" - "github.com/pkg/errors" - "gopkg.in/yaml.v2" "io/ioutil" "math/rand" "net" @@ -27,6 +24,11 @@ import ( "path/filepath" "strings" "time" + + "github.com/Masterminds/semver" + multierror "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "gopkg.in/yaml.v2" ) const chars = "1234567890abcdefghijklmnopqrstuvwxyz" @@ -177,6 +179,33 @@ func CompareVersions(left string, right string) (int, error) { return leftVersion.Compare(rightVersion), nil } +func BreakingChangeAllowed(left string, right string) (bool, error) { + leftVersion, err := semver.NewVersion(left) + if err != nil { + return false, errors.Wrap(err, "Error parsing semantic version") + } + rightVersion, err := semver.NewVersion(right) + if err != nil { + return false, errors.Wrap(err, "Error parsing semantic version") + } + + constraintOp := "^" + if leftVersion.Major() == 0 { + constraintOp = "~" + } + c, err := semver.NewConstraint(fmt.Sprintf("%s %s", constraintOp, leftVersion.String())) + if err != nil { + return false, errors.Wrap(err, "Error parsing semantic version constraint") + } + + minor, reasons := c.Validate(rightVersion) + if len(reasons) > 0 { + err = multierror.Append(err, reasons...) + } + + return !minor, err +} + func CreateInstallParams(chart string, buildId string) (release string, namespace string) { release = path.Base(chart) namespace = release diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index bcf5205..8547449 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -16,8 +16,9 @@ package util import ( "fmt" - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" ) func TestFlatten(t *testing.T) { @@ -88,3 +89,30 @@ func TestTruncateLeft(t *testing.T) { }) } } + +func TestBreakingChangeAllowed(t *testing.T) { + var testDataSlice = []struct { + left string + right string + breaking bool + }{ + {"0.1.0", "0.1.0", false}, + {"0.1.0", "0.1.1", false}, + {"0.1.0", "0.2.0", true}, + {"0.1.0", "0.2.1", true}, + {"1.2.3", "1.2.3", false}, + {"1.2.3", "1.2.4", false}, + {"1.2.3", "1.3.0", false}, + {"1.2.3", "2.0.0", true}, + {"1.2.3", "10.0.0", true}, + {"foo", "1.0.0", false}, // version parse error + {"1.0.0", "bar", false}, // version parse error + } + + for index, testData := range testDataSlice { + t.Run(string(index), func(t *testing.T) { + actual, _ := BreakingChangeAllowed(testData.left, testData.right) + assert.Equal(t, testData.breaking, actual, fmt.Sprintf("input: %s,%s\n", testData.left, testData.right)) + }) + } +}