diff --git a/cmd/helm3.go b/cmd/helm3.go new file mode 100644 index 00000000..29495dd4 --- /dev/null +++ b/cmd/helm3.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "io/ioutil" + "os" + "os/exec" + "strconv" +) + +func getRelease(release, namespace string) ([]byte, error) { + args := []string{"get", "manifest", release} + if namespace != "" { + args = append(args, "--namespace", namespace) + } + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return cmd.Output() +} + +func getHooks(release, namespace string) ([]byte, error) { + args := []string{"get", "hooks", release} + if namespace != "" { + args = append(args, "--namespace", namespace) + } + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return cmd.Output() +} + +func getRevision(release string, revision int, namespace string) ([]byte, error) { + args := []string{"get", "manifest", release, "--revision", strconv.Itoa(revision)} + if namespace != "" { + args = append(args, "--namespace", namespace) + } + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return cmd.Output() +} + +func getChart(release, namespace string) (string, error) { + args := []string{"get", release, "--template", "{{.Release.Chart.Name}}"} + if namespace != "" { + args = append(args, "--namespace", namespace) + } + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func (d *diffCmd) template() ([]byte, error) { + flags := []string{} + if d.devel { + flags = append(flags, "--devel") + } + if d.noHooks { + flags = append(flags, "--no-hooks") + } + if d.chartVersion != "" { + flags = append(flags, "--version", d.chartVersion) + } + if d.namespace != "" { + flags = append(flags, "--namespace", d.namespace) + } + if !d.resetValues { + if d.reuseValues { + tmpfile, err := ioutil.TempFile("", "existing-values") + if err != nil { + return nil, err + } + defer os.Remove(tmpfile.Name()) + flags = append(flags, "--values", tmpfile.Name()) + } + for _, value := range d.values { + flags = append(flags, "--set", value) + } + for _, stringValue := range d.stringValues { + flags = append(flags, "--set-string", stringValue) + } + for _, valueFile := range d.valueFiles { + flags = append(flags, "--values", valueFile) + } + for _, fileValue := range d.fileValues { + flags = append(flags, "--set-file", fileValue) + } + } + + args := []string{"template", d.release, d.chart} + args = append(args, flags...) + cmd := exec.Command(os.Getenv("HELM_BIN"), args...) + return cmd.Output() +} + +func (d *diffCmd) existingValues(f *os.File) error { + cmd := exec.Command(os.Getenv("HELM_BIN"), "get", "values", d.release, "--all") + defer f.Close() + cmd.Stdout = f + return cmd.Run() +} diff --git a/cmd/helpers.go b/cmd/helpers.go index 28ceaf47..e8a40ecb 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -16,6 +16,9 @@ const ( tlsCaCertDefault = "$HELM_HOME/ca.pem" tlsCertDefault = "$HELM_HOME/cert.pem" tlsKeyDefault = "$HELM_HOME/key.pem" + + helm2TestSuccessHook = "test-success" + helm3TestHook = "test" ) var ( @@ -24,6 +27,10 @@ var ( DefaultHelmHome = filepath.Join(homedir.HomeDir(), ".helm") ) +func isHelm3() bool { + return os.Getenv("TILLER_HOST") == "" +} + func addCommonCmdOptions(f *flag.FlagSet) { settings.AddFlagsTLS(f) settings.InitTLS(f) diff --git a/cmd/release.go b/cmd/release.go index 4635cd03..b2fbc563 100644 --- a/cmd/release.go +++ b/cmd/release.go @@ -60,6 +60,9 @@ func releaseCmd() *cobra.Command { } diff.releases = args[0:] + if isHelm3() { + return diff.differentiateHelm3() + } if diff.client == nil { diff.client = createHelmClient() } @@ -73,11 +76,57 @@ func releaseCmd() *cobra.Command { releaseCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") releaseCmd.SuggestionsMinimumDistance = 1 - addCommonCmdOptions(releaseCmd.Flags()) + if !isHelm3() { + addCommonCmdOptions(releaseCmd.Flags()) + } return releaseCmd } +func (d *release) differentiateHelm3() error { + namespace := os.Getenv("HELM_NAMESPACE") + excludes := []string{helm3TestHook, helm2TestSuccessHook} + if d.includeTests { + excludes = []string{} + } + releaseResponse1, err := getRelease(d.releases[0], namespace) + if err != nil { + return err + } + releaseChart1, err := getChart(d.releases[0], namespace) + if err != nil { + return err + } + + releaseResponse2, err := getRelease(d.releases[1], namespace) + if err != nil { + return err + } + releaseChart2, err := getChart(d.releases[1], namespace) + if err != nil { + return err + } + + if releaseChart1 == releaseChart2 { + seenAnyChanges := diff.Releases( + manifest.Parse(string(releaseResponse1), namespace, excludes...), + manifest.Parse(string(releaseResponse2), namespace, excludes...), + d.suppressedKinds, + d.outputContext, + os.Stdout) + + if d.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + } else { + fmt.Printf("Error : Incomparable Releases \n Unable to compare releases from two different charts \"%s\", \"%s\". \n try helm diff release --help to know more \n", releaseChart1, releaseChart2) + } + return nil +} + func (d *release) differentiate() error { releaseResponse1, err := d.client.ReleaseContent(d.releases[0]) diff --git a/cmd/revision.go b/cmd/revision.go index 22be25ec..46ba5f43 100644 --- a/cmd/revision.go +++ b/cmd/revision.go @@ -70,6 +70,9 @@ func revisionCmd() *cobra.Command { diff.release = args[0] diff.revisions = args[1:] + if isHelm3() { + return diff.differentiateHelm3() + } if diff.client == nil { diff.client = createHelmClient() } @@ -83,11 +86,78 @@ func revisionCmd() *cobra.Command { revisionCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") revisionCmd.SuggestionsMinimumDistance = 1 - addCommonCmdOptions(revisionCmd.Flags()) + if !isHelm3() { + addCommonCmdOptions(revisionCmd.Flags()) + } return revisionCmd } +func (d *revision) differentiateHelm3() error { + namespace := os.Getenv("HELM_NAMESPACE") + excludes := []string{helm3TestHook, helm2TestSuccessHook} + if d.includeTests { + excludes = []string{} + } + switch len(d.revisions) { + case 1: + releaseResponse, err := getRelease(d.release, namespace) + + if err != nil { + return err + } + + revision, _ := strconv.Atoi(d.revisions[0]) + revisionResponse, err := getRevision(d.release, revision, namespace) + if err != nil { + return err + } + + diff.Manifests( + manifest.Parse(string(revisionResponse), namespace, excludes...), + manifest.Parse(string(releaseResponse), namespace, excludes...), + d.suppressedKinds, + d.outputContext, + os.Stdout) + + case 2: + revision1, _ := strconv.Atoi(d.revisions[0]) + revision2, _ := strconv.Atoi(d.revisions[1]) + if revision1 > revision2 { + revision1, revision2 = revision2, revision1 + } + + revisionResponse1, err := getRevision(d.release, revision1, namespace) + if err != nil { + return prettyError(err) + } + + revisionResponse2, err := getRevision(d.release, revision2, namespace) + if err != nil { + return prettyError(err) + } + + seenAnyChanges := diff.Manifests( + manifest.Parse(string(revisionResponse1), namespace, excludes...), + manifest.Parse(string(revisionResponse2), namespace, excludes...), + d.suppressedKinds, + d.outputContext, + os.Stdout) + + if d.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + + default: + return errors.New("Invalid Arguments") + } + + return nil +} + func (d *revision) differentiate() error { switch len(d.revisions) { diff --git a/cmd/rollback.go b/cmd/rollback.go index 59f89966..74d0fea9 100644 --- a/cmd/rollback.go +++ b/cmd/rollback.go @@ -60,6 +60,10 @@ func rollbackCmd() *cobra.Command { diff.release = args[0] diff.revisions = args[1:] + if isHelm3() { + return diff.backcastHelm3() + } + if diff.client == nil { diff.client = createHelmClient() } @@ -74,11 +78,51 @@ func rollbackCmd() *cobra.Command { rollbackCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") rollbackCmd.SuggestionsMinimumDistance = 1 - addCommonCmdOptions(rollbackCmd.Flags()) + if !isHelm3() { + addCommonCmdOptions(rollbackCmd.Flags()) + } return rollbackCmd } +func (d *rollback) backcastHelm3() error { + namespace := os.Getenv("HELM_NAMESPACE") + excludes := []string{helm3TestHook, helm2TestSuccessHook} + if d.includeTests { + excludes = []string{} + } + // get manifest of the latest release + releaseResponse, err := getRelease(d.release, namespace) + + if err != nil { + return err + } + + // get manifest of the release to rollback + revision, _ := strconv.Atoi(d.revisions[0]) + revisionResponse, err := getRevision(d.release, revision, namespace) + if err != nil { + return err + } + + // create a diff between the current manifest and the version of the manifest that a user is intended to rollback + seenAnyChanges := diff.Manifests( + manifest.Parse(string(releaseResponse), namespace, excludes...), + manifest.Parse(string(revisionResponse), namespace, excludes...), + d.suppressedKinds, + d.outputContext, + os.Stdout) + + if d.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + + return nil +} + func (d *rollback) backcast() error { // get manifest of the latest release diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 7dde2ec2..e41c8b01 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "os/exec" "strings" "github.com/spf13/cobra" @@ -43,7 +44,9 @@ perform. ` func newChartCommand() *cobra.Command { - diff := diffCmd{} + diff := diffCmd{ + namespace: os.Getenv("HELM_NAMESPACE"), + } cmd := &cobra.Command{ Use: "upgrade [flags] [RELEASE] [CHART]", @@ -66,6 +69,9 @@ func newChartCommand() *cobra.Command { diff.release = args[0] diff.chart = args[1] + if isHelm3() { + return diff.runHelm3() + } if diff.client == nil { diff.client = createHelmClient() } @@ -89,14 +95,75 @@ func newChartCommand() *cobra.Command { f.BoolVar(&diff.devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.") f.StringArrayVar(&diff.suppressedKinds, "suppress", []string{}, "allows suppression of the values listed in the diff output") f.IntVarP(&diff.outputContext, "context", "C", -1, "output NUM lines of context around changes") - f.StringVar(&diff.namespace, "namespace", "default", "namespace to assume the release to be installed into") + if !isHelm3() { + f.StringVar(&diff.namespace, "namespace", "default", "namespace to assume the release to be installed into") + } - addCommonCmdOptions(f) + if !isHelm3() { + addCommonCmdOptions(f) + } return cmd } +func (d *diffCmd) runHelm3() error { + releaseManifest, err := getRelease(d.release, d.namespace) + + var newInstall bool + if err != nil && strings.Contains(string(err.(*exec.ExitError).Stderr), "release: not found") { + if d.allowUnreleased { + fmt.Printf("********************\n\n\tRelease was not present in Helm. Diff will show entire contents as new.\n\n********************\n") + err = nil + } else { + fmt.Printf("********************\n\n\tRelease was not present in Helm. Include the `--allow-unreleased` to perform diff without exiting in error.\n\n********************\n") + return err + } + + } + if err != nil { + return err + } + + installManifest, err := d.template() + if err != nil { + return err + } + + currentSpecs := make(map[string]*manifest.MappingResult) + if !newInstall { + if !d.noHooks { + hooks, err := getHooks(d.release, d.namespace) + if err != nil { + return err + } + releaseManifest = append(releaseManifest, hooks...) + } + if d.includeTests { + currentSpecs = manifest.Parse(string(releaseManifest), d.namespace) + } else { + currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, helm3TestHook, helm2TestSuccessHook) + } + } + var newSpecs map[string]*manifest.MappingResult + if d.includeTests { + newSpecs = manifest.Parse(string(installManifest), d.namespace) + } else { + newSpecs = manifest.Parse(string(installManifest), d.namespace, helm3TestHook, helm2TestSuccessHook) + } + + seenAnyChanges := diff.Manifests(currentSpecs, newSpecs, d.suppressedKinds, d.outputContext, os.Stdout) + + if d.detailedExitCode && seenAnyChanges { + return Error{ + error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), + Code: 2, + } + } + + return nil +} + func (d *diffCmd) run() error { if d.chartVersion == "" && d.devel { d.chartVersion = ">0.0.0-0" diff --git a/manifest/parse.go b/manifest/parse.go index a2287c86..912ab077 100644 --- a/manifest/parse.go +++ b/manifest/parse.go @@ -11,6 +11,10 @@ import ( "k8s.io/helm/pkg/proto/hapi/release" ) +const ( + hookAnnotation = "helm.sh/hook" +) + var yamlSeperator = []byte("\n---\n") // MappingResult to store result of diff @@ -24,8 +28,9 @@ type metadata struct { APIVersion string `yaml:"apiVersion"` Kind string Metadata struct { - Namespace string - Name string + Namespace string + Name string + Annotations map[string]string } } @@ -78,7 +83,7 @@ func ParseRelease(release *release.Release, includeTests bool) map[string]*Mappi } // Parse parses manifest strings into MappingResult -func Parse(manifest string, defaultNamespace string) map[string]*MappingResult { +func Parse(manifest string, defaultNamespace string, excludedHooks ...string) map[string]*MappingResult { scanner := bufio.NewScanner(strings.NewReader(manifest)) scanner.Split(scanYamlSpecs) //Allow for tokens (specs) up to 1M in size @@ -89,8 +94,8 @@ func Parse(manifest string, defaultNamespace string) map[string]*MappingResult { result := make(map[string]*MappingResult) for scanner.Scan() { - content := scanner.Text() - if strings.TrimSpace(content) == "" { + content := strings.TrimSpace(scanner.Text()) + if content == "" { continue } var parsedMetadata metadata @@ -100,7 +105,10 @@ func Parse(manifest string, defaultNamespace string) map[string]*MappingResult { //Skip content without any metadata. It is probably a template that //only contains comments in the current state. - if (metadata{}) == parsedMetadata { + if parsedMetadata.APIVersion == "" && parsedMetadata.Kind == "" { + continue + } + if isHook(parsedMetadata, excludedHooks...) { continue } @@ -124,6 +132,15 @@ func Parse(manifest string, defaultNamespace string) map[string]*MappingResult { return result } +func isHook(metadata metadata, hooks ...string) bool { + for _, hook := range hooks { + if metadata.Metadata.Annotations[hookAnnotation] == hook { + return true + } + } + return false +} + func isTestHook(hookEvents []release.Hook_Event) bool { for _, event := range hookEvents { if event == release.Hook_RELEASE_TEST_FAILURE || event == release.Hook_RELEASE_TEST_SUCCESS { diff --git a/manifest/parse_test.go b/manifest/parse_test.go index 9c4cb3cf..731b5bc2 100644 --- a/manifest/parse_test.go +++ b/manifest/parse_test.go @@ -39,6 +39,26 @@ func TestPodNamespace(t *testing.T) { ) } +func TestPodHook(t *testing.T) { + spec, err := ioutil.ReadFile("testdata/pod_hook.yaml") + require.NoError(t, err) + + require.Equal(t, + []string{"default, nginx, Pod (v1)"}, + foundObjects(Parse(string(spec), "default")), + ) + + require.Equal(t, + []string{"default, nginx, Pod (v1)"}, + foundObjects(Parse(string(spec), "default", "test-success")), + ) + + require.Equal(t, + []string{}, + foundObjects(Parse(string(spec), "default", "test")), + ) +} + func TestDeployV1(t *testing.T) { spec, err := ioutil.ReadFile("testdata/deploy_v1.yaml") require.NoError(t, err) diff --git a/manifest/testdata/pod_hook.yaml b/manifest/testdata/pod_hook.yaml new file mode 100644 index 00000000..1e7ac447 --- /dev/null +++ b/manifest/testdata/pod_hook.yaml @@ -0,0 +1,15 @@ + +--- +# Source: nginx/pod.yaml +apiVersion: v1 +kind: Pod +metadata: + name: nginx + annotations: + helm.sh/hook: test +spec: + containers: + - name: nginx + image: nginx:1.7.9 + ports: + - containerPort: 80