├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ ├── ci.yaml │ ├── cleanup.yaml │ ├── lint-sh.yaml │ ├── lint.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── Dockerfile.release ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── error.go ├── helm.go ├── helm3.go ├── helm3_test.go ├── helpers.go ├── helpers_test.go ├── options.go ├── release.go ├── revision.go ├── rollback.go ├── root.go ├── upgrade.go ├── upgrade_test.go └── version.go ├── diff ├── constant.go ├── diff.go ├── diff_test.go ├── report.go ├── report_test.go └── testdata │ └── customTemplate.tpl ├── go.mod ├── go.sum ├── install-binary.sh ├── main.go ├── main_test.go ├── manifest ├── generate.go ├── parse.go ├── parse_test.go ├── testdata │ ├── configmaplist_v1.yaml │ ├── deploy_v1.yaml │ ├── deploy_v1beta1.yaml │ ├── empty.yaml │ ├── list.yaml │ ├── pod.yaml │ ├── pod_hook.yaml │ ├── pod_namespace.yaml │ ├── pod_no_release_annotations.yaml │ ├── pod_release_annotations.yaml │ ├── secret_immutable.yaml │ └── secretlist_v1.yaml ├── util.go └── util_test.go ├── plugin.yaml ├── scripts ├── release.sh ├── setup-apimachinery.sh ├── update-gofmt.sh ├── verify-gofmt.sh ├── verify-govet.sh └── verify-staticcheck.sh ├── staticcheck.conf └── testdata └── Dockerfile.install /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.sh] 4 | indent_style = space 5 | indent_size = 2 6 | max_line_length = 120 7 | trim_trailing_whitespace = true 8 | shell_variant = posix 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "gomod" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 30 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: stale 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | build: 12 | name: "Build & Test" 13 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-go@v5 18 | with: 19 | go-version-file: 'go.mod' 20 | 21 | - name: Install dependencies 22 | run: make bootstrap 23 | 24 | - name: Run unit tests 25 | run: make test 26 | 27 | - name: Verify installation 28 | run: | 29 | mkdir -p helmhome 30 | make install HELM_HOME=helmhome 31 | helmhome/plugins/helm-diff/bin/diff version 32 | 33 | helm-install: 34 | name: helm install 35 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 36 | needs: [build] 37 | runs-on: ${{ matrix.os }} 38 | container: ${{ matrix.container }} 39 | continue-on-error: ${{ matrix.experimental }} 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | os: [ubuntu-latest, macos-latest, windows-latest] 44 | shell: [ default ] 45 | experimental: [ false ] 46 | helm-version: [ v3.18.2, v3.17.3 ] 47 | include: 48 | - os: windows-latest 49 | shell: wsl 50 | experimental: false 51 | helm-version: v3.18.2 52 | - os: windows-latest 53 | shell: cygwin 54 | experimental: false 55 | helm-version: v3.18.2 56 | - os: ubuntu-latest 57 | container: alpine 58 | shell: sh 59 | experimental: false 60 | helm-version: v3.18.2 61 | - os: windows-latest 62 | shell: wsl 63 | experimental: false 64 | helm-version: v3.17.3 65 | - os: windows-latest 66 | shell: cygwin 67 | experimental: false 68 | helm-version: v3.17.3 69 | - os: ubuntu-latest 70 | container: alpine 71 | shell: sh 72 | experimental: false 73 | helm-version: v3.17.3 74 | 75 | steps: 76 | - name: Disable autocrlf 77 | if: "contains(matrix.os, 'windows-latest')" 78 | run: |- 79 | git config --global core.autocrlf false 80 | git config --global core.eol lf 81 | 82 | - uses: actions/checkout@v4 83 | 84 | - name: Setup Helm 85 | uses: azure/setup-helm@v4 86 | with: 87 | version: ${{ matrix.helm-version }} 88 | 89 | - name: Setup WSL 90 | if: "contains(matrix.shell, 'wsl')" 91 | uses: Vampire/setup-wsl@v5 92 | 93 | - name: Setup Cygwin 94 | if: "contains(matrix.shell, 'cygwin')" 95 | uses: egor-tensin/setup-cygwin@v4 96 | with: 97 | platform: x64 98 | 99 | - name: helm plugin install 100 | run: helm plugin install . 101 | 102 | integration-tests: 103 | name: Integration Tests 104 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 105 | needs: [build] 106 | runs-on: ubuntu-latest 107 | strategy: 108 | matrix: 109 | include: 110 | # Helm maintains the latest minor version only and therefore each Helmfile version supports 2 Helm minor versions. 111 | # That's why we cover only 2 Helm minor versions in this matrix. 112 | # See https://github.com/helmfile/helmfile/pull/286#issuecomment-1250161182 for more context. 113 | - helm-version: v3.18.2 114 | - helm-version: v3.17.3 115 | steps: 116 | - uses: engineerd/setup-kind@v0.6.2 117 | with: 118 | skipClusterLogsExport: true 119 | 120 | 121 | - uses: actions/checkout@v4 122 | 123 | - name: Setup Helm 124 | uses: azure/setup-helm@v4 125 | with: 126 | version: ${{ matrix.helm-version }} 127 | 128 | - name: helm plugin install 129 | run: helm plugin install . 130 | 131 | - name: helm create helm-diff 132 | run: helm create helm-diff 133 | 134 | - name: helm diff upgrade --install helm-diff ./helm-diff 135 | run: helm diff upgrade --install helm-diff ./helm-diff 136 | 137 | - name: helm upgrade -i helm-diff ./helm-diff 138 | run: helm upgrade -i helm-diff ./helm-diff 139 | 140 | - name: helm diff upgrade -C 3 --set replicaCount=2 --install helm-diff ./helm-diff 141 | run: helm diff upgrade -C 3 --set replicaCount=2 --install helm-diff ./helm-diff 142 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Cleanup 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - closed 8 | 9 | jobs: 10 | cleanup-cache: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: 'Cleanup PR cache' 14 | run: | 15 | gh extension install actions/gh-actions-cache 16 | 17 | REPO="${{ github.repository }}" 18 | BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge" 19 | 20 | echo "Fetching list of cache key" 21 | cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) 22 | 23 | ## Setting this to not fail the workflow while deleting cache keys. 24 | set +e 25 | echo "Deleting caches..." 26 | for cacheKey in $cacheKeysForPR 27 | do 28 | gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm 29 | done 30 | echo "Done" 31 | shell: bash 32 | env: 33 | GH_TOKEN: ${{ github.token }} 34 | -------------------------------------------------------------------------------- /.github/workflows/lint-sh.yaml: -------------------------------------------------------------------------------- 1 | name: Lint sh 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: ['install-binary.sh'] 7 | pull_request: 8 | branches: [master] 9 | paths: ['install-binary.sh'] 10 | 11 | jobs: 12 | lint-sh: 13 | name: Lint install-binary.sh 14 | runs-on: ubuntu-latest 15 | if: "!contains(github.event.head_commit.message, '[ci skip]')" 16 | continue-on-error: true 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: luizm/action-sh-checker@v0.9.0 20 | with: 21 | sh_checker_exclude: 'scripts' 22 | sh_checker_checkbashisms_enable: true 23 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lint 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | paths-ignore: [ '**.md' ] 8 | pull_request: 9 | branches: [ master ] 10 | paths-ignore: [ '**.md' ] 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 10 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-go@v5 20 | with: 21 | go-version-file: 'go.mod' 22 | - uses: golangci/golangci-lint-action@v8 23 | with: 24 | version: v2.1.6 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 17 | run: echo "flags=--snapshot" >> $GITHUB_ENV 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - 24 | name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: 'go.mod' 28 | - 29 | name: Run GoReleaser 30 | uses: goreleaser/goreleaser-action@v6 31 | with: 32 | distribution: goreleaser 33 | version: '~> v1' 34 | args: release --clean ${{ env.flags }} 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | bin/ 3 | build/ 4 | release/ 5 | .envrc 6 | .idea 7 | docker-run-release-cache/ 8 | .vscode/ 9 | /cover.out 10 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | tests: true 5 | output: 6 | formats: 7 | text: 8 | path: stdout 9 | print-linter-name: true 10 | print-issued-lines: true 11 | colors: false 12 | linters: 13 | default: none 14 | enable: 15 | - bodyclose 16 | - copyloopvar 17 | - depguard 18 | - errcheck 19 | - errorlint 20 | - funlen 21 | - gocognit 22 | - goconst 23 | - govet 24 | - ineffassign 25 | - misspell 26 | - nakedret 27 | - reassign 28 | - revive 29 | - staticcheck 30 | - testifylint 31 | - unconvert 32 | - unparam 33 | - unused 34 | - usestdlibvars 35 | - whitespace 36 | settings: 37 | staticcheck: 38 | checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-ST1005", "-QF1001", "-QF1008"] 39 | depguard: 40 | rules: 41 | main: 42 | files: 43 | - $all 44 | allow: 45 | - $gostd 46 | - github.com/Masterminds/semver/v3 47 | - github.com/aryann/difflib 48 | - github.com/databus23/helm-diff/v3 49 | - github.com/evanphx/json-patch/v5 50 | - github.com/gonvenience/bunt 51 | - github.com/gonvenience/ytbx 52 | - github.com/google/go-cmp/cmp 53 | - github.com/homeport/dyff/pkg/dyff 54 | - github.com/json-iterator/go 55 | - github.com/mgutz/ansi 56 | - github.com/spf13/cobra 57 | - github.com/spf13/pflag 58 | - golang.org/x/term 59 | - gopkg.in/yaml.v2 60 | - github.com/stretchr/testify/require 61 | - helm.sh/helm/v3 62 | - k8s.io/api/core/v1 63 | - k8s.io/apiextensions-apiserver 64 | - k8s.io/apimachinery 65 | - k8s.io/cli-runtime 66 | - k8s.io/client-go 67 | - sigs.k8s.io/yaml 68 | deny: 69 | - pkg: github.com/sirupsen/logrus 70 | desc: not allowed 71 | - pkg: github.com/pkg/errors 72 | desc: Should be replaced by standard lib errors package 73 | dogsled: 74 | max-blank-identifiers: 2 75 | dupl: 76 | threshold: 100 77 | errcheck: 78 | check-type-assertions: false 79 | check-blank: false 80 | funlen: 81 | lines: 280 82 | statements: 140 83 | gocognit: 84 | min-complexity: 100 85 | goconst: 86 | min-len: 3 87 | min-occurrences: 8 88 | gocritic: 89 | settings: 90 | captLocal: 91 | paramsOnly: true 92 | gocyclo: 93 | min-complexity: 30 94 | godox: 95 | keywords: 96 | - TODO 97 | - BUG 98 | - FIXME 99 | - NOTE 100 | - OPTIMIZE 101 | - HACK 102 | gosec: 103 | excludes: 104 | - G104 105 | govet: 106 | disable: 107 | - shadow 108 | settings: 109 | printf: 110 | funcs: 111 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof 112 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf 113 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf 114 | - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf 115 | lll: 116 | line-length: 120 117 | tab-width: 1 118 | misspell: 119 | locale: US 120 | ignore-rules: 121 | - GitLab 122 | nakedret: 123 | max-func-lines: 30 124 | prealloc: 125 | simple: true 126 | range-loops: true 127 | for-loops: false 128 | revive: 129 | confidence: 0.8 130 | severity: warning 131 | unparam: 132 | check-exported: false 133 | whitespace: 134 | multi-if: false 135 | multi-func: false 136 | wsl: 137 | strict-append: true 138 | allow-assign-and-call: true 139 | allow-multiline-assign: true 140 | force-case-trailing-whitespace: 0 141 | allow-trailing-comment: false 142 | allow-cuddle-declarations: false 143 | exclusions: 144 | generated: lax 145 | rules: 146 | - linters: 147 | - dupl 148 | - errcheck 149 | - funlen 150 | - gocyclo 151 | - gosec 152 | path: _test\.go 153 | - linters: 154 | - lll 155 | source: '^//go:generate ' 156 | paths: 157 | - third_party$ 158 | - builtin$ 159 | - examples$ 160 | issues: 161 | max-issues-per-linter: 0 162 | max-same-issues: 0 163 | new: false 164 | formatters: 165 | enable: 166 | - gci 167 | - gofmt 168 | - goimports 169 | settings: 170 | gci: 171 | sections: 172 | - standard 173 | - default 174 | - prefix(github.com/databus23/helm-diff/v3) 175 | gofmt: 176 | simplify: true 177 | exclusions: 178 | generated: lax 179 | paths: 180 | - third_party$ 181 | - builtin$ 182 | - examples$ 183 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # To test this manually, run: 2 | # go install github.com/goreleaser/goreleaser@latest 3 | # goreleaser --snapshot --clean 4 | # for f in dist/helm-diff*.tgz; do echo Testing $f...; tar tzvf $f; done 5 | project_name: helm-diff 6 | builds: 7 | - id: default 8 | main: . 9 | binary: bin/diff 10 | env: 11 | - CGO_ENABLED=0 12 | flags: 13 | - -trimpath 14 | ldflags: 15 | - -X github.com/databus23/helm-diff/v3/cmd.Version={{ .Version }} 16 | goos: 17 | - freebsd 18 | - darwin 19 | - linux 20 | - windows 21 | goarch: 22 | - amd64 23 | - arm64 24 | 25 | archives: 26 | - id: default 27 | builds: 28 | - default 29 | format: tgz 30 | name_template: '{{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ .Arch }}' 31 | wrap_in_directory: diff 32 | files: 33 | - README.md 34 | - plugin.yaml 35 | - LICENSE 36 | changelog: 37 | use: github-native 38 | 39 | release: 40 | prerelease: auto 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Before submitting a pull request, I'd encourage you to test it yourself. 2 | 3 | To do so, you need to run the plugin indirectly or directly. 4 | 5 | **Indirect** Install the plugin locally and run it via helm: 6 | 7 | ``` 8 | $ helm plugin uninstall diff 9 | 10 | $ helm plugin list 11 | #=> Make sure that the previous installation of helm-diff has unisntalled 12 | 13 | $ make install 14 | 15 | $ helm plugin list 16 | #=> Make sure that the version of helm-diff built from your branch has instaled 17 | 18 | $ helm diff upgrade ... (snip) 19 | ``` 20 | 21 | **Direct** Build the plugin binary and execute it with a few helm-specific environment variables: 22 | 23 | ``` 24 | $ go build . 25 | 26 | $ HELM_NAMESPACE=default \ 27 | HELM_BIN=helm372 \ 28 | ./helm-diff upgrade foo $CHART \ 29 | --set argo-cd.nameOverride=testtest \ 30 | --install 31 | ``` 32 | -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 2 | 3 | # See https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian-ubuntu-linux-raspberry-pi-os-apt 4 | # for the latest gh install instructions when the below didn't work 5 | 6 | RUN type -p curl >/dev/null || (apt update && apt install curl -y) 7 | 8 | RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ 9 | && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ 10 | && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ 11 | && apt update \ 12 | && apt install gh -y 13 | 14 | ARG HELM_DIFF_UID 15 | 16 | RUN adduser \ 17 | --gecos "Helm Diff" \ 18 | --disabled-password \ 19 | -u "$HELM_DIFF_UID" \ 20 | helm-diff-releaser \ 21 | --shell /bin/sh 22 | 23 | USER helm-diff-releaser 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | HELM_HOME ?= $(shell helm env HELM_DATA_HOME) 2 | VERSION := $(shell sed -n -e 's/version:[ "]*\([^"]*\).*/\1/p' plugin.yaml) 3 | 4 | HELM_3_PLUGINS := $(shell helm env HELM_PLUGINS) 5 | 6 | PKG:= github.com/databus23/helm-diff/v3 7 | LDFLAGS := -X $(PKG)/cmd.Version=$(VERSION) 8 | 9 | GO ?= go 10 | 11 | .PHONY: format 12 | format: 13 | test -z "$$(find . -type f -o -name '*.go' -exec gofmt -d {} + | tee /dev/stderr)" || \ 14 | test -z "$$(find . -type f -o -name '*.go' -exec gofmt -w {} + | tee /dev/stderr)" 15 | 16 | .PHONY: install 17 | install: build 18 | mkdir -p $(HELM_HOME)/plugins/helm-diff/bin 19 | cp bin/diff $(HELM_HOME)/plugins/helm-diff/bin 20 | cp plugin.yaml $(HELM_HOME)/plugins/helm-diff/ 21 | 22 | .PHONY: install/helm3 23 | install/helm3: build 24 | mkdir -p $(HELM_3_PLUGINS)/helm-diff/bin 25 | cp bin/diff $(HELM_3_PLUGINS)/helm-diff/bin 26 | cp plugin.yaml $(HELM_3_PLUGINS)/helm-diff/ 27 | 28 | .PHONY: lint 29 | lint: 30 | scripts/update-gofmt.sh 31 | scripts/verify-gofmt.sh 32 | scripts/verify-govet.sh 33 | scripts/verify-staticcheck.sh 34 | 35 | .PHONY: build 36 | build: lint 37 | mkdir -p bin/ 38 | go build -v -o bin/diff -ldflags="$(LDFLAGS)" 39 | 40 | .PHONY: test 41 | test: 42 | go test -v ./... -coverprofile cover.out -race 43 | go tool cover -func cover.out 44 | 45 | .PHONY: bootstrap 46 | bootstrap: 47 | go mod download 48 | command -v staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest 49 | 50 | .PHONY: docker-run-release 51 | docker-run-release: export pkg=/go/src/github.com/databus23/helm-diff 52 | docker-run-release: 53 | git checkout master 54 | git push 55 | # needed to avoid "failed to initialize build cache at /.cache/go-build: mkdir /.cache: permission denied" 56 | mkdir -p docker-run-release-cache 57 | # uid needs to be set to avoid "error obtaining VCS status: exit status 128" 58 | # Also, there needs to be a valid Linux user with the uid in the container- 59 | # otherwise git-push will fail. 60 | docker build -t helm-diff-release -f Dockerfile.release \ 61 | --build-arg HELM_DIFF_UID=$(shell id -u) --load . 62 | docker run -it --rm -e GITHUB_TOKEN \ 63 | -v ${SSH_AUTH_SOCK}:/tmp/ssh-agent.sock -e SSH_AUTH_SOCK=/tmp/ssh-agent.sock \ 64 | -v $(shell pwd):$(pkg) \ 65 | -v $(shell pwd)/docker-run-release-cache:/.cache \ 66 | -w $(pkg) helm-diff-release make bootstrap release 67 | 68 | .PHONY: dist 69 | dist: export COPYFILE_DISABLE=1 #teach OSX tar to not put ._* files in tar archive 70 | dist: export CGO_ENABLED=0 71 | dist: 72 | rm -rf build/diff/* release/* 73 | mkdir -p build/diff/bin release/ 74 | cp README.md LICENSE plugin.yaml build/diff 75 | GOOS=linux GOARCH=amd64 $(GO) build -o build/diff/bin/diff -trimpath -ldflags="$(LDFLAGS)" 76 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-linux-amd64.tgz diff/ 77 | GOOS=linux GOARCH=arm64 $(GO) build -o build/diff/bin/diff -trimpath -ldflags="$(LDFLAGS)" 78 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-linux-arm64.tgz diff/ 79 | GOOS=freebsd GOARCH=amd64 $(GO) build -o build/diff/bin/diff -trimpath -ldflags="$(LDFLAGS)" 80 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-freebsd-amd64.tgz diff/ 81 | GOOS=darwin GOARCH=amd64 $(GO) build -o build/diff/bin/diff -trimpath -ldflags="$(LDFLAGS)" 82 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-macos-amd64.tgz diff/ 83 | GOOS=darwin GOARCH=arm64 $(GO) build -o build/diff/bin/diff -trimpath -ldflags="$(LDFLAGS)" 84 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-macos-arm64.tgz diff/ 85 | rm build/diff/bin/diff 86 | GOOS=windows GOARCH=amd64 $(GO) build -o build/diff/bin/diff.exe -trimpath -ldflags="$(LDFLAGS)" 87 | tar -C build/ -zcvf $(CURDIR)/release/helm-diff-windows-amd64.tgz diff/ 88 | 89 | .PHONY: release 90 | release: lint dist 91 | scripts/release.sh v$(VERSION) 92 | 93 | # Test for the plugin installation with `helm plugin install -v THIS_BRANCH` works 94 | # Useful for verifying modified `install-binary.sh` still works against various environments 95 | .PHONY: test-plugin-installation 96 | test-plugin-installation: 97 | docker build -f testdata/Dockerfile.install . 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Helm Diff Plugin 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/databus23/helm-diff)](https://goreportcard.com/report/github.com/databus23/helm-diff) 3 | [![GoDoc](https://godoc.org/github.com/databus23/helm-diff?status.svg)](https://godoc.org/github.com/databus23/helm-diff) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/databus23/helm-diff/blob/master/LICENSE) 5 | 6 | This is a Helm plugin giving you a preview of what a `helm upgrade` would change. 7 | It basically generates a diff between the latest deployed version of a release 8 | and a `helm upgrade --debug --dry-run`. This can also be used to compare two 9 | revisions/versions of your helm release. 10 | 11 | 12 | 13 | ## Install 14 | 15 | ### Using Helm plugin manager (> 2.3.x) 16 | 17 | ```shell 18 | helm plugin install https://github.com/databus23/helm-diff 19 | ``` 20 | 21 | ### Pre Helm 2.3.0 Installation 22 | Pick a release tarball from the [releases](https://github.com/databus23/helm-diff/releases) page. 23 | 24 | Unpack the tarball in your helm plugins directory (`$(helm home)/plugins`). 25 | 26 | E.g. 27 | ``` 28 | curl -L $TARBALL_URL | tar -C $(helm home)/plugins -xzv 29 | ``` 30 | 31 | ### From Source 32 | #### Prerequisites 33 | - GoLang `>= 1.21` 34 | 35 | Make sure you do not have a version of `helm-diff` installed. You can remove it by running `helm plugin uninstall diff` 36 | 37 | #### Installation Steps 38 | The first step is to download the repository and enter the directory. You can do this via `git clone` or downloading and extracting the release. If you clone via git, remember to checkout the latest tag for the latest release. 39 | 40 | Next, install the plugin into helm. 41 | 42 | ```bash 43 | make install/helm3 44 | ``` 45 | 46 | 47 | ## Usage 48 | 49 | ``` 50 | The Helm Diff Plugin 51 | 52 | * Shows a diff explaining what a helm upgrade would change: 53 | This fetches the currently deployed version of a release 54 | and compares it to a local chart plus values. This can be 55 | used to visualize what changes a helm upgrade will perform. 56 | 57 | * Shows a diff explaining what had changed between two revisions: 58 | This fetches previously deployed versions of a release 59 | and compares them. This can be used to visualize what changes 60 | were made during revision change. 61 | 62 | * Shows a diff explaining what a helm rollback would change: 63 | This fetches the currently deployed version of a release 64 | and compares it to the previously deployed version of the release, that you 65 | want to rollback. This can be used to visualize what changes a 66 | helm rollback will perform. 67 | 68 | Usage: 69 | diff [flags] 70 | diff [command] 71 | 72 | Available Commands: 73 | completion Generate the autocompletion script for the specified shell 74 | release Shows diff between release's manifests 75 | revision Shows diff between revision's manifests 76 | rollback Show a diff explaining what a helm rollback could perform 77 | upgrade Show a diff explaining what a helm upgrade would change. 78 | version Show version of the helm diff plugin 79 | 80 | Flags: 81 | --allow-unreleased enables diffing of releases that are not yet deployed via Helm 82 | -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions 83 | --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 84 | -C, --context int output NUM lines of context around changes (default -1) 85 | --detailed-exitcode return a non-zero exit code when there are changes 86 | --devel use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored. 87 | --disable-openapi-validation disables rendered templates validation against the Kubernetes OpenAPI Schema 88 | --disable-validation disables rendered templates validation against the Kubernetes cluster you are currently pointing to. This is the same validation performed on an install 89 | --dry-run string[="client"] --dry-run, --dry-run=client, or --dry-run=true disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation. --dry-run=server enables the cluster access with helm-get and the lookup template function. 90 | --enable-dns enable DNS lookups when rendering templates 91 | -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched 92 | -h, --help help for diff 93 | --include-crds include CRDs in the diffing 94 | --include-tests enable the diffing of the helm test hooks 95 | --insecure-skip-tls-verify skip tls certificate checks for the chart download 96 | --install enables diffing of releases that are not yet deployed via Helm (equivalent to --allow-unreleased, added to match "helm upgrade --install" command 97 | --kube-version string Kubernetes version used for Capabilities.KubeVersion 98 | --kubeconfig string This flag is ignored, to allow passing of this top level flag to helm 99 | --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 100 | --no-hooks disable diffing of hooks 101 | --normalize-manifests normalize manifests before running diff to exclude style differences from the output 102 | --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") 103 | --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path 104 | --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) 105 | --repo string specify the chart repository url to locate the requested chart 106 | --reset-then-reuse-values reset the values to the ones built into the chart, apply the last release's values and merge in any new values. If '--reset-values' or '--reuse-values' is specified, this is ignored 107 | --reset-values reset the values to the ones built into the chart and merge in any new values 108 | --reuse-values reuse the last release's values and merge in any new values. If '--reset-values' is specified, this is ignored 109 | --set stringArray set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) 110 | --set-file stringArray set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2) 111 | --set-json stringArray set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2) 112 | --set-literal stringArray set STRING literal values on the command line 113 | --set-string stringArray set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) 114 | --show-secrets do not redact secret values in the output 115 | --show-secrets-decoded decode secret values in the output 116 | --skip-schema-validation skip validation of the rendered manifests against the Kubernetes OpenAPI schema 117 | --strip-trailing-cr strip trailing carriage return on input 118 | --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') 119 | --suppress-output-line-regex stringArray a regex to suppress diff output lines that match 120 | -q, --suppress-secrets suppress secrets in the output 121 | --take-ownership if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources 122 | --three-way-merge use three-way-merge to compute patch and generate diff output 123 | -f, --values valueFiles specify values in a YAML file (can specify multiple) (default []) 124 | --version string specify the exact chart version to use. If this is not specified, the latest version is used 125 | 126 | Additional help topcis: 127 | diff 128 | 129 | Use "diff [command] --help" for more information about a command. 130 | ``` 131 | 132 | ## Commands: 133 | 134 | ### upgrade: 135 | 136 | ``` 137 | $ helm diff upgrade -h 138 | Show a diff explaining what a helm upgrade would change. 139 | 140 | This fetches the currently deployed version of a release 141 | and compares it to a chart plus values. 142 | This can be used to visualize what changes a helm upgrade will 143 | perform. 144 | 145 | Usage: 146 | diff upgrade [flags] [RELEASE] [CHART] 147 | 148 | Examples: 149 | helm diff upgrade my-release stable/postgresql --values values.yaml 150 | 151 | # Set HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true to ignore unknown flags 152 | # It's useful when you're using `helm-diff` in a `helm upgrade` wrapper. 153 | # See https://github.com/databus23/helm-diff/issues/278 for more information. 154 | HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true helm diff upgrade my-release stable/postgres --wait 155 | 156 | # Set HELM_DIFF_USE_UPGRADE_DRY_RUN=true to 157 | # use `helm upgrade --dry-run` instead of `helm template` to render manifests from the chart. 158 | # See https://github.com/databus23/helm-diff/issues/253 for more information. 159 | HELM_DIFF_USE_UPGRADE_DRY_RUN=true helm diff upgrade my-release datadog/datadog 160 | 161 | # Set HELM_DIFF_THREE_WAY_MERGE=true to 162 | # enable the three-way-merge on diff. 163 | # This is equivalent to specifying the --three-way-merge flag. 164 | # Read the flag usage below for more information on --three-way-merge. 165 | HELM_DIFF_THREE_WAY_MERGE=true helm diff upgrade my-release datadog/datadog 166 | 167 | # Set HELM_DIFF_NORMALIZE_MANIFESTS=true to 168 | # normalize the yaml file content when using helm diff. 169 | # This is equivalent to specifying the --normalize-manifests flag. 170 | # Read the flag usage below for more information on --normalize-manifests. 171 | HELM_DIFF_NORMALIZE_MANIFESTS=true helm diff upgrade my-release datadog/datadog 172 | 173 | # Set HELM_DIFF_OUTPUT_CONTEXT=n to configure the output context to n lines. 174 | # This is equivalent to specifying the --context flag. 175 | # Read the flag usage below for more information on --context. 176 | HELM_DIFF_OUTPUT_CONTEXT=5 helm diff upgrade my-release datadog/datadog 177 | 178 | Flags: 179 | --allow-unreleased enables diffing of releases that are not yet deployed via Helm 180 | -a, --api-versions stringArray Kubernetes api versions used for Capabilities.APIVersions 181 | -C, --context int output NUM lines of context around changes (default -1) 182 | --detailed-exitcode return a non-zero exit code when there are changes 183 | --devel use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored. 184 | --disable-openapi-validation disables rendered templates validation against the Kubernetes OpenAPI Schema 185 | --disable-validation disables rendered templates validation against the Kubernetes cluster you are currently pointing to. This is the same validation performed on an install 186 | --dry-run string[="client"] --dry-run, --dry-run=client, or --dry-run=true disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation. --dry-run=server enables the cluster access with helm-get and the lookup template function. 187 | --enable-dns enable DNS lookups when rendering templates 188 | -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched 189 | -h, --help help for upgrade 190 | --include-crds include CRDs in the diffing 191 | --include-tests enable the diffing of the helm test hooks 192 | --insecure-skip-tls-verify skip tls certificate checks for the chart download 193 | --install enables diffing of releases that are not yet deployed via Helm (equivalent to --allow-unreleased, added to match "helm upgrade --install" command 194 | --kube-version string Kubernetes version used for Capabilities.KubeVersion 195 | --kubeconfig string This flag is ignored, to allow passing of this top level flag to helm 196 | --no-hooks disable diffing of hooks 197 | --normalize-manifests normalize manifests before running diff to exclude style differences from the output 198 | --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") 199 | --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path 200 | --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) 201 | --repo string specify the chart repository url to locate the requested chart 202 | --reset-then-reuse-values reset the values to the ones built into the chart, apply the last release's values and merge in any new values. If '--reset-values' or '--reuse-values' is specified, this is ignored 203 | --reset-values reset the values to the ones built into the chart and merge in any new values 204 | --reuse-values reuse the last release's values and merge in any new values. If '--reset-values' is specified, this is ignored 205 | --set stringArray set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) 206 | --set-file stringArray set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2) 207 | --set-json stringArray set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2) 208 | --set-literal stringArray set STRING literal values on the command line 209 | --set-string stringArray set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2) 210 | --show-secrets do not redact secret values in the output 211 | --show-secrets-decoded decode secret values in the output 212 | --skip-schema-validation skip validation of the rendered manifests against the Kubernetes OpenAPI schema 213 | --strip-trailing-cr strip trailing carriage return on input 214 | --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') 215 | --suppress-output-line-regex stringArray a regex to suppress diff output lines that match 216 | -q, --suppress-secrets suppress secrets in the output 217 | --take-ownership if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources 218 | --three-way-merge use three-way-merge to compute patch and generate diff output 219 | -f, --values valueFiles specify values in a YAML file (can specify multiple) (default []) 220 | --version string specify the exact chart version to use. If this is not specified, the latest version is used 221 | 222 | Global Flags: 223 | --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 224 | --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 225 | ``` 226 | 227 | ### release: 228 | 229 | ``` 230 | $ helm diff release -h 231 | 232 | This command compares the manifests details of a different releases created from the same chart. 233 | The release name may be specified using namespace/release syntax. 234 | 235 | It can be used to compare the manifests of 236 | 237 | - release1 with release2 238 | $ helm diff release [flags] release1 release2 239 | Example: 240 | $ helm diff release my-prod my-stage 241 | $ helm diff release prod/my-prod stage/my-stage 242 | 243 | Usage: 244 | diff release [flags] RELEASE release1 [release2] 245 | 246 | Flags: 247 | -C, --context int output NUM lines of context around changes (default -1) 248 | --detailed-exitcode return a non-zero exit code when there are changes 249 | -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched 250 | -h, --help help for release 251 | --include-tests enable the diffing of the helm test hooks 252 | --normalize-manifests normalize manifests before running diff to exclude style differences from the output 253 | --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") 254 | --show-secrets do not redact secret values in the output 255 | --strip-trailing-cr strip trailing carriage return on input 256 | --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') 257 | --suppress-output-line-regex stringArray a regex to suppress diff output lines that match 258 | -q, --suppress-secrets suppress secrets in the output 259 | 260 | Global Flags: 261 | --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 262 | --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 263 | ``` 264 | 265 | ### revision: 266 | 267 | ``` 268 | $ helm diff revision -h 269 | 270 | This command compares the manifests details of a named release. 271 | 272 | It can be used to compare the manifests of 273 | 274 | - latest REVISION with specified REVISION 275 | $ helm diff revision [flags] RELEASE REVISION1 276 | Example: 277 | $ helm diff revision my-release 2 278 | 279 | - REVISION1 with REVISION2 280 | $ helm diff revision [flags] RELEASE REVISION1 REVISION2 281 | Example: 282 | $ helm diff revision my-release 2 3 283 | 284 | Usage: 285 | diff revision [flags] RELEASE REVISION1 [REVISION2] 286 | 287 | Flags: 288 | -C, --context int output NUM lines of context around changes (default -1) 289 | --show-secrets-decoded decode secret values in the output 290 | --detailed-exitcode return a non-zero exit code when there are changes 291 | -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched 292 | -h, --help help for revision 293 | --include-tests enable the diffing of the helm test hooks 294 | --normalize-manifests normalize manifests before running diff to exclude style differences from the output 295 | --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") 296 | --show-secrets do not redact secret values in the output 297 | --show-secrets-decoded decode secret values in the output 298 | --strip-trailing-cr strip trailing carriage return on input 299 | --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') 300 | --suppress-output-line-regex stringArray a regex to suppress diff output lines that match 301 | -q, --suppress-secrets suppress secrets in the output 302 | 303 | Global Flags: 304 | --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 305 | --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 306 | ``` 307 | 308 | ### rollback: 309 | 310 | ``` 311 | $ helm diff rollback -h 312 | 313 | This command compares the latest manifest details of a named release 314 | with specific revision values to rollback. 315 | 316 | It forecasts/visualizes changes, that a helm rollback could perform. 317 | 318 | Usage: 319 | diff rollback [flags] [RELEASE] [REVISION] 320 | 321 | Examples: 322 | helm diff rollback my-release 2 323 | 324 | Flags: 325 | -C, --context int output NUM lines of context around changes (default -1) 326 | --detailed-exitcode return a non-zero exit code when there are changes 327 | -D, --find-renames float32 Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched 328 | -h, --help help for rollback 329 | --include-tests enable the diffing of the helm test hooks 330 | --normalize-manifests normalize manifests before running diff to exclude style differences from the output 331 | --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") 332 | --show-secrets do not redact secret values in the output 333 | --show-secrets-decoded decode secret values in the output 334 | --strip-trailing-cr strip trailing carriage return on input 335 | --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') 336 | --suppress-output-line-regex stringArray a regex to suppress diff output lines that match 337 | -q, --suppress-secrets suppress secrets in the output 338 | 339 | Global Flags: 340 | --color color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 341 | --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" 342 | ``` 343 | 344 | ## Build 345 | 346 | Clone the repository into your `$GOPATH` and then build it. 347 | 348 | ``` 349 | $ mkdir -p $GOPATH/src/github.com/databus23/ 350 | $ cd $GOPATH/src/github.com/databus23/ 351 | $ git clone https://github.com/databus23/helm-diff.git 352 | $ cd helm-diff 353 | $ make install 354 | ``` 355 | 356 | The above will install this plugin into your `$HELM_HOME/plugins` directory. 357 | 358 | ### Prerequisites 359 | 360 | - You need to have [Go](http://golang.org) installed. Make sure to set `$GOPATH` 361 | 362 | ### Running Tests 363 | Automated tests are implemented with [*testing*](https://golang.org/pkg/testing/). 364 | 365 | To run all tests: 366 | ``` 367 | go test -v ./... 368 | ``` 369 | 370 | ## Release 371 | 372 | Bump `version` in `plugin.yaml`: 373 | 374 | ``` 375 | $ code plugin.yaml 376 | $ git commit -m 'Bump helm-diff version to 3.x.y' 377 | ``` 378 | 379 | Set `GITHUB_TOKEN` and run: 380 | 381 | ``` 382 | $ make docker-run-release 383 | ``` 384 | -------------------------------------------------------------------------------- /cmd/error.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | // Error to report errors 4 | type Error struct { 5 | error 6 | Code int 7 | } 8 | -------------------------------------------------------------------------------- /cmd/helm.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | // This file contains functions that where blatantly copied from 4 | // https://github.wdf.sap.corp/kubernetes/helm 5 | 6 | import ( 7 | "errors" 8 | "fmt" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | /////////////// Source: cmd/helm/install.go ///////////////////////// 14 | 15 | type valueFiles []string 16 | 17 | func (v *valueFiles) String() string { 18 | return fmt.Sprint(*v) 19 | } 20 | 21 | // Ensures all valuesFiles exist 22 | func (v *valueFiles) Valid() error { 23 | errStr := "" 24 | for _, valuesFile := range *v { 25 | if strings.TrimSpace(valuesFile) != "-" { 26 | if _, err := os.Stat(valuesFile); os.IsNotExist(err) { 27 | errStr += err.Error() 28 | } 29 | } 30 | } 31 | 32 | if errStr == "" { 33 | return nil 34 | } 35 | 36 | return errors.New(errStr) 37 | } 38 | 39 | func (v *valueFiles) Type() string { 40 | return "valueFiles" 41 | } 42 | 43 | func (v *valueFiles) Set(value string) error { 44 | for _, filePath := range strings.Split(value, ",") { 45 | *v = append(*v, filePath) 46 | } 47 | return nil 48 | } 49 | 50 | /////////////// Source: cmd/helm/helm.go //////////////////////////// 51 | 52 | func checkArgsLength(argsReceived int, requiredArgs ...string) error { 53 | expectedNum := len(requiredArgs) 54 | if argsReceived != expectedNum { 55 | arg := "arguments" 56 | if expectedNum == 1 { 57 | arg = "argument" 58 | } 59 | return fmt.Errorf("This command needs %v %s: %s", expectedNum, arg, strings.Join(requiredArgs, ", ")) 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /cmd/helm3.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/Masterminds/semver/v3" 14 | ) 15 | 16 | var ( 17 | helmVersionRE = regexp.MustCompile(`Version:\s*"([^"]+)"`) 18 | minHelmVersion = semver.MustParse("v3.1.0-rc.1") 19 | // See https://github.com/helm/helm/pull/9426. 20 | minHelmVersionWithDryRunLookupSupport = semver.MustParse("v3.13.0") 21 | // The --reset-then-reuse-values flag for `helm upgrade` was added in 22 | // https://github.com/helm/helm/pull/9653 and released as part of Helm v3.14.0. 23 | minHelmVersionWithResetThenReuseValues = semver.MustParse("v3.14.0") 24 | ) 25 | 26 | func getHelmVersion() (*semver.Version, error) { 27 | cmd := exec.Command(os.Getenv("HELM_BIN"), "version") 28 | debugPrint("Executing %s", strings.Join(cmd.Args, " ")) 29 | output, err := cmd.CombinedOutput() 30 | if err != nil { 31 | return nil, fmt.Errorf("Failed to run `%s version`: %w", os.Getenv("HELM_BIN"), err) 32 | } 33 | versionOutput := string(output) 34 | 35 | matches := helmVersionRE.FindStringSubmatch(versionOutput) 36 | if matches == nil { 37 | return nil, fmt.Errorf("Failed to find version in output %#v", versionOutput) 38 | } 39 | helmVersion, err := semver.NewVersion(matches[1]) 40 | if err != nil { 41 | return nil, fmt.Errorf("Failed to parse version %#v: %w", matches[1], err) 42 | } 43 | 44 | return helmVersion, nil 45 | } 46 | 47 | func isHelmVersionAtLeast(versionToCompareTo *semver.Version) (bool, error) { 48 | helmVersion, err := getHelmVersion() 49 | 50 | if err != nil { 51 | return false, err 52 | } 53 | if helmVersion.LessThan(versionToCompareTo) { 54 | return false, nil 55 | } 56 | return true, nil 57 | } 58 | 59 | func compatibleHelm3Version() error { 60 | if isCompatible, err := isHelmVersionAtLeast(minHelmVersion); err != nil { 61 | return err 62 | } else if !isCompatible { 63 | return fmt.Errorf("helm diff upgrade requires at least helm version %s", minHelmVersion.String()) 64 | } 65 | return nil 66 | } 67 | 68 | func getRelease(release, namespace string) ([]byte, error) { 69 | args := []string{"get", "manifest", release} 70 | if namespace != "" { 71 | args = append(args, "--namespace", namespace) 72 | } 73 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 74 | return outputWithRichError(cmd) 75 | } 76 | 77 | func getHooks(release, namespace string) ([]byte, error) { 78 | args := []string{"get", "hooks", release} 79 | if namespace != "" { 80 | args = append(args, "--namespace", namespace) 81 | } 82 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 83 | return outputWithRichError(cmd) 84 | } 85 | 86 | func getRevision(release string, revision int, namespace string) ([]byte, error) { 87 | args := []string{"get", "manifest", release, "--revision", strconv.Itoa(revision)} 88 | if namespace != "" { 89 | args = append(args, "--namespace", namespace) 90 | } 91 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 92 | return outputWithRichError(cmd) 93 | } 94 | 95 | func getChart(release, namespace string) (string, error) { 96 | args := []string{"get", "all", release, "--template", "{{.Release.Chart.Name}}"} 97 | if namespace != "" { 98 | args = append(args, "--namespace", namespace) 99 | } 100 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 101 | out, err := outputWithRichError(cmd) 102 | if err != nil { 103 | return "", err 104 | } 105 | return string(out), nil 106 | } 107 | 108 | func (d *diffCmd) template(isUpgrade bool) ([]byte, error) { 109 | flags := []string{} 110 | if d.devel { 111 | flags = append(flags, "--devel") 112 | } 113 | if d.noHooks && !d.useUpgradeDryRun { 114 | flags = append(flags, "--no-hooks") 115 | } 116 | if d.includeCRDs { 117 | flags = append(flags, "--include-crds") 118 | } 119 | if d.chartVersion != "" { 120 | flags = append(flags, "--version", d.chartVersion) 121 | } 122 | if d.chartRepo != "" { 123 | flags = append(flags, "--repo", d.chartRepo) 124 | } 125 | if d.namespace != "" { 126 | flags = append(flags, "--namespace", d.namespace) 127 | } 128 | if d.postRenderer != "" { 129 | flags = append(flags, "--post-renderer", d.postRenderer) 130 | } 131 | for _, arg := range d.postRendererArgs { 132 | flags = append(flags, "--post-renderer-args", arg) 133 | } 134 | if d.insecureSkipTLSVerify { 135 | flags = append(flags, "--insecure-skip-tls-verify") 136 | } 137 | // Helm automatically enable --reuse-values when there's no --set, --set-string, --set-json, --set-values, --set-file present. 138 | // Let's simulate that in helm-diff. 139 | // See https://medium.com/@kcatstack/understand-helm-upgrade-flags-reset-values-reuse-values-6e58ac8f127e 140 | shouldDefaultReusingValues := isUpgrade && len(d.values) == 0 && len(d.stringValues) == 0 && len(d.stringLiteralValues) == 0 && len(d.jsonValues) == 0 && len(d.valueFiles) == 0 && len(d.fileValues) == 0 141 | if (d.reuseValues || d.resetThenReuseValues || shouldDefaultReusingValues) && !d.resetValues && d.clusterAccessAllowed() { 142 | tmpfile, err := os.CreateTemp("", "existing-values") 143 | if err != nil { 144 | return nil, err 145 | } 146 | defer func() { 147 | _ = os.Remove(tmpfile.Name()) 148 | }() 149 | // In the presence of --reuse-values (or --reset-values), --reset-then-reuse-values is ignored. 150 | if d.resetThenReuseValues && !d.reuseValues { 151 | var supported bool 152 | supported, err = isHelmVersionAtLeast(minHelmVersionWithResetThenReuseValues) 153 | if err != nil { 154 | return nil, err 155 | } 156 | if !supported { 157 | return nil, fmt.Errorf("Using --reset-then-reuse-values requires at least helm version %s", minHelmVersionWithResetThenReuseValues.String()) 158 | } 159 | err = d.writeExistingValues(tmpfile, false) 160 | } else { 161 | err = d.writeExistingValues(tmpfile, true) 162 | } 163 | if err != nil { 164 | return nil, err 165 | } 166 | flags = append(flags, "--values", tmpfile.Name()) 167 | } 168 | for _, value := range d.values { 169 | flags = append(flags, "--set", value) 170 | } 171 | for _, stringValue := range d.stringValues { 172 | flags = append(flags, "--set-string", stringValue) 173 | } 174 | for _, stringLiteralValue := range d.stringLiteralValues { 175 | flags = append(flags, "--set-literal", stringLiteralValue) 176 | } 177 | for _, jsonValue := range d.jsonValues { 178 | flags = append(flags, "--set-json", jsonValue) 179 | } 180 | for _, valueFile := range d.valueFiles { 181 | if strings.TrimSpace(valueFile) == "-" { 182 | bytes, err := io.ReadAll(os.Stdin) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | tmpfile, err := os.CreateTemp("", "helm-diff-stdin-values") 188 | if err != nil { 189 | return nil, err 190 | } 191 | defer func() { 192 | _ = os.Remove(tmpfile.Name()) 193 | }() 194 | 195 | if _, err := tmpfile.Write(bytes); err != nil { 196 | _ = tmpfile.Close() 197 | return nil, err 198 | } 199 | 200 | if err := tmpfile.Close(); err != nil { 201 | return nil, err 202 | } 203 | 204 | flags = append(flags, "--values", tmpfile.Name()) 205 | } else { 206 | flags = append(flags, "--values", valueFile) 207 | } 208 | } 209 | for _, fileValue := range d.fileValues { 210 | flags = append(flags, "--set-file", fileValue) 211 | } 212 | 213 | if d.disableOpenAPIValidation { 214 | flags = append(flags, "--disable-openapi-validation") 215 | } 216 | 217 | if d.enableDNS { 218 | flags = append(flags, "--enable-dns") 219 | } 220 | 221 | if d.SkipSchemaValidation { 222 | flags = append(flags, "--skip-schema-validation") 223 | } 224 | 225 | if d.takeOwnership { 226 | flags = append(flags, "--take-ownership") 227 | } 228 | 229 | var ( 230 | subcmd string 231 | filter func([]byte) []byte 232 | ) 233 | 234 | // `--dry-run=client` or `--dry-run=server`? 235 | // 236 | // Or what's the relationoship between helm-diff's --dry-run flag, 237 | // HELM_DIFF_UPGRADE_DRY_RUN env var and the helm upgrade --dry-run flag? 238 | // 239 | // Read on to find out. 240 | if d.useUpgradeDryRun { 241 | if d.isAllowUnreleased() { 242 | // Otherwise you get the following error when this is a diff for a new install 243 | // Error: UPGRADE FAILED: "$RELEASE_NAME" has no deployed releases 244 | flags = append(flags, "--install") 245 | } 246 | 247 | // If the program reaches here, 248 | // we are sure that the user wants to use the `helm upgrade --dry-run` command 249 | // for generating the manifests to be diffed. 250 | // 251 | // So the question is only whether to use `--dry-run=client` or `--dry-run=server`. 252 | // 253 | // As HELM_DIFF_UPGRADE_DRY_RUN is there for producing more complete and correct diff results, 254 | // we use --dry-run=server if the version of helm supports it. 255 | // Otherwise, we use --dry-run=client, as that's the best we can do. 256 | if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService { 257 | flags = append(flags, "--dry-run=server") 258 | } else { 259 | flags = append(flags, "--dry-run") 260 | } 261 | subcmd = "upgrade" 262 | filter = func(s []byte) []byte { 263 | return extractManifestFromHelmUpgradeDryRunOutput(s, d.noHooks) 264 | } 265 | } else { 266 | if !d.disableValidation && d.clusterAccessAllowed() { 267 | flags = append(flags, "--validate") 268 | } 269 | 270 | if isUpgrade { 271 | flags = append(flags, "--is-upgrade") 272 | } 273 | 274 | for _, a := range d.extraAPIs { 275 | flags = append(flags, "--api-versions", a) 276 | } 277 | 278 | if d.kubeVersion != "" { 279 | flags = append(flags, "--kube-version", d.kubeVersion) 280 | } 281 | 282 | // To keep the full compatibility with older helm-diff versions, 283 | // we pass --dry-run to `helm template` only if Helm is greater than v3.13.0. 284 | if useDryRunService, err := isHelmVersionAtLeast(minHelmVersionWithDryRunLookupSupport); err == nil && useDryRunService { 285 | // However, which dry-run mode to use is still not clear. 286 | // 287 | // For compatibility with the old and new helm-diff options, 288 | // old and new helm, we assume that the user wants to use the older `helm template --dry-run=client` mode 289 | // if helm-diff has been invoked with any of the following flags: 290 | // 291 | // * no dry-run flags (to be consistent with helm-template) 292 | // * --dry-run 293 | // * --dry-run="" 294 | // * --dry-run=client 295 | // 296 | // and the newer `helm template --dry-run=server` mode when invoked with: 297 | // 298 | // * --dry-run=server 299 | // 300 | // Any other values should result in errors. 301 | // 302 | // See the fllowing link for more details: 303 | // - https://github.com/databus23/helm-diff/pull/458 304 | // - https://github.com/helm/helm/pull/9426#issuecomment-1501005666 305 | if d.dryRunMode == "server" { 306 | // This is for security reasons! 307 | // 308 | // We give helm-template the additional cluster access for the helm `lookup` function 309 | // only if the user has explicitly requested it by --dry-run=server, 310 | // 311 | // In other words, although helm-diff-upgrade implies limited cluster access by default, 312 | // helm-diff-upgrade without a --dry-run flag does NOT imply 313 | // full cluster-access via helm-template --dry-run=server! 314 | flags = append(flags, "--dry-run=server") 315 | } else { 316 | // Since helm-diff 3.9.0 and helm 3.13.0, we pass --dry-run=client to `helm template` by default. 317 | // This doesn't make any difference for helm-diff itself, 318 | // because helm-template w/o flags is equivalent to helm-template --dry-run=client. 319 | // See https://github.com/helm/helm/pull/9426#discussion_r1181397259 320 | flags = append(flags, "--dry-run=client") 321 | } 322 | } 323 | 324 | subcmd = "template" 325 | 326 | filter = func(s []byte) []byte { 327 | return s 328 | } 329 | } 330 | 331 | args := []string{subcmd, d.release, d.chart} 332 | args = append(args, flags...) 333 | 334 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 335 | out, err := outputWithRichError(cmd) 336 | return filter(out), err 337 | } 338 | 339 | func (d *diffCmd) writeExistingValues(f *os.File, all bool) error { 340 | args := []string{"get", "values", d.release, "--output", "yaml"} 341 | if all { 342 | args = append(args, "--all") 343 | } 344 | cmd := exec.Command(os.Getenv("HELM_BIN"), args...) 345 | debugPrint("Executing %s", strings.Join(cmd.Args, " ")) 346 | defer func() { 347 | _ = f.Close() 348 | }() 349 | cmd.Stdout = f 350 | return cmd.Run() 351 | } 352 | 353 | func extractManifestFromHelmUpgradeDryRunOutput(s []byte, noHooks bool) []byte { 354 | if len(s) == 0 { 355 | return s 356 | } 357 | 358 | i := bytes.Index(s, []byte("HOOKS:")) 359 | hooks := s[i:] 360 | 361 | j := bytes.Index(hooks, []byte("MANIFEST:")) 362 | 363 | manifest := hooks[j:] 364 | hooks = hooks[:j] 365 | 366 | k := bytes.Index(manifest, []byte("\nNOTES:")) 367 | 368 | if k > -1 { 369 | manifest = manifest[:k+1] 370 | } 371 | 372 | if noHooks { 373 | hooks = nil 374 | } else { 375 | a := bytes.Index(hooks, []byte("---")) 376 | if a > -1 { 377 | hooks = hooks[a:] 378 | } else { 379 | hooks = nil 380 | } 381 | } 382 | 383 | a := bytes.Index(manifest, []byte("---")) 384 | if a > -1 { 385 | manifest = manifest[a:] 386 | } 387 | 388 | r := []byte{} 389 | r = append(r, manifest...) 390 | r = append(r, hooks...) 391 | 392 | return r 393 | } 394 | -------------------------------------------------------------------------------- /cmd/helm3_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestExtractManifestFromHelmUpgradeDryRunOutput(t *testing.T) { 10 | type testdata struct { 11 | description string 12 | 13 | s string 14 | noHooks bool 15 | 16 | want string 17 | } 18 | 19 | manifest := `--- 20 | # Source: mysql/templates/secrets.yaml 21 | apiVersion: v1 22 | kind: Secret 23 | metadata: 24 | name: my1-mysql 25 | namespace: default 26 | labels: 27 | app: my1-mysql 28 | chart: "mysql-1.6.9" 29 | release: "my1" 30 | heritage: "Helm" 31 | type: Opaque 32 | data: 33 | mysql-root-password: "ZlhEVGJseUhmeg==" 34 | mysql-password: "YnRuU3pPOTJMVg==" 35 | --- 36 | # Source: mysql/templates/tests/test-configmap.yaml 37 | apiVersion: v1 38 | kind: ConfigMap 39 | metadata: 40 | name: my1-mysql-test 41 | namespace: default 42 | labels: 43 | app: my1-mysql 44 | chart: "mysql-1.6.9" 45 | heritage: "Helm" 46 | release: "my1" 47 | data: 48 | run.sh: |- 49 | 50 | ` 51 | hooks := `--- 52 | # Source: mysql/templates/tests/test.yaml 53 | apiVersion: v1 54 | kind: Pod 55 | metadata: 56 | name: my1-mysql-test 57 | namespace: default 58 | labels: 59 | app: my1-mysql 60 | chart: "mysql-1.6.9" 61 | heritage: "Helm" 62 | release: "my1" 63 | annotations: 64 | "helm.sh/hook": test-success 65 | spec: 66 | containers: 67 | - name: my1-test 68 | image: "bats/bats:1.2.1" 69 | imagePullPolicy: "IfNotPresent" 70 | command: ["/opt/bats/bin/bats", "-t", "/tests/run.sh"] 71 | ` 72 | 73 | header := `Release "my1" has been upgraded. Happy Helming! 74 | NAME: my1 75 | LAST DEPLOYED: Sun Feb 13 02:26:16 2022 76 | NAMESPACE: default 77 | STATUS: pending-upgrade 78 | REVISION: 2 79 | HOOKS: 80 | ` 81 | 82 | notes := `NOTES: 83 | MySQL can be accessed via port 3306 on the following DNS name from within your cluster: 84 | my1-mysql.default.svc.cluster.local 85 | 86 | *snip* 87 | 88 | To connect to your database directly from outside the K8s cluster: 89 | MYSQL_HOST=127.0.0.1 90 | MYSQL_PORT=3306 91 | 92 | # Execute the following command to route the connection: 93 | kubectl port-forward svc/my1-mysql 3306 94 | 95 | mysql -h ${MYSQL_HOST} -P${MYSQL_PORT} -u root -p${MYSQL_ROOT_PASSWORD} 96 | ` 97 | 98 | outputWithHooks := header + hooks + "MANIFEST:\n" + manifest + notes 99 | outputWithNoHooks := header + "MANIFEST:\n" + manifest + notes 100 | 101 | testcases := []testdata{ 102 | { 103 | description: "should output manifest when noHooks specified", 104 | s: outputWithHooks, 105 | noHooks: true, 106 | want: manifest, 107 | }, 108 | { 109 | description: "should output manifest and hooks when noHooks unspecified", 110 | s: outputWithHooks, 111 | noHooks: false, 112 | want: manifest + hooks, 113 | }, 114 | { 115 | description: "should output manifest if noHooks specified but input did not contain hooks", 116 | s: outputWithNoHooks, 117 | noHooks: true, 118 | want: manifest, 119 | }, 120 | { 121 | description: "should output manifest if noHooks unspecified and input did not contain hooks", 122 | s: outputWithNoHooks, 123 | noHooks: false, 124 | want: manifest, 125 | }, 126 | } 127 | 128 | for _, tc := range testcases { 129 | t.Run(tc.description, func(t *testing.T) { 130 | got := extractManifestFromHelmUpgradeDryRunOutput([]byte(tc.s), tc.noHooks) 131 | 132 | if d := cmp.Diff(tc.want, string(got)); d != "" { 133 | t.Errorf("unexpected diff: %s", d) 134 | } 135 | }) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /cmd/helpers.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "k8s.io/client-go/util/homedir" 12 | ) 13 | 14 | var ( 15 | // DefaultHelmHome to hold default home path of .helm dir 16 | DefaultHelmHome = filepath.Join(homedir.HomeDir(), ".helm") 17 | ) 18 | 19 | func isDebug() bool { 20 | return os.Getenv("HELM_DEBUG") == "true" 21 | } 22 | func debugPrint(format string, a ...interface{}) { 23 | if isDebug() { 24 | fmt.Printf(format+"\n", a...) 25 | } 26 | } 27 | 28 | func outputWithRichError(cmd *exec.Cmd) ([]byte, error) { 29 | debugPrint("Executing %s", strings.Join(cmd.Args, " ")) 30 | output, err := cmd.Output() 31 | var exitError *exec.ExitError 32 | if errors.As(err, &exitError) { 33 | return output, fmt.Errorf("%s: %s", exitError.Error(), string(exitError.Stderr)) 34 | } 35 | return output, err 36 | } 37 | -------------------------------------------------------------------------------- /cmd/helpers_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func captureStdout(f func()) (string, error) { 14 | old := os.Stdout 15 | r, w, err := os.Pipe() 16 | if err != nil { 17 | return "", err 18 | } 19 | os.Stdout = w 20 | 21 | defer func() { 22 | os.Stdout = old 23 | }() 24 | 25 | f() 26 | 27 | w.Close() 28 | 29 | var buf bytes.Buffer 30 | _, err = io.Copy(&buf, r) 31 | if err != nil { 32 | return "", err 33 | } 34 | return buf.String(), nil 35 | } 36 | 37 | func TestCaptureStdout(t *testing.T) { 38 | output, err := captureStdout(func() { 39 | _, _ = os.Stdout.Write([]byte("test")) 40 | }) 41 | require.NoError(t, err) 42 | require.Equal(t, "test", output) 43 | } 44 | 45 | func TestIsDebug(t *testing.T) { 46 | tests := []struct { 47 | name string 48 | envValue string 49 | expected bool 50 | }{ 51 | { 52 | name: "HELM_DEBUG is true", 53 | envValue: "true", 54 | expected: true, 55 | }, 56 | { 57 | name: "HELM_DEBUG is false", 58 | envValue: "false", 59 | expected: false, 60 | }, 61 | } 62 | 63 | for _, tt := range tests { 64 | t.Run(tt.name, func(t *testing.T) { 65 | t.Setenv("HELM_DEBUG", tt.envValue) 66 | require.Equalf(t, tt.expected, isDebug(), "Expected %v but got %v", tt.expected, isDebug()) 67 | }) 68 | } 69 | } 70 | 71 | func TestDebugPrint(t *testing.T) { 72 | tests := []struct { 73 | name string 74 | envValue string 75 | expected string 76 | }{ 77 | { 78 | name: "non-empty when HELM_DEBUG is true", 79 | envValue: "true", 80 | expected: "test\n", 81 | }, 82 | { 83 | name: "empty when HELM_DEBUG is false", 84 | envValue: "false", 85 | expected: "", 86 | }, 87 | } 88 | 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | t.Setenv("HELM_DEBUG", tt.envValue) 92 | output, err := captureStdout(func() { 93 | debugPrint("test") 94 | }) 95 | require.NoError(t, err) 96 | require.Equalf(t, tt.expected, output, "Expected %v but got %v", tt.expected, output) 97 | }) 98 | } 99 | } 100 | 101 | func TestOutputWithRichError(t *testing.T) { 102 | tests := []struct { 103 | name string 104 | envValue string 105 | cmd *exec.Cmd 106 | expected string 107 | expectedStdout string 108 | }{ 109 | { 110 | name: "debug output in stdout when HELM_DEBUG is true", 111 | envValue: "true", 112 | cmd: exec.Command("echo", "test1"), 113 | expected: "test1\n", 114 | expectedStdout: "Executing echo test1\n", 115 | }, 116 | { 117 | name: "non-debug output in stdout when HELM_DEBUG is false", 118 | envValue: "false", 119 | cmd: exec.Command("echo", "test2"), 120 | expected: "test2\n", 121 | expectedStdout: "", 122 | }, 123 | } 124 | 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | t.Setenv("HELM_DEBUG", tt.envValue) 128 | var ( 129 | stdoutString string 130 | outBytes []byte 131 | funcErr, captureErr error 132 | ) 133 | stdoutString, captureErr = captureStdout(func() { 134 | outBytes, funcErr = outputWithRichError(tt.cmd) 135 | }) 136 | require.NoError(t, captureErr) 137 | require.NoError(t, funcErr) 138 | require.Equalf(t, tt.expected, string(outBytes), "Expected %v but got %v", tt.expected, string(outBytes)) 139 | require.Equalf(t, tt.expectedStdout, stdoutString, "Expected %v but got %v", tt.expectedStdout, stdoutString) 140 | }) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /cmd/options.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | 6 | "github.com/databus23/helm-diff/v3/diff" 7 | ) 8 | 9 | // AddDiffOptions adds flags for the various consolidated options to the functions in the diff package 10 | func AddDiffOptions(f *pflag.FlagSet, o *diff.Options) { 11 | f.BoolP("suppress-secrets", "q", false, "suppress secrets in the output") 12 | f.BoolVar(&o.ShowSecrets, "show-secrets", false, "do not redact secret values in the output") 13 | f.BoolVar(&o.ShowSecretsDecoded, "show-secrets-decoded", false, "decode secret values in the output") 14 | f.StringArrayVar(&o.SuppressedKinds, "suppress", []string{}, "allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')") 15 | f.IntVarP(&o.OutputContext, "context", "C", -1, "output NUM lines of context around changes") 16 | f.StringVar(&o.OutputFormat, "output", "diff", "Possible values: diff, simple, template, dyff. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") 17 | f.BoolVar(&o.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input") 18 | f.Float32VarP(&o.FindRenames, "find-renames", "D", 0, "Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched") 19 | f.StringArrayVar(&o.SuppressedOutputLineRegex, "suppress-output-line-regex", []string{}, "a regex to suppress diff output lines that match") 20 | } 21 | 22 | // ProcessDiffOptions processes the set flags and handles possible interactions between them 23 | func ProcessDiffOptions(f *pflag.FlagSet, o *diff.Options) { 24 | if q, _ := f.GetBool("suppress-secrets"); q { 25 | o.SuppressedKinds = append(o.SuppressedKinds, "Secret") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /cmd/release.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/databus23/helm-diff/v3/diff" 12 | "github.com/databus23/helm-diff/v3/manifest" 13 | ) 14 | 15 | type release struct { 16 | detailedExitCode bool 17 | releases []string 18 | includeTests bool 19 | normalizeManifests bool 20 | diff.Options 21 | } 22 | 23 | const releaseCmdLongUsage = ` 24 | This command compares the manifests details of a different releases created from the same chart. 25 | The release name may be specified using namespace/release syntax. 26 | 27 | It can be used to compare the manifests of 28 | 29 | - release1 with release2 30 | $ helm diff release [flags] release1 release2 31 | Example: 32 | $ helm diff release my-prod my-stage 33 | $ helm diff release prod/my-prod stage/my-stage 34 | ` 35 | 36 | func releaseCmd() *cobra.Command { 37 | diff := release{} 38 | releaseCmd := &cobra.Command{ 39 | Use: "release [flags] RELEASE release1 [release2]", 40 | Short: "Shows diff between release's manifests", 41 | Long: releaseCmdLongUsage, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | // Suppress the command usage on error. See #77 for more info 44 | cmd.SilenceUsage = true 45 | 46 | if v, _ := cmd.Flags().GetBool("version"); v { 47 | fmt.Println(Version) 48 | return nil 49 | } 50 | 51 | switch { 52 | case len(args) < 2: 53 | return errors.New("Too few arguments to Command \"release\".\nMinimum 2 arguments required: release name-1, release name-2") 54 | } 55 | 56 | ProcessDiffOptions(cmd.Flags(), &diff.Options) 57 | 58 | diff.releases = args[0:] 59 | return diff.differentiateHelm3() 60 | }, 61 | } 62 | 63 | releaseCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") 64 | releaseCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") 65 | releaseCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") 66 | AddDiffOptions(releaseCmd.Flags(), &diff.Options) 67 | 68 | releaseCmd.SuggestionsMinimumDistance = 1 69 | 70 | return releaseCmd 71 | } 72 | 73 | func (d *release) differentiateHelm3() error { 74 | excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} 75 | if d.includeTests { 76 | excludes = []string{} 77 | } 78 | 79 | namespace1 := os.Getenv("HELM_NAMESPACE") 80 | release1 := d.releases[0] 81 | if strings.Contains(release1, "/") { 82 | namespace1 = strings.Split(release1, "/")[0] 83 | release1 = strings.Split(release1, "/")[1] 84 | } 85 | releaseResponse1, err := getRelease(release1, namespace1) 86 | if err != nil { 87 | return err 88 | } 89 | releaseChart1, err := getChart(release1, namespace1) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | namespace2 := os.Getenv("HELM_NAMESPACE") 95 | release2 := d.releases[1] 96 | if strings.Contains(release2, "/") { 97 | namespace2 = strings.Split(release2, "/")[0] 98 | release2 = strings.Split(release2, "/")[1] 99 | } 100 | releaseResponse2, err := getRelease(release2, namespace2) 101 | if err != nil { 102 | return err 103 | } 104 | releaseChart2, err := getChart(release2, namespace2) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if releaseChart1 == releaseChart2 { 110 | seenAnyChanges := diff.Releases( 111 | manifest.Parse(string(releaseResponse1), namespace1, d.normalizeManifests, excludes...), 112 | manifest.Parse(string(releaseResponse2), namespace2, d.normalizeManifests, excludes...), 113 | &d.Options, 114 | os.Stdout) 115 | 116 | if d.detailedExitCode && seenAnyChanges { 117 | return Error{ 118 | error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), 119 | Code: 2, 120 | } 121 | } 122 | } else { 123 | 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) 124 | } 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /cmd/revision.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/databus23/helm-diff/v3/diff" 12 | "github.com/databus23/helm-diff/v3/manifest" 13 | ) 14 | 15 | type revision struct { 16 | release string 17 | detailedExitCode bool 18 | revisions []string 19 | includeTests bool 20 | normalizeManifests bool 21 | diff.Options 22 | } 23 | 24 | const revisionCmdLongUsage = ` 25 | This command compares the manifests details of a named release. 26 | 27 | It can be used to compare the manifests of 28 | 29 | - latest REVISION with specified REVISION 30 | $ helm diff revision [flags] RELEASE REVISION1 31 | Example: 32 | $ helm diff revision my-release 2 33 | 34 | - REVISION1 with REVISION2 35 | $ helm diff revision [flags] RELEASE REVISION1 REVISION2 36 | Example: 37 | $ helm diff revision my-release 2 3 38 | ` 39 | 40 | func revisionCmd() *cobra.Command { 41 | diff := revision{} 42 | revisionCmd := &cobra.Command{ 43 | Use: "revision [flags] RELEASE REVISION1 [REVISION2]", 44 | Short: "Shows diff between revision's manifests", 45 | Long: revisionCmdLongUsage, 46 | RunE: func(cmd *cobra.Command, args []string) error { 47 | // Suppress the command usage on error. See #77 for more info 48 | cmd.SilenceUsage = true 49 | 50 | if v, _ := cmd.Flags().GetBool("version"); v { 51 | fmt.Println(Version) 52 | return nil 53 | } 54 | 55 | switch { 56 | case len(args) < 2: 57 | return errors.New("Too few arguments to Command \"revision\".\nMinimum 2 arguments required: release name, revision") 58 | case len(args) > 3: 59 | return errors.New("Too many arguments to Command \"revision\".\nMaximum 3 arguments allowed: release name, revision1, revision2") 60 | } 61 | 62 | ProcessDiffOptions(cmd.Flags(), &diff.Options) 63 | 64 | diff.release = args[0] 65 | diff.revisions = args[1:] 66 | return diff.differentiateHelm3() 67 | }, 68 | } 69 | 70 | revisionCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") 71 | revisionCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") 72 | revisionCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") 73 | AddDiffOptions(revisionCmd.Flags(), &diff.Options) 74 | 75 | revisionCmd.SuggestionsMinimumDistance = 1 76 | 77 | return revisionCmd 78 | } 79 | 80 | func (d *revision) differentiateHelm3() error { 81 | namespace := os.Getenv("HELM_NAMESPACE") 82 | excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} 83 | if d.includeTests { 84 | excludes = []string{} 85 | } 86 | switch len(d.revisions) { 87 | case 1: 88 | releaseResponse, err := getRelease(d.release, namespace) 89 | 90 | if err != nil { 91 | return err 92 | } 93 | 94 | revision, _ := strconv.Atoi(d.revisions[0]) 95 | revisionResponse, err := getRevision(d.release, revision, namespace) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | diff.Manifests( 101 | manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...), 102 | manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...), 103 | &d.Options, 104 | os.Stdout) 105 | 106 | case 2: 107 | revision1, _ := strconv.Atoi(d.revisions[0]) 108 | revision2, _ := strconv.Atoi(d.revisions[1]) 109 | if revision1 > revision2 { 110 | revision1, revision2 = revision2, revision1 111 | } 112 | 113 | revisionResponse1, err := getRevision(d.release, revision1, namespace) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | revisionResponse2, err := getRevision(d.release, revision2, namespace) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | seenAnyChanges := diff.Manifests( 124 | manifest.Parse(string(revisionResponse1), namespace, d.normalizeManifests, excludes...), 125 | manifest.Parse(string(revisionResponse2), namespace, d.normalizeManifests, excludes...), 126 | &d.Options, 127 | os.Stdout) 128 | 129 | if d.detailedExitCode && seenAnyChanges { 130 | return Error{ 131 | error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), 132 | Code: 2, 133 | } 134 | } 135 | 136 | default: 137 | return errors.New("Invalid Arguments") 138 | } 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /cmd/rollback.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/databus23/helm-diff/v3/diff" 12 | "github.com/databus23/helm-diff/v3/manifest" 13 | ) 14 | 15 | type rollback struct { 16 | release string 17 | detailedExitCode bool 18 | revisions []string 19 | includeTests bool 20 | normalizeManifests bool 21 | diff.Options 22 | } 23 | 24 | const rollbackCmdLongUsage = ` 25 | This command compares the latest manifest details of a named release 26 | with specific revision values to rollback. 27 | 28 | It forecasts/visualizes changes, that a helm rollback could perform. 29 | ` 30 | 31 | func rollbackCmd() *cobra.Command { 32 | diff := rollback{} 33 | rollbackCmd := &cobra.Command{ 34 | Use: "rollback [flags] [RELEASE] [REVISION]", 35 | Short: "Show a diff explaining what a helm rollback could perform", 36 | Long: rollbackCmdLongUsage, 37 | Example: " helm diff rollback my-release 2", 38 | RunE: func(cmd *cobra.Command, args []string) error { 39 | // Suppress the command usage on error. See #77 for more info 40 | cmd.SilenceUsage = true 41 | 42 | if v, _ := cmd.Flags().GetBool("version"); v { 43 | fmt.Println(Version) 44 | return nil 45 | } 46 | 47 | if err := checkArgsLength(len(args), "release name", "revision number"); err != nil { 48 | return err 49 | } 50 | 51 | ProcessDiffOptions(cmd.Flags(), &diff.Options) 52 | 53 | diff.release = args[0] 54 | diff.revisions = args[1:] 55 | 56 | return diff.backcastHelm3() 57 | }, 58 | } 59 | 60 | rollbackCmd.Flags().BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") 61 | rollbackCmd.Flags().BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") 62 | rollbackCmd.Flags().BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") 63 | AddDiffOptions(rollbackCmd.Flags(), &diff.Options) 64 | 65 | rollbackCmd.SuggestionsMinimumDistance = 1 66 | 67 | return rollbackCmd 68 | } 69 | 70 | func (d *rollback) backcastHelm3() error { 71 | namespace := os.Getenv("HELM_NAMESPACE") 72 | excludes := []string{manifest.Helm3TestHook, manifest.Helm2TestSuccessHook} 73 | if d.includeTests { 74 | excludes = []string{} 75 | } 76 | // get manifest of the latest release 77 | releaseResponse, err := getRelease(d.release, namespace) 78 | 79 | if err != nil { 80 | return err 81 | } 82 | 83 | // get manifest of the release to rollback 84 | revision, _ := strconv.Atoi(d.revisions[0]) 85 | revisionResponse, err := getRevision(d.release, revision, namespace) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | // create a diff between the current manifest and the version of the manifest that a user is intended to rollback 91 | seenAnyChanges := diff.Manifests( 92 | manifest.Parse(string(releaseResponse), namespace, d.normalizeManifests, excludes...), 93 | manifest.Parse(string(revisionResponse), namespace, d.normalizeManifests, excludes...), 94 | &d.Options, 95 | os.Stdout) 96 | 97 | if d.detailedExitCode && seenAnyChanges { 98 | return Error{ 99 | error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), 100 | Code: 2, 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/gonvenience/bunt" 9 | "github.com/mgutz/ansi" 10 | "github.com/spf13/cobra" 11 | "golang.org/x/term" 12 | ) 13 | 14 | const rootCmdLongUsage = ` 15 | The Helm Diff Plugin 16 | 17 | * Shows a diff explaining what a helm upgrade would change: 18 | This fetches the currently deployed version of a release 19 | and compares it to a local chart plus values. This can be 20 | used to visualize what changes a helm upgrade will perform. 21 | 22 | * Shows a diff explaining what had changed between two revisions: 23 | This fetches previously deployed versions of a release 24 | and compares them. This can be used to visualize what changes 25 | were made during revision change. 26 | 27 | * Shows a diff explaining what a helm rollback would change: 28 | This fetches the currently deployed version of a release 29 | and compares it to the previously deployed version of the release, that you 30 | want to rollback. This can be used to visualize what changes a 31 | helm rollback will perform. 32 | ` 33 | 34 | // New creates a new cobra client 35 | func New() *cobra.Command { 36 | chartCommand := newChartCommand() 37 | 38 | cmd := &cobra.Command{ 39 | Use: "diff", 40 | Short: "Show manifest differences", 41 | Long: rootCmdLongUsage, 42 | //Alias root command to chart subcommand 43 | Args: chartCommand.Args, 44 | // parse the flags and check for actions like suppress-secrets, no-colors 45 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 46 | var fc *bool 47 | 48 | if cmd.Flags().Changed("color") { 49 | v, _ := cmd.Flags().GetBool("color") 50 | fc = &v 51 | } else { 52 | v, err := strconv.ParseBool(os.Getenv("HELM_DIFF_COLOR")) 53 | if err == nil { 54 | fc = &v 55 | } 56 | } 57 | 58 | if !cmd.Flags().Changed("output") { 59 | v, set := os.LookupEnv("HELM_DIFF_OUTPUT") 60 | if set && strings.TrimSpace(v) != "" { 61 | _ = cmd.Flags().Set("output", v) 62 | } 63 | } 64 | 65 | // Dyff relies on bunt, default to color=on 66 | bunt.SetColorSettings(bunt.ON, bunt.ON) 67 | nc, _ := cmd.Flags().GetBool("no-color") 68 | 69 | if nc || (fc != nil && !*fc) { 70 | ansi.DisableColors(true) 71 | bunt.SetColorSettings(bunt.OFF, bunt.OFF) 72 | } else if !cmd.Flags().Changed("no-color") && fc == nil { 73 | term := term.IsTerminal(int(os.Stdout.Fd())) 74 | // https://github.com/databus23/helm-diff/issues/281 75 | dumb := os.Getenv("TERM") == "dumb" 76 | ansi.DisableColors(!term || dumb) 77 | bunt.SetColorSettings(bunt.OFF, bunt.OFF) 78 | } 79 | }, 80 | RunE: func(cmd *cobra.Command, args []string) error { 81 | cmd.Println(`Command "helm diff" is deprecated, use "helm diff upgrade" instead`) 82 | return chartCommand.RunE(cmd, args) 83 | }, 84 | } 85 | 86 | // add no-color as global flag 87 | cmd.PersistentFlags().Bool("no-color", false, "remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not \"dumb\"") 88 | cmd.PersistentFlags().Bool("color", false, "color output. You can control the value for this flag via HELM_DIFF_COLOR=[true|false]. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not \"dumb\"") 89 | // add flagset from chartCommand 90 | cmd.Flags().AddFlagSet(chartCommand.Flags()) 91 | cmd.AddCommand(newVersionCmd(), chartCommand) 92 | // add subcommands 93 | cmd.AddCommand( 94 | revisionCmd(), 95 | rollbackCmd(), 96 | releaseCmd(), 97 | ) 98 | cmd.SetHelpCommand(&cobra.Command{}) // Disable the help command 99 | return cmd 100 | } 101 | -------------------------------------------------------------------------------- /cmd/upgrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "slices" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/spf13/cobra" 14 | "helm.sh/helm/v3/pkg/action" 15 | "helm.sh/helm/v3/pkg/cli" 16 | "helm.sh/helm/v3/pkg/kube" 17 | apierrors "k8s.io/apimachinery/pkg/api/errors" 18 | "k8s.io/cli-runtime/pkg/resource" 19 | 20 | "github.com/databus23/helm-diff/v3/diff" 21 | "github.com/databus23/helm-diff/v3/manifest" 22 | ) 23 | 24 | var ( 25 | validDryRunValues = []string{"server", "client", "true", "false"} 26 | ) 27 | 28 | const ( 29 | dryRunNoOptDefVal = "client" 30 | ) 31 | 32 | type diffCmd struct { 33 | release string 34 | chart string 35 | chartVersion string 36 | chartRepo string 37 | detailedExitCode bool 38 | devel bool 39 | disableValidation bool 40 | disableOpenAPIValidation bool 41 | enableDNS bool 42 | SkipSchemaValidation bool 43 | namespace string // namespace to assume the release to be installed into. Defaults to the current kube config namespace. 44 | valueFiles valueFiles 45 | values []string 46 | stringValues []string 47 | stringLiteralValues []string 48 | jsonValues []string 49 | fileValues []string 50 | reuseValues bool 51 | resetValues bool 52 | resetThenReuseValues bool 53 | allowUnreleased bool 54 | noHooks bool 55 | includeTests bool 56 | includeCRDs bool 57 | postRenderer string 58 | postRendererArgs []string 59 | insecureSkipTLSVerify bool 60 | install bool 61 | normalizeManifests bool 62 | takeOwnership bool 63 | threeWayMerge bool 64 | extraAPIs []string 65 | kubeVersion string 66 | useUpgradeDryRun bool 67 | diff.Options 68 | 69 | // dryRunMode can take the following values: 70 | // - "none": no dry run is performed 71 | // - "client": dry run is performed without remote cluster access 72 | // - "server": dry run is performed with remote cluster access 73 | // - "true": same as "client" 74 | // - "false": same as "none" 75 | dryRunMode string 76 | } 77 | 78 | func (d *diffCmd) isAllowUnreleased() bool { 79 | // helm update --install is effectively the same as helm-diff's --allow-unreleased option, 80 | // support both so that helm diff plugin can be applied on the same command 81 | // https://github.com/databus23/helm-diff/issues/108 82 | return d.allowUnreleased || d.install 83 | } 84 | 85 | // clusterAccessAllowed returns true if the diff command is allowed to access the cluster at some degree. 86 | // 87 | // helm-diff basically have 2 modes of operation: 88 | // 1. without cluster access at all when --dry-run=true or --dry-run=client is specified. 89 | // 2. with cluster access when --dry-run is unspecified, false, or server. 90 | // 91 | // clusterAccessAllowed returns true when the mode is either 2 or 3. 92 | // 93 | // If false, helm-diff should not access the cluster at all. 94 | // More concretely: 95 | // - It shouldn't pass --validate to helm-template because it requires cluster access. 96 | // - It shouldn't get the current release manifest using helm-get-manifest because it requires cluster access. 97 | // - It shouldn't get the current release hooks using helm-get-hooks because it requires cluster access. 98 | // - It shouldn't get the current release values using helm-get-values because it requires cluster access. 99 | // 100 | // See also https://github.com/helm/helm/pull/9426#discussion_r1181397259 101 | func (d *diffCmd) clusterAccessAllowed() bool { 102 | return d.dryRunMode == "none" || d.dryRunMode == "false" || d.dryRunMode == "server" 103 | } 104 | 105 | const globalUsage = `Show a diff explaining what a helm upgrade would change. 106 | 107 | This fetches the currently deployed version of a release 108 | and compares it to a chart plus values. 109 | This can be used to visualize what changes a helm upgrade will 110 | perform. 111 | ` 112 | 113 | var envSettings = cli.New() 114 | 115 | func newChartCommand() *cobra.Command { 116 | diff := diffCmd{ 117 | namespace: os.Getenv("HELM_NAMESPACE"), 118 | } 119 | unknownFlags := os.Getenv("HELM_DIFF_IGNORE_UNKNOWN_FLAGS") == "true" 120 | 121 | cmd := &cobra.Command{ 122 | Use: "upgrade [flags] [RELEASE] [CHART]", 123 | Short: "Show a diff explaining what a helm upgrade would change.", 124 | Long: globalUsage, 125 | Example: strings.Join([]string{ 126 | " helm diff upgrade my-release stable/postgresql --values values.yaml", 127 | "", 128 | " # Set HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true to ignore unknown flags", 129 | " # It's useful when you're using `helm-diff` in a `helm upgrade` wrapper.", 130 | " # See https://github.com/databus23/helm-diff/issues/278 for more information.", 131 | " HELM_DIFF_IGNORE_UNKNOWN_FLAGS=true helm diff upgrade my-release stable/postgres --wait", 132 | "", 133 | " # Set HELM_DIFF_USE_UPGRADE_DRY_RUN=true to", 134 | " # use `helm upgrade --dry-run` instead of `helm template` to render manifests from the chart.", 135 | " # See https://github.com/databus23/helm-diff/issues/253 for more information.", 136 | " HELM_DIFF_USE_UPGRADE_DRY_RUN=true helm diff upgrade my-release datadog/datadog", 137 | "", 138 | " # Set HELM_DIFF_THREE_WAY_MERGE=true to", 139 | " # enable the three-way-merge on diff.", 140 | " # This is equivalent to specifying the --three-way-merge flag.", 141 | " # Read the flag usage below for more information on --three-way-merge.", 142 | " HELM_DIFF_THREE_WAY_MERGE=true helm diff upgrade my-release datadog/datadog", 143 | "", 144 | " # Set HELM_DIFF_NORMALIZE_MANIFESTS=true to", 145 | " # normalize the yaml file content when using helm diff.", 146 | " # This is equivalent to specifying the --normalize-manifests flag.", 147 | " # Read the flag usage below for more information on --normalize-manifests.", 148 | " HELM_DIFF_NORMALIZE_MANIFESTS=true helm diff upgrade my-release datadog/datadog", 149 | "", 150 | "# Set HELM_DIFF_OUTPUT_CONTEXT=n to configure the output context to n lines.", 151 | "# This is equivalent to specifying the --context flag.", 152 | "# Read the flag usage below for more information on --context.", 153 | "HELM_DIFF_OUTPUT_CONTEXT=5 helm diff upgrade my-release datadog/datadog", 154 | }, "\n"), 155 | Args: func(cmd *cobra.Command, args []string) error { 156 | return checkArgsLength(len(args), "release name", "chart path") 157 | }, 158 | RunE: func(cmd *cobra.Command, args []string) error { 159 | if diff.dryRunMode == "" { 160 | diff.dryRunMode = "none" 161 | } else if !slices.Contains(validDryRunValues, diff.dryRunMode) { 162 | return fmt.Errorf("flag %q must take a bool value or either %q or %q, but got %q", "dry-run", "client", "server", diff.dryRunMode) 163 | } 164 | 165 | // Suppress the command usage on error. See #77 for more info 166 | cmd.SilenceUsage = true 167 | 168 | // See https://github.com/databus23/helm-diff/issues/253 169 | diff.useUpgradeDryRun = os.Getenv("HELM_DIFF_USE_UPGRADE_DRY_RUN") == "true" 170 | 171 | if !diff.threeWayMerge && !cmd.Flags().Changed("three-way-merge") { 172 | enabled := os.Getenv("HELM_DIFF_THREE_WAY_MERGE") == "true" 173 | diff.threeWayMerge = enabled 174 | 175 | if enabled { 176 | fmt.Fprintf(os.Stderr, "Enabled three way merge via the envvar\n") 177 | } 178 | } 179 | 180 | if !diff.normalizeManifests && !cmd.Flags().Changed("normalize-manifests") { 181 | enabled := os.Getenv("HELM_DIFF_NORMALIZE_MANIFESTS") == "true" 182 | diff.normalizeManifests = enabled 183 | 184 | if enabled { 185 | fmt.Fprintf(os.Stderr, "Enabled normalize manifests via the envvar\n") 186 | } 187 | } 188 | 189 | if diff.OutputContext == -1 && !cmd.Flags().Changed("context") { 190 | contextEnvVar := os.Getenv("HELM_DIFF_OUTPUT_CONTEXT") 191 | if contextEnvVar != "" { 192 | context, err := strconv.Atoi(contextEnvVar) 193 | if err == nil { 194 | diff.OutputContext = context 195 | } 196 | } 197 | } 198 | 199 | ProcessDiffOptions(cmd.Flags(), &diff.Options) 200 | 201 | diff.release = args[0] 202 | diff.chart = args[1] 203 | return diff.runHelm3() 204 | }, 205 | FParseErrWhitelist: cobra.FParseErrWhitelist{ 206 | UnknownFlags: unknownFlags, 207 | }, 208 | } 209 | 210 | f := cmd.Flags() 211 | var kubeconfig string 212 | f.StringVar(&kubeconfig, "kubeconfig", "", "This flag is ignored, to allow passing of this top level flag to helm") 213 | f.BoolVar(&diff.threeWayMerge, "three-way-merge", false, "use three-way-merge to compute patch and generate diff output") 214 | // f.StringVar(&diff.kubeContext, "kube-context", "", "name of the kubeconfig context to use") 215 | f.StringVar(&diff.chartVersion, "version", "", "specify the exact chart version to use. If this is not specified, the latest version is used") 216 | f.StringVar(&diff.chartRepo, "repo", "", "specify the chart repository url to locate the requested chart") 217 | f.BoolVar(&diff.detailedExitCode, "detailed-exitcode", false, "return a non-zero exit code when there are changes") 218 | // See the below links for more context on when to use this flag 219 | // - https://github.com/helm/helm/blob/d9ffe37d371c9d06448c55c852c800051830e49a/cmd/helm/template.go#L184 220 | // - https://github.com/databus23/helm-diff/issues/318 221 | f.StringArrayVarP(&diff.extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions") 222 | // Support for kube-version was re-enabled and ported from helm2 to helm3 on https://github.com/helm/helm/pull/9040 223 | f.StringVar(&diff.kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") 224 | f.VarP(&diff.valueFiles, "values", "f", "specify values in a YAML file (can specify multiple)") 225 | f.StringArrayVar(&diff.values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") 226 | f.StringArrayVar(&diff.stringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") 227 | f.StringArrayVar(&diff.stringLiteralValues, "set-literal", []string{}, "set STRING literal values on the command line") 228 | f.StringArrayVar(&diff.jsonValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)") 229 | f.StringArrayVar(&diff.fileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)") 230 | f.BoolVar(&diff.reuseValues, "reuse-values", false, "reuse the last release's values and merge in any new values. If '--reset-values' is specified, this is ignored") 231 | f.BoolVar(&diff.resetValues, "reset-values", false, "reset the values to the ones built into the chart and merge in any new values") 232 | f.BoolVar(&diff.resetThenReuseValues, "reset-then-reuse-values", false, "reset the values to the ones built into the chart, apply the last release's values and merge in any new values. If '--reset-values' or '--reuse-values' is specified, this is ignored") 233 | f.BoolVar(&diff.allowUnreleased, "allow-unreleased", false, "enables diffing of releases that are not yet deployed via Helm") 234 | f.BoolVar(&diff.install, "install", false, "enables diffing of releases that are not yet deployed via Helm (equivalent to --allow-unreleased, added to match \"helm upgrade --install\" command") 235 | f.BoolVar(&diff.noHooks, "no-hooks", false, "disable diffing of hooks") 236 | f.BoolVar(&diff.includeTests, "include-tests", false, "enable the diffing of the helm test hooks") 237 | f.BoolVar(&diff.includeCRDs, "include-crds", false, "include CRDs in the diffing") 238 | f.BoolVar(&diff.devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored.") 239 | f.BoolVar(&diff.disableValidation, "disable-validation", false, "disables rendered templates validation against the Kubernetes cluster you are currently pointing to. This is the same validation performed on an install") 240 | f.BoolVar(&diff.disableOpenAPIValidation, "disable-openapi-validation", false, "disables rendered templates validation against the Kubernetes OpenAPI Schema") 241 | f.StringVar(&diff.dryRunMode, "dry-run", "", "--dry-run, --dry-run=client, or --dry-run=true disables cluster access and show diff as if it was install. Implies --install, --reset-values, and --disable-validation."+ 242 | " --dry-run=server enables the cluster access with helm-get and the lookup template function.") 243 | f.Lookup("dry-run").NoOptDefVal = dryRunNoOptDefVal 244 | f.BoolVar(&diff.enableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") 245 | f.BoolVar(&diff.SkipSchemaValidation, "skip-schema-validation", false, "skip validation of the rendered manifests against the Kubernetes OpenAPI schema") 246 | f.StringVar(&diff.postRenderer, "post-renderer", "", "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") 247 | f.StringArrayVar(&diff.postRendererArgs, "post-renderer-args", []string{}, "an argument to the post-renderer (can specify multiple)") 248 | f.BoolVar(&diff.insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download") 249 | f.BoolVar(&diff.normalizeManifests, "normalize-manifests", false, "normalize manifests before running diff to exclude style differences from the output") 250 | f.BoolVar(&diff.takeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources") 251 | 252 | AddDiffOptions(f, &diff.Options) 253 | 254 | return cmd 255 | } 256 | 257 | func (d *diffCmd) runHelm3() error { 258 | if err := compatibleHelm3Version(); err != nil { 259 | return err 260 | } 261 | 262 | var releaseManifest []byte 263 | 264 | var err error 265 | 266 | if d.takeOwnership { 267 | // We need to do a three way merge between the manifests of the new 268 | // release, the manifests of the old release and what is currently deployed 269 | d.threeWayMerge = true 270 | } 271 | 272 | if d.clusterAccessAllowed() { 273 | releaseManifest, err = getRelease(d.release, d.namespace) 274 | } 275 | 276 | var newInstall bool 277 | if err != nil && strings.Contains(err.Error(), "release: not found") { 278 | if d.isAllowUnreleased() { 279 | fmt.Fprintf(os.Stderr, "********************\n\n\tRelease was not present in Helm. Diff will show entire contents as new.\n\n********************\n") 280 | newInstall = true 281 | err = nil 282 | } else { 283 | fmt.Fprintf(os.Stderr, "********************\n\n\tRelease was not present in Helm. Include the `--allow-unreleased` to perform diff without exiting in error.\n\n********************\n") 284 | return err 285 | } 286 | } 287 | if err != nil { 288 | return fmt.Errorf("Failed to get release %s in namespace %s: %w", d.release, d.namespace, err) 289 | } 290 | 291 | installManifest, err := d.template(!newInstall) 292 | if err != nil { 293 | return fmt.Errorf("Failed to render chart: %w", err) 294 | } 295 | 296 | var actionConfig *action.Configuration 297 | if d.threeWayMerge || d.takeOwnership { 298 | actionConfig = new(action.Configuration) 299 | if err := actionConfig.Init(envSettings.RESTClientGetter(), envSettings.Namespace(), os.Getenv("HELM_DRIVER"), log.Printf); err != nil { 300 | log.Fatalf("%+v", err) 301 | } 302 | if err := actionConfig.KubeClient.IsReachable(); err != nil { 303 | return err 304 | } 305 | } 306 | 307 | if d.threeWayMerge { 308 | releaseManifest, installManifest, err = manifest.Generate(actionConfig, releaseManifest, installManifest) 309 | if err != nil { 310 | return fmt.Errorf("unable to generate manifests: %w", err) 311 | } 312 | } 313 | 314 | currentSpecs := make(map[string]*manifest.MappingResult) 315 | if !newInstall && d.clusterAccessAllowed() { 316 | if !d.noHooks && !d.threeWayMerge { 317 | hooks, err := getHooks(d.release, d.namespace) 318 | if err != nil { 319 | return err 320 | } 321 | releaseManifest = append(releaseManifest, hooks...) 322 | } 323 | if d.includeTests { 324 | currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests) 325 | } else { 326 | currentSpecs = manifest.Parse(string(releaseManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook) 327 | } 328 | } 329 | 330 | var newOwnedReleases map[string]diff.OwnershipDiff 331 | if d.takeOwnership { 332 | resources, err := actionConfig.KubeClient.Build(bytes.NewBuffer(installManifest), false) 333 | if err != nil { 334 | return err 335 | } 336 | newOwnedReleases, err = checkOwnership(d, resources, currentSpecs) 337 | if err != nil { 338 | return err 339 | } 340 | } 341 | 342 | var newSpecs map[string]*manifest.MappingResult 343 | if d.includeTests { 344 | newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests) 345 | } else { 346 | newSpecs = manifest.Parse(string(installManifest), d.namespace, d.normalizeManifests, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook) 347 | } 348 | 349 | seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout) 350 | 351 | if d.detailedExitCode && seenAnyChanges { 352 | return Error{ 353 | error: errors.New("identified at least one change, exiting with non-zero exit code (detailed-exitcode parameter enabled)"), 354 | Code: 2, 355 | } 356 | } 357 | 358 | return nil 359 | } 360 | 361 | func checkOwnership(d *diffCmd, resources kube.ResourceList, currentSpecs map[string]*manifest.MappingResult) (map[string]diff.OwnershipDiff, error) { 362 | newOwnedReleases := make(map[string]diff.OwnershipDiff) 363 | err := resources.Visit(func(info *resource.Info, err error) error { 364 | if err != nil { 365 | return err 366 | } 367 | 368 | helper := resource.NewHelper(info.Client, info.Mapping) 369 | currentObj, err := helper.Get(info.Namespace, info.Name) 370 | if err != nil { 371 | if !apierrors.IsNotFound(err) { 372 | return err 373 | } 374 | return nil 375 | } 376 | 377 | var result *manifest.MappingResult 378 | var oldRelease string 379 | if d.includeTests { 380 | result, oldRelease, err = manifest.ParseObject(currentObj, d.namespace) 381 | } else { 382 | result, oldRelease, err = manifest.ParseObject(currentObj, d.namespace, manifest.Helm3TestHook, manifest.Helm2TestSuccessHook) 383 | } 384 | 385 | if err != nil { 386 | return err 387 | } 388 | 389 | newRelease := d.namespace + "/" + d.release 390 | if oldRelease == newRelease { 391 | return nil 392 | } 393 | 394 | newOwnedReleases[result.Name] = diff.OwnershipDiff{ 395 | OldRelease: oldRelease, 396 | NewRelease: newRelease, 397 | } 398 | currentSpecs[result.Name] = result 399 | 400 | return nil 401 | }) 402 | return newOwnedReleases, err 403 | } 404 | -------------------------------------------------------------------------------- /cmd/upgrade_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "testing" 4 | 5 | func TestIsRemoteAccessAllowed(t *testing.T) { 6 | cases := []struct { 7 | name string 8 | cmd diffCmd 9 | expected bool 10 | }{ 11 | { 12 | name: "no flags", 13 | cmd: diffCmd{ 14 | dryRunMode: "none", 15 | }, 16 | expected: true, 17 | }, 18 | { 19 | name: "legacy explicit dry-run=true flag", 20 | cmd: diffCmd{ 21 | dryRunMode: "true", 22 | }, 23 | expected: false, 24 | }, 25 | { 26 | name: "legacy explicit dry-run=false flag", 27 | cmd: diffCmd{ 28 | dryRunMode: "false", 29 | }, 30 | expected: true, 31 | }, 32 | { 33 | name: "legacy empty dry-run flag", 34 | cmd: diffCmd{ 35 | dryRunMode: dryRunNoOptDefVal, 36 | }, 37 | expected: false, 38 | }, 39 | { 40 | name: "server-side dry-run flag", 41 | cmd: diffCmd{ 42 | dryRunMode: "server", 43 | }, 44 | expected: true, 45 | }, 46 | { 47 | name: "client-side dry-run flag", 48 | cmd: diffCmd{ 49 | dryRunMode: "client", 50 | }, 51 | expected: false, 52 | }, 53 | } 54 | 55 | for _, tc := range cases { 56 | t.Run(tc.name, func(t *testing.T) { 57 | actual := tc.cmd.clusterAccessAllowed() 58 | if actual != tc.expected { 59 | t.Errorf("Expected %v, got %v", tc.expected, actual) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // Version identifier populated via the CI/CD process. 10 | var Version = "HEAD" 11 | 12 | func newVersionCmd() *cobra.Command { 13 | return &cobra.Command{ 14 | Use: "version", 15 | Short: "Show version of the helm diff plugin", 16 | Run: func(*cobra.Command, []string) { 17 | fmt.Println(Version) 18 | }, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /diff/constant.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | const defaultTemplateReport = `[ 4 | {{- $global := . -}} 5 | {{- range $idx, $entry := . -}} 6 | { 7 | "api": "{{ $entry.API }}", 8 | "kind": "{{ $entry.Kind }}", 9 | "namespace": "{{ $entry.Namespace }}", 10 | "name": "{{ $entry.Name }}", 11 | "change": "{{ $entry.Change }}" 12 | }{{ if not (last $idx $global) }},{{ end }} 13 | {{- end }}]` 14 | -------------------------------------------------------------------------------- /diff/diff.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "math" 8 | "regexp" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/aryann/difflib" 13 | "github.com/mgutz/ansi" 14 | v1 "k8s.io/api/core/v1" 15 | "k8s.io/apimachinery/pkg/runtime/serializer/json" 16 | "k8s.io/apimachinery/pkg/util/yaml" 17 | "k8s.io/client-go/kubernetes/scheme" 18 | 19 | "github.com/databus23/helm-diff/v3/manifest" 20 | ) 21 | 22 | // Options are all the options to be passed to generate a diff 23 | type Options struct { 24 | OutputFormat string 25 | OutputContext int 26 | StripTrailingCR bool 27 | ShowSecrets bool 28 | ShowSecretsDecoded bool 29 | SuppressedKinds []string 30 | FindRenames float32 31 | SuppressedOutputLineRegex []string 32 | } 33 | 34 | type OwnershipDiff struct { 35 | OldRelease string 36 | NewRelease string 37 | } 38 | 39 | // Manifests diff on manifests 40 | func Manifests(oldIndex, newIndex map[string]*manifest.MappingResult, options *Options, to io.Writer) bool { 41 | return ManifestsOwnership(oldIndex, newIndex, nil, options, to) 42 | } 43 | 44 | func ManifestsOwnership(oldIndex, newIndex map[string]*manifest.MappingResult, newOwnedReleases map[string]OwnershipDiff, options *Options, to io.Writer) bool { 45 | report := Report{} 46 | report.setupReportFormat(options.OutputFormat) 47 | var possiblyRemoved []string 48 | 49 | for name, diff := range newOwnedReleases { 50 | diff := diffStrings(diff.OldRelease, diff.NewRelease, true) 51 | report.addEntry(name, options.SuppressedKinds, "", 0, diff, "OWNERSHIP") 52 | } 53 | 54 | for _, key := range sortedKeys(oldIndex) { 55 | oldContent := oldIndex[key] 56 | 57 | if newContent, ok := newIndex[key]; ok { 58 | // modified? 59 | doDiff(&report, key, oldContent, newContent, options) 60 | } else { 61 | possiblyRemoved = append(possiblyRemoved, key) 62 | } 63 | } 64 | 65 | var possiblyAdded []string 66 | for _, key := range sortedKeys(newIndex) { 67 | if _, ok := oldIndex[key]; !ok { 68 | possiblyAdded = append(possiblyAdded, key) 69 | } 70 | } 71 | 72 | removed, added := contentSearch(&report, possiblyRemoved, oldIndex, possiblyAdded, newIndex, options) 73 | 74 | for _, key := range removed { 75 | oldContent := oldIndex[key] 76 | if oldContent.ResourcePolicy != "keep" { 77 | doDiff(&report, key, oldContent, nil, options) 78 | } 79 | } 80 | 81 | for _, key := range added { 82 | newContent := newIndex[key] 83 | doDiff(&report, key, nil, newContent, options) 84 | } 85 | 86 | seenAnyChanges := len(report.entries) > 0 87 | 88 | report, err := doSuppress(report, options.SuppressedOutputLineRegex) 89 | if err != nil { 90 | panic(err) 91 | } 92 | 93 | report.print(to) 94 | report.clean() 95 | return seenAnyChanges 96 | } 97 | 98 | func doSuppress(report Report, suppressedOutputLineRegex []string) (Report, error) { 99 | if len(report.entries) == 0 || len(suppressedOutputLineRegex) == 0 { 100 | return report, nil 101 | } 102 | 103 | filteredReport := Report{} 104 | filteredReport.format = report.format 105 | filteredReport.entries = []ReportEntry{} 106 | 107 | var suppressOutputRegexes []*regexp.Regexp 108 | 109 | for _, suppressOutputRegex := range suppressedOutputLineRegex { 110 | regex, err := regexp.Compile(suppressOutputRegex) 111 | if err != nil { 112 | return Report{}, err 113 | } 114 | 115 | suppressOutputRegexes = append(suppressOutputRegexes, regex) 116 | } 117 | 118 | for _, entry := range report.entries { 119 | var diffs []difflib.DiffRecord 120 | 121 | DIFFS: 122 | for _, diff := range entry.diffs { 123 | for _, suppressOutputRegex := range suppressOutputRegexes { 124 | if suppressOutputRegex.MatchString(diff.Payload) { 125 | continue DIFFS 126 | } 127 | } 128 | 129 | diffs = append(diffs, diff) 130 | } 131 | 132 | containsDiff := false 133 | 134 | // Add entry to the report, if diffs are present. 135 | for _, diff := range diffs { 136 | if diff.Delta.String() != " " { 137 | containsDiff = true 138 | break 139 | } 140 | } 141 | 142 | diffRecords := []difflib.DiffRecord{} 143 | switch { 144 | case containsDiff: 145 | diffRecords = diffs 146 | case entry.changeType == "MODIFY": 147 | entry.changeType = "MODIFY_SUPPRESSED" 148 | } 149 | 150 | filteredReport.addEntry(entry.key, entry.suppressedKinds, entry.kind, entry.context, diffRecords, entry.changeType) 151 | } 152 | 153 | return filteredReport, nil 154 | } 155 | 156 | func actualChanges(diff []difflib.DiffRecord) int { 157 | changes := 0 158 | for _, record := range diff { 159 | if record.Delta != difflib.Common { 160 | changes++ 161 | } 162 | } 163 | return changes 164 | } 165 | 166 | func contentSearch(report *Report, possiblyRemoved []string, oldIndex map[string]*manifest.MappingResult, possiblyAdded []string, newIndex map[string]*manifest.MappingResult, options *Options) ([]string, []string) { 167 | if options.FindRenames <= 0 { 168 | return possiblyRemoved, possiblyAdded 169 | } 170 | 171 | var removed []string 172 | 173 | for _, removedKey := range possiblyRemoved { 174 | oldContent := oldIndex[removedKey] 175 | var smallestKey string 176 | var smallestFraction float32 = math.MaxFloat32 177 | for _, addedKey := range possiblyAdded { 178 | newContent := newIndex[addedKey] 179 | if oldContent.Kind != newContent.Kind { 180 | continue 181 | } 182 | 183 | switch { 184 | case options.ShowSecretsDecoded: 185 | decodeSecrets(oldContent, newContent) 186 | case !options.ShowSecrets: 187 | redactSecrets(oldContent, newContent) 188 | } 189 | 190 | diff := diffMappingResults(oldContent, newContent, options.StripTrailingCR) 191 | delta := actualChanges(diff) 192 | if delta == 0 || len(diff) == 0 { 193 | continue // Should never happen, but better safe than sorry 194 | } 195 | fraction := float32(delta) / float32(len(diff)) 196 | if fraction > 0 && fraction < smallestFraction { 197 | smallestKey = addedKey 198 | smallestFraction = fraction 199 | } 200 | } 201 | 202 | if smallestFraction < options.FindRenames { 203 | index := sort.SearchStrings(possiblyAdded, smallestKey) 204 | possiblyAdded = append(possiblyAdded[:index], possiblyAdded[index+1:]...) 205 | newContent := newIndex[smallestKey] 206 | doDiff(report, removedKey, oldContent, newContent, options) 207 | } else { 208 | removed = append(removed, removedKey) 209 | } 210 | } 211 | 212 | return removed, possiblyAdded 213 | } 214 | 215 | func doDiff(report *Report, key string, oldContent *manifest.MappingResult, newContent *manifest.MappingResult, options *Options) { 216 | if oldContent != nil && newContent != nil && oldContent.Content == newContent.Content { 217 | return 218 | } 219 | switch { 220 | case options.ShowSecretsDecoded: 221 | decodeSecrets(oldContent, newContent) 222 | case !options.ShowSecrets: 223 | redactSecrets(oldContent, newContent) 224 | } 225 | 226 | if oldContent == nil { 227 | emptyMapping := &manifest.MappingResult{} 228 | diffs := diffMappingResults(emptyMapping, newContent, options.StripTrailingCR) 229 | report.addEntry(key, options.SuppressedKinds, newContent.Kind, options.OutputContext, diffs, "ADD") 230 | } else if newContent == nil { 231 | emptyMapping := &manifest.MappingResult{} 232 | diffs := diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR) 233 | report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "REMOVE") 234 | } else { 235 | diffs := diffMappingResults(oldContent, newContent, options.StripTrailingCR) 236 | if actualChanges(diffs) > 0 { 237 | report.addEntry(key, options.SuppressedKinds, oldContent.Kind, options.OutputContext, diffs, "MODIFY") 238 | } 239 | } 240 | } 241 | 242 | func preHandleSecrets(old, new *manifest.MappingResult) (v1.Secret, v1.Secret, error, error) { 243 | var oldSecretDecodeErr, newSecretDecodeErr error 244 | var oldSecret, newSecret v1.Secret 245 | if old != nil { 246 | oldSecretDecodeErr = yaml.NewYAMLToJSONDecoder(bytes.NewBufferString(old.Content)).Decode(&oldSecret) 247 | if oldSecretDecodeErr != nil { 248 | old.Content = fmt.Sprintf("Error parsing old secret: %s", oldSecretDecodeErr) 249 | } else { 250 | //if we have a Secret containing `stringData`, apply the same 251 | //transformation that the apiserver would do with it (this protects 252 | //stringData keys from being overwritten down below) 253 | if len(oldSecret.StringData) > 0 && oldSecret.Data == nil { 254 | oldSecret.Data = make(map[string][]byte, len(oldSecret.StringData)) 255 | } 256 | for k, v := range oldSecret.StringData { 257 | oldSecret.Data[k] = []byte(v) 258 | } 259 | } 260 | } 261 | if new != nil { 262 | newSecretDecodeErr = yaml.NewYAMLToJSONDecoder(bytes.NewBufferString(new.Content)).Decode(&newSecret) 263 | if newSecretDecodeErr != nil { 264 | new.Content = fmt.Sprintf("Error parsing new secret: %s", newSecretDecodeErr) 265 | } else { 266 | //same as above 267 | if len(newSecret.StringData) > 0 && newSecret.Data == nil { 268 | newSecret.Data = make(map[string][]byte, len(newSecret.StringData)) 269 | } 270 | for k, v := range newSecret.StringData { 271 | newSecret.Data[k] = []byte(v) 272 | } 273 | } 274 | } 275 | return oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr 276 | } 277 | 278 | // redactSecrets redacts secrets from the diff output. 279 | func redactSecrets(old, new *manifest.MappingResult) { 280 | if (old != nil && old.Kind != "Secret") || (new != nil && new.Kind != "Secret") { 281 | return 282 | } 283 | serializer := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) 284 | 285 | oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr := preHandleSecrets(old, new) 286 | 287 | if old != nil && oldSecretDecodeErr == nil { 288 | oldSecret.StringData = make(map[string]string, len(oldSecret.Data)) 289 | for k, v := range oldSecret.Data { 290 | if new != nil && bytes.Equal(v, newSecret.Data[k]) { 291 | oldSecret.StringData[k] = fmt.Sprintf("REDACTED # (%d bytes)", len(v)) 292 | } else { 293 | oldSecret.StringData[k] = fmt.Sprintf("-------- # (%d bytes)", len(v)) 294 | } 295 | } 296 | } 297 | if new != nil && newSecretDecodeErr == nil { 298 | newSecret.StringData = make(map[string]string, len(newSecret.Data)) 299 | for k, v := range newSecret.Data { 300 | if old != nil && bytes.Equal(v, oldSecret.Data[k]) { 301 | newSecret.StringData[k] = fmt.Sprintf("REDACTED # (%d bytes)", len(v)) 302 | } else { 303 | newSecret.StringData[k] = fmt.Sprintf("++++++++ # (%d bytes)", len(v)) 304 | } 305 | } 306 | } 307 | 308 | // remove Data field now that we are using StringData for serialization 309 | if old != nil && oldSecretDecodeErr == nil { 310 | oldSecretBuf := bytes.NewBuffer(nil) 311 | oldSecret.Data = nil 312 | if err := serializer.Encode(&oldSecret, oldSecretBuf); err != nil { 313 | new.Content = fmt.Sprintf("Error encoding new secret: %s", err) 314 | } 315 | old.Content = getComment(old.Content) + strings.Replace(strings.Replace(oldSecretBuf.String(), "stringData", "data", 1), " creationTimestamp: null\n", "", 1) 316 | oldSecretBuf.Reset() 317 | } 318 | if new != nil && newSecretDecodeErr == nil { 319 | newSecretBuf := bytes.NewBuffer(nil) 320 | newSecret.Data = nil 321 | if err := serializer.Encode(&newSecret, newSecretBuf); err != nil { 322 | new.Content = fmt.Sprintf("Error encoding new secret: %s", err) 323 | } 324 | new.Content = getComment(new.Content) + strings.Replace(strings.Replace(newSecretBuf.String(), "stringData", "data", 1), " creationTimestamp: null\n", "", 1) 325 | newSecretBuf.Reset() 326 | } 327 | } 328 | 329 | // decodeSecrets decodes secrets from the diff output. 330 | func decodeSecrets(old, new *manifest.MappingResult) { 331 | if (old != nil && old.Kind != "Secret") || (new != nil && new.Kind != "Secret") { 332 | return 333 | } 334 | serializer := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) 335 | 336 | oldSecret, newSecret, oldSecretDecodeErr, newSecretDecodeErr := preHandleSecrets(old, new) 337 | 338 | if old != nil && oldSecretDecodeErr == nil { 339 | oldSecret.StringData = make(map[string]string, len(oldSecret.Data)) 340 | for k, v := range oldSecret.Data { 341 | oldSecret.StringData[k] = string(v) 342 | } 343 | } 344 | if new != nil && newSecretDecodeErr == nil { 345 | newSecret.StringData = make(map[string]string, len(newSecret.Data)) 346 | for k, v := range newSecret.Data { 347 | newSecret.StringData[k] = string(v) 348 | } 349 | } 350 | 351 | // remove Data field now that we are using StringData for serialization 352 | if old != nil && oldSecretDecodeErr == nil { 353 | oldSecretBuf := bytes.NewBuffer(nil) 354 | oldSecret.Data = nil 355 | if err := serializer.Encode(&oldSecret, oldSecretBuf); err != nil { 356 | new.Content = fmt.Sprintf("Error encoding new secret: %s", err) 357 | } 358 | old.Content = getComment(old.Content) + strings.Replace(oldSecretBuf.String(), " creationTimestamp: null\n", "", 1) 359 | oldSecretBuf.Reset() 360 | } 361 | if new != nil && newSecretDecodeErr == nil { 362 | newSecretBuf := bytes.NewBuffer(nil) 363 | newSecret.Data = nil 364 | if err := serializer.Encode(&newSecret, newSecretBuf); err != nil { 365 | new.Content = fmt.Sprintf("Error encoding new secret: %s", err) 366 | } 367 | new.Content = getComment(new.Content) + strings.Replace(newSecretBuf.String(), " creationTimestamp: null\n", "", 1) 368 | newSecretBuf.Reset() 369 | } 370 | } 371 | 372 | // return the first line of a string if its a comment. 373 | // This gives as the # Source: lines from the rendering 374 | func getComment(s string) string { 375 | i := strings.Index(s, "\n") 376 | if i < 0 || !strings.HasPrefix(s, "#") { 377 | return "" 378 | } 379 | return s[:i+1] 380 | } 381 | 382 | // Releases reindex the content based on the template names and pass it to Manifests 383 | func Releases(oldIndex, newIndex map[string]*manifest.MappingResult, options *Options, to io.Writer) bool { 384 | oldIndex = reIndexForRelease(oldIndex) 385 | newIndex = reIndexForRelease(newIndex) 386 | return Manifests(oldIndex, newIndex, options, to) 387 | } 388 | 389 | func diffMappingResults(oldContent *manifest.MappingResult, newContent *manifest.MappingResult, stripTrailingCR bool) []difflib.DiffRecord { 390 | return diffStrings(oldContent.Content, newContent.Content, stripTrailingCR) 391 | } 392 | 393 | func diffStrings(before, after string, stripTrailingCR bool) []difflib.DiffRecord { 394 | return difflib.Diff(split(before, stripTrailingCR), split(after, stripTrailingCR)) 395 | } 396 | 397 | func split(value string, stripTrailingCR bool) []string { 398 | const sep = "\n" 399 | split := strings.Split(value, sep) 400 | if !stripTrailingCR { 401 | return split 402 | } 403 | var stripped []string 404 | for _, s := range split { 405 | stripped = append(stripped, strings.TrimSuffix(s, "\r")) 406 | } 407 | return stripped 408 | } 409 | 410 | func printDiffRecords(suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, to io.Writer) { 411 | for _, ckind := range suppressedKinds { 412 | if ckind == kind { 413 | str := fmt.Sprintf("+ Changes suppressed on sensitive content of type %s\n", kind) 414 | _, _ = fmt.Fprint(to, ansi.Color(str, "yellow")) 415 | return 416 | } 417 | } 418 | 419 | if context >= 0 { 420 | distances := calculateDistances(diffs) 421 | omitting := false 422 | for i, diff := range diffs { 423 | if distances[i] > context { 424 | if !omitting { 425 | _, _ = fmt.Fprintln(to, "...") 426 | omitting = true 427 | } 428 | } else { 429 | omitting = false 430 | printDiffRecord(diff, to) 431 | } 432 | } 433 | } else { 434 | for _, diff := range diffs { 435 | printDiffRecord(diff, to) 436 | } 437 | } 438 | } 439 | 440 | func printDiffRecord(diff difflib.DiffRecord, to io.Writer) { 441 | text := diff.Payload 442 | 443 | switch diff.Delta { 444 | case difflib.RightOnly: 445 | _, _ = fmt.Fprintf(to, "%s\n", ansi.Color("+ "+text, "green")) 446 | case difflib.LeftOnly: 447 | _, _ = fmt.Fprintf(to, "%s\n", ansi.Color("- "+text, "red")) 448 | case difflib.Common: 449 | if text == "" { 450 | _, _ = fmt.Fprintln(to) 451 | } else { 452 | _, _ = fmt.Fprintf(to, "%s\n", " "+text) 453 | } 454 | } 455 | } 456 | 457 | // Calculate distance of every diff-line to the closest change 458 | func calculateDistances(diffs []difflib.DiffRecord) map[int]int { 459 | distances := map[int]int{} 460 | 461 | // Iterate forwards through diffs, set 'distance' based on closest 'change' before this line 462 | change := -1 463 | for i, diff := range diffs { 464 | if diff.Delta != difflib.Common { 465 | change = i 466 | } 467 | distance := math.MaxInt32 468 | if change != -1 { 469 | distance = i - change 470 | } 471 | distances[i] = distance 472 | } 473 | 474 | // Iterate backwards through diffs, reduce 'distance' based on closest 'change' after this line 475 | change = -1 476 | for i := len(diffs) - 1; i >= 0; i-- { 477 | diff := diffs[i] 478 | if diff.Delta != difflib.Common { 479 | change = i 480 | } 481 | if change != -1 { 482 | distance := change - i 483 | if distance < distances[i] { 484 | distances[i] = distance 485 | } 486 | } 487 | } 488 | 489 | return distances 490 | } 491 | 492 | // reIndexForRelease based on template names 493 | func reIndexForRelease(index map[string]*manifest.MappingResult) map[string]*manifest.MappingResult { 494 | // sort the index to iterate map in the same order 495 | var keys []string 496 | for key := range index { 497 | keys = append(keys, key) 498 | } 499 | sort.Strings(keys) 500 | 501 | // holds number of object in a single file 502 | count := make(map[string]int) 503 | 504 | newIndex := make(map[string]*manifest.MappingResult) 505 | 506 | for key := range keys { 507 | str := strings.Replace(strings.Split(index[keys[key]].Content, "\n")[0], "# Source: ", "", 1) 508 | 509 | if _, ok := newIndex[str]; ok { 510 | count[str]++ 511 | str += fmt.Sprintf(" %d", count[str]) 512 | newIndex[str] = index[keys[key]] 513 | } else { 514 | newIndex[str] = index[keys[key]] 515 | count[str]++ 516 | } 517 | } 518 | return newIndex 519 | } 520 | 521 | func sortedKeys(manifests map[string]*manifest.MappingResult) []string { 522 | var keys []string 523 | 524 | for key := range manifests { 525 | keys = append(keys, key) 526 | } 527 | 528 | sort.Strings(keys) 529 | 530 | return keys 531 | } 532 | -------------------------------------------------------------------------------- /diff/report.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "reflect" 11 | "regexp" 12 | "text/template" 13 | 14 | "github.com/aryann/difflib" 15 | "github.com/gonvenience/ytbx" 16 | "github.com/homeport/dyff/pkg/dyff" 17 | "github.com/mgutz/ansi" 18 | ) 19 | 20 | // Report to store report data and format 21 | type Report struct { 22 | format ReportFormat 23 | entries []ReportEntry 24 | } 25 | 26 | // ReportEntry to store changes between releases 27 | type ReportEntry struct { 28 | key string 29 | suppressedKinds []string 30 | kind string 31 | context int 32 | diffs []difflib.DiffRecord 33 | changeType string 34 | } 35 | 36 | // ReportFormat to the context to make a changes report 37 | type ReportFormat struct { 38 | output func(r *Report, to io.Writer) 39 | changestyles map[string]ChangeStyle 40 | } 41 | 42 | // ChangeStyle for styling the report 43 | type ChangeStyle struct { 44 | color string 45 | message string 46 | } 47 | 48 | // ReportTemplateSpec for common template spec 49 | type ReportTemplateSpec struct { 50 | Namespace string 51 | Name string 52 | Kind string 53 | API string 54 | Change string 55 | } 56 | 57 | // setupReportFormat: process output argument. 58 | func (r *Report) setupReportFormat(format string) { 59 | switch format { 60 | case "simple": 61 | setupSimpleReport(r) 62 | case "template": 63 | setupTemplateReport(r) 64 | case "json": 65 | setupJSONReport(r) 66 | case "dyff": 67 | setupDyffReport(r) 68 | default: 69 | setupDiffReport(r) 70 | } 71 | } 72 | 73 | func setupDyffReport(r *Report) { 74 | r.format.output = printDyffReport 75 | } 76 | 77 | func printDyffReport(r *Report, to io.Writer) { 78 | currentFile, _ := os.CreateTemp("", "existing-values") 79 | defer func() { 80 | _ = os.Remove(currentFile.Name()) 81 | }() 82 | newFile, _ := os.CreateTemp("", "new-values") 83 | defer func() { 84 | _ = os.Remove(newFile.Name()) 85 | }() 86 | 87 | for _, entry := range r.entries { 88 | _, _ = currentFile.WriteString("---\n") 89 | _, _ = newFile.WriteString("---\n") 90 | for _, record := range entry.diffs { 91 | switch record.Delta { 92 | case difflib.Common: 93 | _, _ = currentFile.WriteString(record.Payload + "\n") 94 | _, _ = newFile.WriteString(record.Payload + "\n") 95 | case difflib.LeftOnly: 96 | _, _ = currentFile.WriteString(record.Payload + "\n") 97 | case difflib.RightOnly: 98 | _, _ = newFile.WriteString(record.Payload + "\n") 99 | } 100 | } 101 | } 102 | _ = currentFile.Close() 103 | _ = newFile.Close() 104 | 105 | currentInputFile, newInputFile, _ := ytbx.LoadFiles(currentFile.Name(), newFile.Name()) 106 | 107 | report, _ := dyff.CompareInputFiles(currentInputFile, newInputFile) 108 | reportWriter := &dyff.HumanReport{ 109 | Report: report, 110 | OmitHeader: true, 111 | MinorChangeThreshold: 0.1, 112 | } 113 | _ = reportWriter.WriteReport(to) 114 | } 115 | 116 | // addEntry: stores diff changes. 117 | func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string) { 118 | entry := ReportEntry{ 119 | key, 120 | suppressedKinds, 121 | kind, 122 | context, 123 | diffs, 124 | changeType, 125 | } 126 | r.entries = append(r.entries, entry) 127 | } 128 | 129 | // print: prints entries added to the report. 130 | func (r *Report) print(to io.Writer) { 131 | r.format.output(r, to) 132 | } 133 | 134 | // clean: needed for testing 135 | func (r *Report) clean() { 136 | r.entries = nil 137 | } 138 | 139 | // setup report for default output: diff 140 | func setupDiffReport(r *Report) { 141 | r.format.output = printDiffReport 142 | r.format.changestyles = make(map[string]ChangeStyle) 143 | r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: "has been added:"} 144 | r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: "has been removed:"} 145 | r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: "has changed:"} 146 | r.format.changestyles["OWNERSHIP"] = ChangeStyle{color: "magenta", message: "changed ownership:"} 147 | r.format.changestyles["MODIFY_SUPPRESSED"] = ChangeStyle{color: "blue+h", message: "has changed, but diff is empty after suppression."} 148 | } 149 | 150 | // print report for default output: diff 151 | func printDiffReport(r *Report, to io.Writer) { 152 | for _, entry := range r.entries { 153 | _, _ = fmt.Fprintf( 154 | to, 155 | ansi.Color("%s %s", r.format.changestyles[entry.changeType].color)+"\n", 156 | entry.key, 157 | r.format.changestyles[entry.changeType].message, 158 | ) 159 | printDiffRecords(entry.suppressedKinds, entry.kind, entry.context, entry.diffs, to) 160 | } 161 | } 162 | 163 | // setup report for simple output. 164 | func setupSimpleReport(r *Report) { 165 | r.format.output = printSimpleReport 166 | r.format.changestyles = make(map[string]ChangeStyle) 167 | r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: "to be added."} 168 | r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: "to be removed."} 169 | r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: "to be changed."} 170 | r.format.changestyles["OWNERSHIP"] = ChangeStyle{color: "magenta", message: "to change ownership."} 171 | r.format.changestyles["MODIFY_SUPPRESSED"] = ChangeStyle{color: "blue+h", message: "has changed, but diff is empty after suppression."} 172 | } 173 | 174 | // print report for simple output 175 | func printSimpleReport(r *Report, to io.Writer) { 176 | var summary = map[string]int{ 177 | "ADD": 0, 178 | "REMOVE": 0, 179 | "MODIFY": 0, 180 | "OWNERSHIP": 0, 181 | "MODIFY_SUPPRESSED": 0, 182 | } 183 | for _, entry := range r.entries { 184 | _, _ = fmt.Fprintf(to, ansi.Color("%s %s", r.format.changestyles[entry.changeType].color)+"\n", 185 | entry.key, 186 | r.format.changestyles[entry.changeType].message, 187 | ) 188 | summary[entry.changeType]++ 189 | } 190 | _, _ = fmt.Fprintf(to, "Plan: %d to add, %d to change, %d to destroy, %d to change ownership.\n", summary["ADD"], summary["MODIFY"], summary["REMOVE"], summary["OWNERSHIP"]) 191 | } 192 | 193 | func newTemplate(name string) *template.Template { 194 | // Prepare template functions 195 | var funcsMap = template.FuncMap{ 196 | "last": func(x int, a interface{}) bool { 197 | return x == reflect.ValueOf(a).Len()-1 198 | }, 199 | } 200 | 201 | return template.New(name).Funcs(funcsMap) 202 | } 203 | 204 | // setup report for json output 205 | func setupJSONReport(r *Report) { 206 | t, err := newTemplate("entries").Parse(defaultTemplateReport) 207 | if err != nil { 208 | log.Fatalf("Error loading default template: %v", err) 209 | } 210 | 211 | r.format.output = templateReportPrinter(t) 212 | r.format.changestyles = make(map[string]ChangeStyle) 213 | r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: ""} 214 | r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: ""} 215 | r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: ""} 216 | r.format.changestyles["OWNERSHIP"] = ChangeStyle{color: "magenta", message: ""} 217 | r.format.changestyles["MODIFY_SUPPRESSED"] = ChangeStyle{color: "blue+h", message: ""} 218 | } 219 | 220 | // setup report for template output 221 | func setupTemplateReport(r *Report) { 222 | var tpl *template.Template 223 | 224 | { 225 | tplFile, present := os.LookupEnv("HELM_DIFF_TPL") 226 | if present { 227 | t, err := newTemplate(filepath.Base(tplFile)).ParseFiles(tplFile) 228 | if err != nil { 229 | fmt.Println(err) 230 | log.Fatalf("Error loading custom template") 231 | } 232 | tpl = t 233 | } else { 234 | // Render 235 | t, err := newTemplate("entries").Parse(defaultTemplateReport) 236 | if err != nil { 237 | log.Fatalf("Error loading default template") 238 | } 239 | tpl = t 240 | } 241 | } 242 | 243 | r.format.output = templateReportPrinter(tpl) 244 | r.format.changestyles = make(map[string]ChangeStyle) 245 | r.format.changestyles["ADD"] = ChangeStyle{color: "green", message: ""} 246 | r.format.changestyles["REMOVE"] = ChangeStyle{color: "red", message: ""} 247 | r.format.changestyles["MODIFY"] = ChangeStyle{color: "yellow", message: ""} 248 | r.format.changestyles["OWNERSHIP"] = ChangeStyle{color: "magenta", message: ""} 249 | r.format.changestyles["MODIFY_SUPPRESSED"] = ChangeStyle{color: "blue+h", message: ""} 250 | } 251 | 252 | // report with template output will only have access to ReportTemplateSpec. 253 | // This function reverts parsedMetadata.String() 254 | func (t *ReportTemplateSpec) loadFromKey(key string) error { 255 | pattern := regexp.MustCompile(`(?P[a-z0-9-]+), (?P[a-z0-9.-]+), (?P\w+) \((?P[^)]+)\)`) 256 | matches := pattern.FindStringSubmatch(key) 257 | if len(matches) > 1 { 258 | t.Namespace = matches[1] 259 | t.Name = matches[2] 260 | t.Kind = matches[3] 261 | t.API = matches[4] 262 | return nil 263 | } 264 | return errors.New("key string didn't match regexp") 265 | } 266 | 267 | // load and print report for template output 268 | func templateReportPrinter(t *template.Template) func(r *Report, to io.Writer) { 269 | return func(r *Report, to io.Writer) { 270 | var templateDataArray []ReportTemplateSpec 271 | 272 | for _, entry := range r.entries { 273 | templateData := ReportTemplateSpec{} 274 | err := templateData.loadFromKey(entry.key) 275 | if err != nil { 276 | log.Println("error processing report entry") 277 | } else { 278 | templateData.Change = entry.changeType 279 | templateDataArray = append(templateDataArray, templateData) 280 | } 281 | } 282 | 283 | _ = t.Execute(to, templateDataArray) 284 | _, _ = to.Write([]byte("\n")) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /diff/report_test.go: -------------------------------------------------------------------------------- 1 | package diff 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestLoadFromKey(t *testing.T) { 10 | keyToReportTemplateSpec := map[string]ReportTemplateSpec{ 11 | "default, nginx, Deployment (apps)": { 12 | Namespace: "default", 13 | Name: "nginx", 14 | Kind: "Deployment", 15 | API: "apps", 16 | }, 17 | "default, probes.monitoring.coreos.com, CustomResourceDefinition (apiextensions.k8s.io)": { 18 | Namespace: "default", 19 | Name: "probes.monitoring.coreos.com", 20 | Kind: "CustomResourceDefinition", 21 | API: "apiextensions.k8s.io", 22 | }, 23 | "default, my-cert, Certificate (cert-manager.io/v1)": { 24 | Namespace: "default", 25 | Name: "my-cert", 26 | Kind: "Certificate", 27 | API: "cert-manager.io/v1", 28 | }, 29 | } 30 | 31 | for key, expectedTemplateSpec := range keyToReportTemplateSpec { 32 | templateSpec := &ReportTemplateSpec{} 33 | require.NoError(t, templateSpec.loadFromKey(key)) 34 | require.Equal(t, expectedTemplateSpec, *templateSpec) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /diff/testdata/customTemplate.tpl: -------------------------------------------------------------------------------- 1 | {{- range $idx, $entry := . -}} 2 | Resource name: {{ $entry.Name }} 3 | {{- end -}} 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/databus23/helm-diff/v3 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/Masterminds/semver/v3 v3.3.1 7 | github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b 8 | github.com/evanphx/json-patch/v5 v5.9.11 9 | github.com/gonvenience/bunt v1.4.1 10 | github.com/gonvenience/ytbx v1.4.7 11 | github.com/google/go-cmp v0.7.0 12 | github.com/homeport/dyff v1.10.1 13 | github.com/json-iterator/go v1.1.12 14 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d 15 | github.com/spf13/cobra v1.9.1 16 | github.com/spf13/pflag v1.0.6 17 | github.com/stretchr/testify v1.10.0 18 | golang.org/x/term v0.32.0 19 | gopkg.in/yaml.v2 v2.4.0 20 | helm.sh/helm/v3 v3.18.2 21 | k8s.io/api v0.33.1 22 | k8s.io/apiextensions-apiserver v0.33.1 23 | k8s.io/apimachinery v0.33.1 24 | k8s.io/cli-runtime v0.33.1 25 | k8s.io/client-go v0.33.1 26 | sigs.k8s.io/yaml v1.4.0 27 | ) 28 | 29 | require ( 30 | dario.cat/mergo v1.0.1 // indirect 31 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 32 | github.com/BurntSushi/toml v1.5.0 // indirect 33 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 34 | github.com/Masterminds/goutils v1.1.1 // indirect 35 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 36 | github.com/Masterminds/squirrel v1.5.4 // indirect 37 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 38 | github.com/blang/semver/v4 v4.0.0 // indirect 39 | github.com/chai2010/gettext-go v1.0.2 // indirect 40 | github.com/containerd/containerd v1.7.27 // indirect 41 | github.com/containerd/errdefs v0.3.0 // indirect 42 | github.com/containerd/log v0.1.0 // indirect 43 | github.com/containerd/platforms v0.2.1 // indirect 44 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 45 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 46 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 47 | github.com/evanphx/json-patch v5.9.11+incompatible // indirect 48 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 49 | github.com/fatih/color v1.13.0 // indirect 50 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 51 | github.com/go-errors/errors v1.4.2 // indirect 52 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 53 | github.com/go-logr/logr v1.4.2 // indirect 54 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 55 | github.com/go-openapi/jsonreference v0.20.2 // indirect 56 | github.com/go-openapi/swag v0.23.0 // indirect 57 | github.com/gobwas/glob v0.2.3 // indirect 58 | github.com/gogo/protobuf v1.3.2 // indirect 59 | github.com/gonvenience/idem v0.0.1 // indirect 60 | github.com/gonvenience/neat v1.3.15 // indirect 61 | github.com/gonvenience/term v1.0.3 // indirect 62 | github.com/gonvenience/text v1.0.8 // indirect 63 | github.com/google/btree v1.1.3 // indirect 64 | github.com/google/gnostic-models v0.6.9 // indirect 65 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 66 | github.com/google/uuid v1.6.0 // indirect 67 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 68 | github.com/gosuri/uitable v0.0.4 // indirect 69 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 70 | github.com/hashicorp/errwrap v1.1.0 // indirect 71 | github.com/hashicorp/go-multierror v1.1.1 // indirect 72 | github.com/huandu/xstrings v1.5.0 // indirect 73 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 74 | github.com/jmoiron/sqlx v1.4.0 // indirect 75 | github.com/josharian/intern v1.0.0 // indirect 76 | github.com/klauspost/compress v1.18.0 // indirect 77 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 78 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 79 | github.com/lib/pq v1.10.9 // indirect 80 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 81 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 82 | github.com/mailru/easyjson v0.7.7 // indirect 83 | github.com/mattn/go-ciede2000 v0.0.0-20170301095244-782e8c62fec3 // indirect 84 | github.com/mattn/go-colorable v0.1.13 // indirect 85 | github.com/mattn/go-isatty v0.0.20 // indirect 86 | github.com/mattn/go-runewidth v0.0.9 // indirect 87 | github.com/mitchellh/copystructure v1.2.0 // indirect 88 | github.com/mitchellh/go-ps v1.0.0 // indirect 89 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 90 | github.com/mitchellh/hashstructure v1.1.0 // indirect 91 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 92 | github.com/moby/spdystream v0.5.0 // indirect 93 | github.com/moby/term v0.5.2 // indirect 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 95 | github.com/modern-go/reflect2 v1.0.2 // indirect 96 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 97 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 98 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 99 | github.com/opencontainers/go-digest v1.0.0 // indirect 100 | github.com/opencontainers/image-spec v1.1.1 // indirect 101 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 102 | github.com/pkg/errors v0.9.1 // indirect 103 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 104 | github.com/rubenv/sql-migrate v1.8.0 // indirect 105 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 106 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 107 | github.com/shopspring/decimal v1.4.0 // indirect 108 | github.com/sirupsen/logrus v1.9.3 // indirect 109 | github.com/spf13/cast v1.7.0 // indirect 110 | github.com/texttheater/golang-levenshtein v1.0.1 // indirect 111 | github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 // indirect 112 | github.com/x448/float16 v0.8.4 // indirect 113 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 114 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 115 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 116 | github.com/xlab/treeprint v1.2.0 // indirect 117 | golang.org/x/crypto v0.37.0 // indirect 118 | golang.org/x/net v0.38.0 // indirect 119 | golang.org/x/oauth2 v0.28.0 // indirect 120 | golang.org/x/sync v0.14.0 // indirect 121 | golang.org/x/sys v0.33.0 // indirect 122 | golang.org/x/text v0.24.0 // indirect 123 | golang.org/x/time v0.9.0 // indirect 124 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect 125 | google.golang.org/grpc v1.68.1 // indirect 126 | google.golang.org/protobuf v1.36.5 // indirect 127 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 128 | gopkg.in/inf.v0 v0.9.1 // indirect 129 | gopkg.in/yaml.v3 v3.0.1 // indirect 130 | k8s.io/apiserver v0.33.1 // indirect 131 | k8s.io/component-base v0.33.1 // indirect 132 | k8s.io/klog/v2 v2.130.1 // indirect 133 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 134 | k8s.io/kubectl v0.33.0 // indirect 135 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect 136 | oras.land/oras-go/v2 v2.5.0 // indirect 137 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 138 | sigs.k8s.io/kustomize/api v0.19.0 // indirect 139 | sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect 140 | sigs.k8s.io/randfill v1.0.0 // indirect 141 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 142 | ) 143 | -------------------------------------------------------------------------------- /install-binary.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Shamelessly copied from https://github.com/technosophos/helm-template 4 | 5 | PROJECT_NAME="helm-diff" 6 | PROJECT_GH="databus23/$PROJECT_NAME" 7 | export GREP_COLOR="never" 8 | 9 | # Convert HELM_BIN and HELM_PLUGIN_DIR to unix if cygpath is 10 | # available. This is the case when using MSYS2 or Cygwin 11 | # on Windows where helm returns a Windows path but we 12 | # need a Unix path 13 | 14 | if command -v cygpath >/dev/null 2>&1; then 15 | HELM_BIN="$(cygpath -u "${HELM_BIN}")" 16 | HELM_PLUGIN_DIR="$(cygpath -u "${HELM_PLUGIN_DIR}")" 17 | fi 18 | 19 | [ -z "$HELM_BIN" ] && HELM_BIN=$(command -v helm) 20 | 21 | [ -z "$HELM_HOME" ] && HELM_HOME=$(helm env | grep 'HELM_DATA_HOME' | cut -d '=' -f2 | tr -d '"') 22 | 23 | mkdir -p "$HELM_HOME" 24 | 25 | : "${HELM_PLUGIN_DIR:="$HELM_HOME/plugins/helm-diff"}" 26 | 27 | if [ "$SKIP_BIN_INSTALL" = "1" ]; then 28 | echo "Skipping binary install" 29 | exit 30 | fi 31 | 32 | # which mode is the common installer script running in 33 | SCRIPT_MODE="install" 34 | if [ "$1" = "-u" ]; then 35 | SCRIPT_MODE="update" 36 | fi 37 | 38 | # initArch discovers the architecture for this system. 39 | initArch() { 40 | ARCH=$(uname -m) 41 | case $ARCH in 42 | armv5*) ARCH="armv5" ;; 43 | armv6*) ARCH="armv6" ;; 44 | armv7*) ARCH="armv7" ;; 45 | aarch64) ARCH="arm64" ;; 46 | x86) ARCH="386" ;; 47 | x86_64) ARCH="amd64" ;; 48 | i686) ARCH="386" ;; 49 | i386) ARCH="386" ;; 50 | esac 51 | } 52 | 53 | # initOS discovers the operating system for this system. 54 | initOS() { 55 | OS=$(uname -s) 56 | 57 | case "$OS" in 58 | Windows_NT) OS='windows' ;; 59 | # Msys support 60 | MSYS*) OS='windows' ;; 61 | # Minimalist GNU for Windows 62 | MINGW*) OS='windows' ;; 63 | CYGWIN*) OS='windows' ;; 64 | Darwin) OS='macos' ;; 65 | Linux) OS='linux' ;; 66 | esac 67 | } 68 | 69 | # verifySupported checks that the os/arch combination is supported for 70 | # binary builds. 71 | verifySupported() { 72 | supported="linux-amd64\nlinux-arm64\nfreebsd-amd64\nmacos-amd64\nmacos-arm64\nwindows-amd64" 73 | if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then 74 | echo "No prebuild binary for ${OS}-${ARCH}." 75 | exit 1 76 | fi 77 | 78 | if 79 | ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1 80 | then 81 | echo "Either curl or wget is required" 82 | exit 1 83 | fi 84 | } 85 | 86 | # getDownloadURL checks the latest available version. 87 | getDownloadURL() { 88 | version=$(git -C "$HELM_PLUGIN_DIR" describe --tags --exact-match 2>/dev/null || :) 89 | if [ "$SCRIPT_MODE" = "install" ] && [ -n "$version" ]; then 90 | DOWNLOAD_URL="https://github.com/$PROJECT_GH/releases/download/$version/helm-diff-$OS-$ARCH.tgz" 91 | else 92 | DOWNLOAD_URL="https://github.com/$PROJECT_GH/releases/latest/download/helm-diff-$OS-$ARCH.tgz" 93 | fi 94 | } 95 | 96 | # Temporary dir 97 | mkTempDir() { 98 | HELM_TMP="$(mktemp -d -t "${PROJECT_NAME}-XXXXXX")" 99 | } 100 | rmTempDir() { 101 | if [ -d "${HELM_TMP:-/tmp/helm-diff-tmp}" ]; then 102 | rm -rf "${HELM_TMP:-/tmp/helm-diff-tmp}" 103 | fi 104 | } 105 | 106 | # downloadFile downloads the latest binary package and also the checksum 107 | # for that binary. 108 | downloadFile() { 109 | PLUGIN_TMP_FILE="${HELM_TMP}/${PROJECT_NAME}.tgz" 110 | echo "Downloading $DOWNLOAD_URL" 111 | if 112 | command -v curl >/dev/null 2>&1 113 | then 114 | curl -sSf -L "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 115 | elif 116 | command -v wget >/dev/null 2>&1 117 | then 118 | wget -q -O - "$DOWNLOAD_URL" >"$PLUGIN_TMP_FILE" 119 | fi 120 | } 121 | 122 | # installFile verifies the SHA256 for the file, then unpacks and 123 | # installs it. 124 | installFile() { 125 | tar xzf "$PLUGIN_TMP_FILE" -C "$HELM_TMP" 126 | HELM_TMP_BIN="$HELM_TMP/diff/bin/diff" 127 | if [ "${OS}" = "windows" ]; then 128 | HELM_TMP_BIN="$HELM_TMP_BIN.exe" 129 | fi 130 | echo "Preparing to install into ${HELM_PLUGIN_DIR}" 131 | mkdir -p "$HELM_PLUGIN_DIR/bin" 132 | cp "$HELM_TMP_BIN" "$HELM_PLUGIN_DIR/bin" 133 | } 134 | 135 | # exit_trap is executed if on exit (error or not). 136 | exit_trap() { 137 | result=$? 138 | rmTempDir 139 | if [ "$result" != "0" ]; then 140 | echo "Failed to install $PROJECT_NAME" 141 | printf '\tFor support, go to https://github.com/databus23/helm-diff.\n' 142 | fi 143 | exit $result 144 | } 145 | 146 | # Execution 147 | 148 | #Stop execution on any error 149 | trap "exit_trap" EXIT 150 | set -e 151 | initArch 152 | initOS 153 | verifySupported 154 | getDownloadURL 155 | mkTempDir 156 | downloadFile 157 | installFile 158 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | _ "k8s.io/client-go/plugin/pkg/client/auth/azure" 8 | _ "k8s.io/client-go/plugin/pkg/client/auth/exec" 9 | _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" 10 | _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" 11 | 12 | "github.com/databus23/helm-diff/v3/cmd" 13 | ) 14 | 15 | func main() { 16 | if err := cmd.New().Execute(); err != nil { 17 | var cmdErr cmd.Error 18 | switch { 19 | case errors.As(err, &cmdErr): 20 | os.Exit(cmdErr.Code) 21 | default: 22 | os.Exit(1) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/databus23/helm-diff/v3/cmd" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | if os.Getenv(env) == envValue { 16 | os.Exit(runFakeHelm()) 17 | } 18 | 19 | os.Exit(m.Run()) 20 | } 21 | 22 | func TestHelmDiff(t *testing.T) { 23 | os.Setenv(env, envValue) 24 | defer os.Unsetenv(env) 25 | 26 | helmBin, helmBinSet := os.LookupEnv("HELM_BIN") 27 | os.Setenv("HELM_BIN", os.Args[0]) 28 | defer func() { 29 | if helmBinSet { 30 | os.Setenv("HELM_BIN", helmBin) 31 | } else { 32 | os.Unsetenv("HELM_BIN") 33 | } 34 | }() 35 | 36 | os.Args = []string{"helm-diff", "upgrade", "-f", "test/testdata/test-values.yaml", "test-release", "test/testdata/test-chart"} 37 | require.NoError(t, cmd.New().Execute()) 38 | } 39 | 40 | const ( 41 | env = "BECOME_FAKE_HELM" 42 | envValue = "1" 43 | ) 44 | 45 | type fakeHelmSubcmd struct { 46 | cmd []string 47 | args []string 48 | stdout string 49 | stderr string 50 | exitCode int 51 | } 52 | 53 | var helmSubcmdStubs = []fakeHelmSubcmd{ 54 | { 55 | cmd: []string{"version"}, 56 | stdout: `version.BuildInfo{Version:"v3.1.0-rc.1", GitCommit:"12345", GitTreeState:"clean", GoVersion:"go1.20.12"}`, 57 | }, 58 | { 59 | cmd: []string{"get", "manifest"}, 60 | args: []string{"test-release"}, 61 | stdout: `--- 62 | # Source: test-chart/templates/cm.yaml 63 | `, 64 | }, 65 | { 66 | cmd: []string{"template"}, 67 | args: []string{"test-release", "test/testdata/test-chart", "--values", "test/testdata/test-values.yaml", "--validate", "--is-upgrade"}, 68 | }, 69 | { 70 | cmd: []string{"get", "hooks"}, 71 | args: []string{"test-release"}, 72 | }, 73 | } 74 | 75 | func runFakeHelm() int { 76 | var stub *fakeHelmSubcmd 77 | 78 | if len(os.Args) < 2 { 79 | _, _ = fmt.Fprintln(os.Stderr, "fake helm does not support invocations without subcommands") 80 | return 1 81 | } 82 | 83 | cmdAndArgs := os.Args[1:] 84 | for i := range helmSubcmdStubs { 85 | s := helmSubcmdStubs[i] 86 | if reflect.DeepEqual(s.cmd, cmdAndArgs[:len(s.cmd)]) { 87 | stub = &s 88 | break 89 | } 90 | } 91 | 92 | if stub == nil { 93 | _, _ = fmt.Fprintf(os.Stderr, "no stub for %s\n", cmdAndArgs) 94 | return 1 95 | } 96 | 97 | want := stub.args 98 | if want == nil { 99 | want = []string{} 100 | } 101 | got := cmdAndArgs[len(stub.cmd):] 102 | if !reflect.DeepEqual(want, got) { 103 | _, _ = fmt.Fprintf(os.Stderr, "want: %v\n", want) 104 | _, _ = fmt.Fprintf(os.Stderr, "got : %v\n", got) 105 | _, _ = fmt.Fprintf(os.Stderr, "args : %v\n", os.Args) 106 | _, _ = fmt.Fprintf(os.Stderr, "env : %v\n", os.Environ()) 107 | return 1 108 | } 109 | _, _ = fmt.Fprintf(os.Stdout, "%s", stub.stdout) 110 | _, _ = fmt.Fprintf(os.Stderr, "%s", stub.stderr) 111 | return stub.exitCode 112 | } 113 | -------------------------------------------------------------------------------- /manifest/generate.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | jsonpatch "github.com/evanphx/json-patch/v5" 9 | jsoniter "github.com/json-iterator/go" 10 | "helm.sh/helm/v3/pkg/action" 11 | "helm.sh/helm/v3/pkg/kube" 12 | apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 13 | apierrors "k8s.io/apimachinery/pkg/api/errors" 14 | "k8s.io/apimachinery/pkg/runtime" 15 | "k8s.io/apimachinery/pkg/types" 16 | "k8s.io/apimachinery/pkg/util/strategicpatch" 17 | "k8s.io/cli-runtime/pkg/resource" 18 | "sigs.k8s.io/yaml" 19 | ) 20 | 21 | const ( 22 | Helm2TestSuccessHook = "test-success" 23 | Helm3TestHook = "test" 24 | ) 25 | 26 | func Generate(actionConfig *action.Configuration, originalManifest, targetManifest []byte) ([]byte, []byte, error) { 27 | var err error 28 | original, err := actionConfig.KubeClient.Build(bytes.NewBuffer(originalManifest), false) 29 | if err != nil { 30 | return nil, nil, fmt.Errorf("unable to build kubernetes objects from original release manifest: %w", err) 31 | } 32 | target, err := actionConfig.KubeClient.Build(bytes.NewBuffer(targetManifest), false) 33 | if err != nil { 34 | return nil, nil, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err) 35 | } 36 | releaseManifest, installManifest := make([]byte, 0), make([]byte, 0) 37 | // to be deleted 38 | targetResources := make(map[string]bool) 39 | for _, r := range target { 40 | targetResources[objectKey(r)] = true 41 | } 42 | for _, r := range original { 43 | if !targetResources[objectKey(r)] { 44 | out, _ := yaml.Marshal(r.Object) 45 | releaseManifest = append(releaseManifest, yamlSeparator...) 46 | releaseManifest = append(releaseManifest, out...) 47 | } 48 | } 49 | 50 | existingResources := make(map[string]bool) 51 | for _, r := range original { 52 | existingResources[objectKey(r)] = true 53 | } 54 | 55 | var toBeCreated kube.ResourceList 56 | for _, r := range target { 57 | if !existingResources[objectKey(r)] { 58 | toBeCreated = append(toBeCreated, r) 59 | } 60 | } 61 | 62 | toBeUpdated, err := existingResourceConflict(toBeCreated) 63 | if err != nil { 64 | return nil, nil, fmt.Errorf("rendered manifests contain a resource that already exists. Unable to continue with update: %w", err) 65 | } 66 | 67 | _ = toBeUpdated.Visit(func(r *resource.Info, err error) error { 68 | if err != nil { 69 | return err 70 | } 71 | original.Append(r) 72 | return nil 73 | }) 74 | 75 | err = target.Visit(func(info *resource.Info, err error) error { 76 | if err != nil { 77 | return err 78 | } 79 | kind := info.Mapping.GroupVersionKind.Kind 80 | 81 | // Fetch the current object for the three-way merge 82 | helper := resource.NewHelper(info.Client, info.Mapping) 83 | currentObj, err := helper.Get(info.Namespace, info.Name) 84 | if err != nil { 85 | if !apierrors.IsNotFound(err) { 86 | return fmt.Errorf("could not get information about the resource: %w", err) 87 | } 88 | // to be created 89 | out, _ := yaml.Marshal(info.Object) 90 | installManifest = append(installManifest, yamlSeparator...) 91 | installManifest = append(installManifest, out...) 92 | return nil 93 | } 94 | // to be updated 95 | out, _ := jsoniter.ConfigCompatibleWithStandardLibrary.Marshal(currentObj) 96 | pruneObj, err := deleteStatusAndTidyMetadata(out) 97 | if err != nil { 98 | return fmt.Errorf("prune current obj %q with kind %s: %w", info.Name, kind, err) 99 | } 100 | pruneOut, err := yaml.Marshal(pruneObj) 101 | if err != nil { 102 | return fmt.Errorf("prune current out %q with kind %s: %w", info.Name, kind, err) 103 | } 104 | releaseManifest = append(releaseManifest, yamlSeparator...) 105 | releaseManifest = append(releaseManifest, pruneOut...) 106 | 107 | originalInfo := original.Get(info) 108 | if originalInfo == nil { 109 | return fmt.Errorf("could not find %q", info.Name) 110 | } 111 | 112 | patch, patchType, err := createPatch(originalInfo.Object, currentObj, info) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | helper.ServerDryRun = true 118 | targetObj, err := helper.Patch(info.Namespace, info.Name, patchType, patch, nil) 119 | if err != nil { 120 | return fmt.Errorf("cannot patch %q with kind %s: %w", info.Name, kind, err) 121 | } 122 | out, _ = jsoniter.ConfigCompatibleWithStandardLibrary.Marshal(targetObj) 123 | pruneObj, err = deleteStatusAndTidyMetadata(out) 124 | if err != nil { 125 | return fmt.Errorf("prune current obj %q with kind %s: %w", info.Name, kind, err) 126 | } 127 | pruneOut, err = yaml.Marshal(pruneObj) 128 | if err != nil { 129 | return fmt.Errorf("prune current out %q with kind %s: %w", info.Name, kind, err) 130 | } 131 | installManifest = append(installManifest, yamlSeparator...) 132 | installManifest = append(installManifest, pruneOut...) 133 | return nil 134 | }) 135 | 136 | return releaseManifest, installManifest, err 137 | } 138 | 139 | func createPatch(originalObj, currentObj runtime.Object, target *resource.Info) ([]byte, types.PatchType, error) { 140 | oldData, err := json.Marshal(originalObj) 141 | if err != nil { 142 | return nil, types.StrategicMergePatchType, fmt.Errorf("serializing current configuration: %w", err) 143 | } 144 | newData, err := json.Marshal(target.Object) 145 | if err != nil { 146 | return nil, types.StrategicMergePatchType, fmt.Errorf("serializing target configuration: %w", err) 147 | } 148 | 149 | // Even if currentObj is nil (because it was not found), it will marshal just fine 150 | currentData, err := json.Marshal(currentObj) 151 | if err != nil { 152 | return nil, types.StrategicMergePatchType, fmt.Errorf("serializing live configuration: %w", err) 153 | } 154 | // kind := target.Mapping.GroupVersionKind.Kind 155 | // if kind == "Deployment" { 156 | // curr, _ := yaml.Marshal(currentObj) 157 | // fmt.Println(string(curr)) 158 | // } 159 | 160 | // Get a versioned object 161 | versionedObject := kube.AsVersioned(target) 162 | 163 | // Unstructured objects, such as CRDs, may not have an not registered error 164 | // returned from ConvertToVersion. Anything that's unstructured should 165 | // use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported 166 | // on objects like CRDs. 167 | _, isUnstructured := versionedObject.(runtime.Unstructured) 168 | 169 | // On newer K8s versions, CRDs aren't unstructured but has this dedicated type 170 | _, isCRD := versionedObject.(*apiextv1.CustomResourceDefinition) 171 | 172 | if isUnstructured || isCRD { 173 | // fall back to generic JSON merge patch 174 | patch, err := jsonpatch.CreateMergePatch(oldData, newData) 175 | return patch, types.MergePatchType, err 176 | } 177 | 178 | patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject) 179 | if err != nil { 180 | return nil, types.StrategicMergePatchType, fmt.Errorf("unable to create patch metadata from object: %w", err) 181 | } 182 | 183 | patch, err := strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true) 184 | return patch, types.StrategicMergePatchType, err 185 | } 186 | 187 | func objectKey(r *resource.Info) string { 188 | gvk := r.Object.GetObjectKind().GroupVersionKind() 189 | return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name) 190 | } 191 | 192 | func existingResourceConflict(resources kube.ResourceList) (kube.ResourceList, error) { 193 | var requireUpdate kube.ResourceList 194 | 195 | err := resources.Visit(func(info *resource.Info, err error) error { 196 | if err != nil { 197 | return err 198 | } 199 | 200 | helper := resource.NewHelper(info.Client, info.Mapping) 201 | _, err = helper.Get(info.Namespace, info.Name) 202 | if err != nil { 203 | if apierrors.IsNotFound(err) { 204 | return nil 205 | } 206 | return fmt.Errorf("could not get information about the resource: %w", err) 207 | } 208 | 209 | requireUpdate.Append(info) 210 | return nil 211 | }) 212 | 213 | return requireUpdate, err 214 | } 215 | -------------------------------------------------------------------------------- /manifest/parse.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log" 8 | "strings" 9 | 10 | jsoniter "github.com/json-iterator/go" 11 | "gopkg.in/yaml.v2" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | ) 14 | 15 | const ( 16 | hookAnnotation = "helm.sh/hook" 17 | resourcePolicyAnnotation = "helm.sh/resource-policy" 18 | ) 19 | 20 | var yamlSeparator = []byte("\n---\n") 21 | 22 | // MappingResult to store result of diff 23 | type MappingResult struct { 24 | Name string 25 | Kind string 26 | Content string 27 | ResourcePolicy string 28 | } 29 | 30 | type metadata struct { 31 | APIVersion string `yaml:"apiVersion"` 32 | Kind string 33 | Metadata struct { 34 | Namespace string 35 | Name string 36 | Annotations map[string]string 37 | } 38 | } 39 | 40 | func (m metadata) String() string { 41 | apiBase := m.APIVersion 42 | sp := strings.Split(apiBase, "/") 43 | if len(sp) > 1 { 44 | apiBase = strings.Join(sp[:len(sp)-1], "/") 45 | } 46 | name := m.Metadata.Name 47 | if a := m.Metadata.Annotations; a != nil { 48 | if baseName, ok := a["helm-diff/base-name"]; ok { 49 | name = baseName 50 | } 51 | } 52 | return fmt.Sprintf("%s, %s, %s (%s)", m.Metadata.Namespace, name, m.Kind, apiBase) 53 | } 54 | 55 | func scanYamlSpecs(data []byte, atEOF bool) (advance int, token []byte, err error) { 56 | if atEOF && len(data) == 0 { 57 | return 0, nil, nil 58 | } 59 | if i := bytes.Index(data, yamlSeparator); i >= 0 { 60 | // We have a full newline-terminated line. 61 | return i + len(yamlSeparator), data[0:i], nil 62 | } 63 | // If we're at EOF, we have a final, non-terminated line. Return it. 64 | if atEOF { 65 | return len(data), data, nil 66 | } 67 | // Request more data. 68 | return 0, nil, nil 69 | } 70 | 71 | // Parse parses manifest strings into MappingResult 72 | func Parse(manifest string, defaultNamespace string, normalizeManifests bool, excludedHooks ...string) map[string]*MappingResult { 73 | // Ensure we have a newline in front of the yaml separator 74 | scanner := bufio.NewScanner(strings.NewReader("\n" + manifest)) 75 | scanner.Split(scanYamlSpecs) 76 | // Allow for tokens (specs) up to 10MiB in size 77 | scanner.Buffer(make([]byte, bufio.MaxScanTokenSize), 10485760) 78 | 79 | result := make(map[string]*MappingResult) 80 | 81 | for scanner.Scan() { 82 | content := strings.TrimSpace(scanner.Text()) 83 | if content == "" { 84 | continue 85 | } 86 | 87 | parsed, err := parseContent(content, defaultNamespace, normalizeManifests, excludedHooks...) 88 | if err != nil { 89 | log.Fatalf("%v", err) 90 | } 91 | 92 | for _, p := range parsed { 93 | name := p.Name 94 | 95 | if _, ok := result[name]; ok { 96 | log.Printf("Error: Found duplicate key %#v in manifest", name) 97 | } else { 98 | result[name] = p 99 | } 100 | } 101 | } 102 | if err := scanner.Err(); err != nil { 103 | log.Fatalf("Error reading input: %s", err) 104 | } 105 | return result 106 | } 107 | 108 | func ParseObject(object runtime.Object, defaultNamespace string, excludedHooks ...string) (*MappingResult, string, error) { 109 | json, _ := jsoniter.ConfigCompatibleWithStandardLibrary.Marshal(object) 110 | var objectMap map[string]interface{} 111 | err := jsoniter.Unmarshal(json, &objectMap) 112 | if err != nil { 113 | return nil, "", fmt.Errorf("could not unmarshal byte sequence: %w", err) 114 | } 115 | 116 | metadata := objectMap["metadata"].(map[string]interface{}) 117 | var oldRelease string 118 | if a := metadata["annotations"]; a != nil { 119 | annotations := a.(map[string]interface{}) 120 | if releaseNs, ok := annotations["meta.helm.sh/release-namespace"].(string); ok { 121 | oldRelease += releaseNs + "/" 122 | } 123 | if releaseName, ok := annotations["meta.helm.sh/release-name"].(string); ok { 124 | oldRelease += releaseName 125 | } 126 | } 127 | 128 | // Clean namespace metadata as it exists in Kubernetes but not in Helm manifest 129 | purgedObj, _ := deleteStatusAndTidyMetadata(json) 130 | 131 | content, err := yaml.Marshal(purgedObj) 132 | if err != nil { 133 | return nil, "", err 134 | } 135 | 136 | result, err := parseContent(string(content), defaultNamespace, true, excludedHooks...) 137 | if err != nil { 138 | return nil, "", err 139 | } 140 | 141 | if len(result) != 1 { 142 | return nil, "", fmt.Errorf("failed to parse content of Kubernetes resource %s", metadata["name"]) 143 | } 144 | 145 | result[0].Content = strings.TrimSuffix(result[0].Content, "\n") 146 | 147 | return result[0], oldRelease, nil 148 | } 149 | 150 | func parseContent(content string, defaultNamespace string, normalizeManifests bool, excludedHooks ...string) ([]*MappingResult, error) { 151 | var parsedMetadata metadata 152 | if err := yaml.Unmarshal([]byte(content), &parsedMetadata); err != nil { 153 | log.Fatalf("YAML unmarshal error: %s\nCan't unmarshal %s", err, content) 154 | } 155 | 156 | // Skip content without any metadata. It is probably a template that 157 | // only contains comments in the current state. 158 | if parsedMetadata.APIVersion == "" && parsedMetadata.Kind == "" { 159 | return nil, nil 160 | } 161 | 162 | if strings.HasSuffix(parsedMetadata.Kind, "List") { 163 | type ListV1 struct { 164 | Items []yaml.MapSlice `yaml:"items"` 165 | } 166 | 167 | var list ListV1 168 | 169 | if err := yaml.Unmarshal([]byte(content), &list); err != nil { 170 | log.Fatalf("YAML unmarshal error: %s\nCan't unmarshal %s", err, content) 171 | } 172 | 173 | var result []*MappingResult 174 | 175 | for _, item := range list.Items { 176 | subcontent, err := yaml.Marshal(item) 177 | if err != nil { 178 | log.Printf("YAML marshal error: %s\nCan't marshal %v", err, item) 179 | } 180 | 181 | subs, err := parseContent(string(subcontent), defaultNamespace, normalizeManifests, excludedHooks...) 182 | if err != nil { 183 | return nil, fmt.Errorf("Parsing YAML list item: %w", err) 184 | } 185 | 186 | result = append(result, subs...) 187 | } 188 | 189 | return result, nil 190 | } 191 | 192 | if normalizeManifests { 193 | // Unmarshal and marshal again content to normalize yaml structure 194 | // This avoids style differences to show up as diffs but it can 195 | // make the output different from the original template (since it is in normalized form) 196 | var object map[interface{}]interface{} 197 | if err := yaml.Unmarshal([]byte(content), &object); err != nil { 198 | log.Fatalf("YAML unmarshal error: %s\nCan't unmarshal %s", err, content) 199 | } 200 | normalizedContent, err := yaml.Marshal(object) 201 | if err != nil { 202 | log.Fatalf("YAML marshal error: %s\nCan't marshal %v", err, object) 203 | } 204 | content = string(normalizedContent) 205 | } 206 | 207 | if isHook(parsedMetadata, excludedHooks...) { 208 | return nil, nil 209 | } 210 | 211 | if parsedMetadata.Metadata.Namespace == "" { 212 | parsedMetadata.Metadata.Namespace = defaultNamespace 213 | } 214 | 215 | name := parsedMetadata.String() 216 | return []*MappingResult{ 217 | { 218 | Name: name, 219 | Kind: parsedMetadata.Kind, 220 | Content: content, 221 | ResourcePolicy: parsedMetadata.Metadata.Annotations[resourcePolicyAnnotation], 222 | }, 223 | }, nil 224 | } 225 | 226 | func isHook(metadata metadata, hooks ...string) bool { 227 | for _, hook := range hooks { 228 | if metadata.Metadata.Annotations[hookAnnotation] == hook { 229 | return true 230 | } 231 | } 232 | return false 233 | } 234 | -------------------------------------------------------------------------------- /manifest/parse_test.go: -------------------------------------------------------------------------------- 1 | package manifest_test 2 | 3 | import ( 4 | "os" 5 | "sort" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 10 | "k8s.io/apimachinery/pkg/runtime/serializer/yaml" 11 | 12 | . "github.com/databus23/helm-diff/v3/manifest" 13 | ) 14 | 15 | func foundObjects(result map[string]*MappingResult) []string { 16 | objs := make([]string, 0, len(result)) 17 | for k := range result { 18 | objs = append(objs, k) 19 | } 20 | sort.Strings(objs) 21 | return objs 22 | } 23 | 24 | func TestPod(t *testing.T) { 25 | spec, err := os.ReadFile("testdata/pod.yaml") 26 | require.NoError(t, err) 27 | 28 | require.Equal(t, 29 | []string{"default, nginx, Pod (v1)"}, 30 | foundObjects(Parse(string(spec), "default", false)), 31 | ) 32 | } 33 | 34 | func TestPodNamespace(t *testing.T) { 35 | spec, err := os.ReadFile("testdata/pod_namespace.yaml") 36 | require.NoError(t, err) 37 | 38 | require.Equal(t, 39 | []string{"batcave, nginx, Pod (v1)"}, 40 | foundObjects(Parse(string(spec), "default", false)), 41 | ) 42 | } 43 | 44 | func TestPodHook(t *testing.T) { 45 | spec, err := os.ReadFile("testdata/pod_hook.yaml") 46 | require.NoError(t, err) 47 | 48 | require.Equal(t, 49 | []string{"default, nginx, Pod (v1)"}, 50 | foundObjects(Parse(string(spec), "default", false)), 51 | ) 52 | 53 | require.Equal(t, 54 | []string{"default, nginx, Pod (v1)"}, 55 | foundObjects(Parse(string(spec), "default", false, "test-success")), 56 | ) 57 | 58 | require.Equal(t, 59 | []string{}, 60 | foundObjects(Parse(string(spec), "default", false, "test")), 61 | ) 62 | } 63 | 64 | func TestDeployV1(t *testing.T) { 65 | spec, err := os.ReadFile("testdata/deploy_v1.yaml") 66 | require.NoError(t, err) 67 | 68 | require.Equal(t, 69 | []string{"default, nginx, Deployment (apps)"}, 70 | foundObjects(Parse(string(spec), "default", false)), 71 | ) 72 | } 73 | 74 | func TestDeployV1Beta1(t *testing.T) { 75 | spec, err := os.ReadFile("testdata/deploy_v1beta1.yaml") 76 | require.NoError(t, err) 77 | 78 | require.Equal(t, 79 | []string{"default, nginx, Deployment (apps)"}, 80 | foundObjects(Parse(string(spec), "default", false)), 81 | ) 82 | } 83 | 84 | func TestList(t *testing.T) { 85 | spec, err := os.ReadFile("testdata/list.yaml") 86 | require.NoError(t, err) 87 | 88 | require.Equal(t, 89 | []string{ 90 | "default, prometheus-operator-example, PrometheusRule (monitoring.coreos.com)", 91 | "default, prometheus-operator-example2, PrometheusRule (monitoring.coreos.com)", 92 | }, 93 | foundObjects(Parse(string(spec), "default", false)), 94 | ) 95 | } 96 | 97 | func TestConfigMapList(t *testing.T) { 98 | spec, err := os.ReadFile("testdata/configmaplist_v1.yaml") 99 | require.NoError(t, err) 100 | 101 | require.Equal(t, 102 | []string{ 103 | "default, configmap-2-1, ConfigMap (v1)", 104 | "default, configmap-2-2, ConfigMap (v1)", 105 | }, 106 | foundObjects(Parse(string(spec), "default", false)), 107 | ) 108 | } 109 | 110 | func TestSecretList(t *testing.T) { 111 | spec, err := os.ReadFile("testdata/secretlist_v1.yaml") 112 | require.NoError(t, err) 113 | 114 | require.Equal(t, 115 | []string{ 116 | "default, my-secret-1, Secret (v1)", 117 | "default, my-secret-2, Secret (v1)", 118 | }, 119 | foundObjects(Parse(string(spec), "default", false)), 120 | ) 121 | } 122 | 123 | func TestEmpty(t *testing.T) { 124 | spec, err := os.ReadFile("testdata/empty.yaml") 125 | require.NoError(t, err) 126 | 127 | require.Equal(t, 128 | []string{}, 129 | foundObjects(Parse(string(spec), "default", false)), 130 | ) 131 | } 132 | 133 | func TestBaseNameAnnotation(t *testing.T) { 134 | spec, err := os.ReadFile("testdata/secret_immutable.yaml") 135 | require.NoError(t, err) 136 | 137 | require.Equal(t, 138 | []string{"default, bat-secret, Secret (v1)"}, 139 | foundObjects(Parse(string(spec), "default", false)), 140 | ) 141 | } 142 | 143 | func TestParseObject(t *testing.T) { 144 | for _, tt := range []struct { 145 | name string 146 | filename string 147 | releaseName string 148 | kind string 149 | oldRelease string 150 | }{ 151 | { 152 | name: "no release info", 153 | filename: "testdata/pod_no_release_annotations.yaml", 154 | releaseName: "testNS, nginx, Pod (v1)", 155 | kind: "Pod", 156 | oldRelease: "", 157 | }, 158 | { 159 | name: "get old release info", 160 | filename: "testdata/pod_release_annotations.yaml", 161 | releaseName: "testNS, nginx, Pod (v1)", 162 | kind: "Pod", 163 | oldRelease: "oldNS/oldReleaseName", 164 | }, 165 | } { 166 | t.Run(tt.name, func(t *testing.T) { 167 | spec, err := os.ReadFile(tt.filename) 168 | require.NoError(t, err) 169 | 170 | obj, _, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(spec, nil, nil) 171 | require.NoError(t, err) 172 | 173 | release, oldRelease, err := ParseObject(obj, "testNS") 174 | require.NoError(t, err) 175 | 176 | require.Equal(t, tt.releaseName, release.Name) 177 | require.Equal(t, tt.kind, release.Kind) 178 | require.Equal(t, tt.oldRelease, oldRelease) 179 | }) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /manifest/testdata/configmaplist_v1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMapList 3 | items: 4 | - apiVersion: v1 5 | kind: ConfigMap 6 | metadata: 7 | name: configmap-2-1 8 | data: 9 | key1: data1 10 | - apiVersion: v1 11 | kind: ConfigMap 12 | metadata: 13 | name: configmap-2-2 14 | data: 15 | key2: data2 -------------------------------------------------------------------------------- /manifest/testdata/deploy_v1.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/deployment.yaml 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: nginx 8 | spec: 9 | replicas: 3 10 | selector: 11 | matchLabels: 12 | app: nginx 13 | template: 14 | metadata: 15 | labels: 16 | app: nginx 17 | spec: 18 | containers: 19 | - name: nginx 20 | image: nginx:1.7.9 21 | ports: 22 | - containerPort: 80 23 | -------------------------------------------------------------------------------- /manifest/testdata/deploy_v1beta1.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/deployment.yaml 4 | apiVersion: apps/v1beta1 5 | kind: Deployment 6 | metadata: 7 | name: nginx 8 | spec: 9 | replicas: 3 10 | selector: 11 | matchLabels: 12 | app: nginx 13 | template: 14 | metadata: 15 | labels: 16 | app: nginx 17 | spec: 18 | containers: 19 | - name: nginx 20 | image: nginx:1.7.9 21 | ports: 22 | - containerPort: 80 23 | -------------------------------------------------------------------------------- /manifest/testdata/empty.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Source: nginx/pod.yaml 3 | # This template is empty 4 | # and only contains comments 5 | --- 6 | # Source: nginx/pod.yaml 7 | # This template is empty 8 | # and only contains comments 9 | -------------------------------------------------------------------------------- /manifest/testdata/list.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: prometheus-operator/templates/prometheus/additionalPrometheusRules.yaml 4 | apiVersion: v1 5 | kind: List 6 | items: 7 | - apiVersion: monitoring.coreos.com/v1 8 | kind: PrometheusRule 9 | metadata: 10 | name: prometheus-operator-example 11 | namespace: default 12 | labels: 13 | app: prometheus-operator 14 | chart: prometheus-operator-9.3.0 15 | release: "foo" 16 | heritage: "Helm" 17 | spec: 18 | groups: 19 | - name: mygroup 20 | rules: 21 | - annotations: 22 | summary: Container {{ $labels.container }} in Pod {{$labels.namespace}}/{{$labels.pod}} 23 | restarting 24 | expr: count(sum by (pod)(delta(kube_pod_container_status_restarts_total[15m]) 25 | > 0)) 26 | labels: 27 | severity: warning 28 | record: ContainerRestarted 29 | - apiVersion: monitoring.coreos.com/v1 30 | kind: PrometheusRule 31 | metadata: 32 | name: prometheus-operator-example2 33 | namespace: default 34 | labels: 35 | app: prometheus-operator 36 | chart: prometheus-operator-9.3.0 37 | release: "foo" 38 | heritage: "Helm" 39 | spec: 40 | groups: 41 | - name: mygroup2 42 | rules: 43 | - annotations: 44 | summary: Container {{ $labels.container }} in Pod {{$labels.namespace}}/{{$labels.pod}} 45 | restarting 46 | expr: count(sum by (pod)(delta(kube_pod_container_status_restarts_total[15m]) 47 | > 0)) 48 | labels: 49 | severity: warning 50 | record: ContainerRestarted 51 | -------------------------------------------------------------------------------- /manifest/testdata/pod.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/pod.yaml 4 | apiVersion: v1 5 | kind: Pod 6 | metadata: 7 | name: nginx 8 | spec: 9 | containers: 10 | - name: nginx 11 | image: nginx:1.7.9 12 | ports: 13 | - containerPort: 80 14 | -------------------------------------------------------------------------------- /manifest/testdata/pod_hook.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/pod.yaml 4 | apiVersion: v1 5 | kind: Pod 6 | metadata: 7 | name: nginx 8 | annotations: 9 | helm.sh/hook: test 10 | spec: 11 | containers: 12 | - name: nginx 13 | image: nginx:1.7.9 14 | ports: 15 | - containerPort: 80 16 | -------------------------------------------------------------------------------- /manifest/testdata/pod_namespace.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/pod.yaml 4 | apiVersion: v1 5 | kind: Pod 6 | metadata: 7 | name: nginx 8 | namespace: batcave 9 | spec: 10 | containers: 11 | - name: nginx 12 | image: nginx:1.7.9 13 | ports: 14 | - containerPort: 80 15 | -------------------------------------------------------------------------------- /manifest/testdata/pod_no_release_annotations.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/pod.yaml 4 | apiVersion: v1 5 | kind: Pod 6 | metadata: 7 | name: nginx 8 | annotations: 9 | some: "annotation" 10 | spec: 11 | containers: 12 | - name: nginx 13 | image: nginx:1.7.9 14 | ports: 15 | - containerPort: 80 16 | -------------------------------------------------------------------------------- /manifest/testdata/pod_release_annotations.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/pod.yaml 4 | apiVersion: v1 5 | kind: Pod 6 | metadata: 7 | name: nginx 8 | annotations: 9 | meta.helm.sh/release-namespace: "oldNS" 10 | meta.helm.sh/release-name: "oldReleaseName" 11 | spec: 12 | containers: 13 | - name: nginx 14 | image: nginx:1.7.9 15 | ports: 16 | - containerPort: 80 17 | -------------------------------------------------------------------------------- /manifest/testdata/secret_immutable.yaml: -------------------------------------------------------------------------------- 1 | 2 | --- 3 | # Source: nginx/some-secrets.yaml 4 | apiVersion: v1 5 | kind: Secret 6 | metadata: 7 | name: bat-secret-ab1234cd 8 | annotations: 9 | helm-diff/base-name: bat-secret 10 | immutable: true 11 | type: Opaque 12 | stringData: 13 | secret1: Very secretive secret 14 | secret2: One more 15 | -------------------------------------------------------------------------------- /manifest/testdata/secretlist_v1.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: SecretList 3 | items: 4 | - apiVersion: v1 5 | kind: Secret 6 | metadata: 7 | name: my-secret-1 8 | type: Opaque 9 | data: 10 | username: YWRtaW4= 11 | password: MWYyZDFlMmU2N2Rm 12 | - apiVersion: v1 13 | kind: Secret 14 | metadata: 15 | name: my-secret-2 16 | type: Opaque 17 | data: 18 | token: ZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFhV1FpT2pFc0luUnBiV1VpT2pFMU56RXlPRGd3TmpFeE1qQXdNVGN5TWpFeE1qQXdNVEE0TWpVMUxDSmhiR2NpT2lKSVV6STFOaUo5LmV5SjFhV1FpT2pFc0luUnBiV1VpT2pFMU56RXlPRGd3TmpFeE1qQXdNVGN5TWpFeE1qQXdNVEE0TWpVMUxDSmhiR2NpT2lKSVV6STFOaUo= 19 | -------------------------------------------------------------------------------- /manifest/util.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "fmt" 5 | 6 | jsoniter "github.com/json-iterator/go" 7 | ) 8 | 9 | func deleteStatusAndTidyMetadata(obj []byte) (map[string]interface{}, error) { 10 | var objectMap map[string]interface{} 11 | err := jsoniter.Unmarshal(obj, &objectMap) 12 | if err != nil { 13 | return nil, fmt.Errorf("could not unmarshal byte sequence: %w", err) 14 | } 15 | 16 | delete(objectMap, "status") 17 | 18 | metadata := objectMap["metadata"].(map[string]interface{}) 19 | 20 | delete(metadata, "managedFields") 21 | delete(metadata, "generation") 22 | delete(metadata, "creationTimestamp") 23 | delete(metadata, "resourceVersion") 24 | delete(metadata, "uid") 25 | 26 | // See the below for the goal of this metadata tidy logic. 27 | // https://github.com/databus23/helm-diff/issues/326#issuecomment-1008253274 28 | if a := metadata["annotations"]; a != nil { 29 | annotations := a.(map[string]interface{}) 30 | delete(annotations, "meta.helm.sh/release-name") 31 | delete(annotations, "meta.helm.sh/release-namespace") 32 | delete(annotations, "deployment.kubernetes.io/revision") 33 | 34 | if len(annotations) == 0 { 35 | delete(metadata, "annotations") 36 | } 37 | } 38 | 39 | return objectMap, nil 40 | } 41 | -------------------------------------------------------------------------------- /manifest/util_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func Test_deleteStatusAndTidyMetadata(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | obj []byte 13 | want map[string]interface{} 14 | wantErr bool 15 | }{ 16 | { 17 | name: "not valid json", 18 | obj: []byte("notvalid"), 19 | want: nil, 20 | wantErr: true, 21 | }, 22 | { 23 | name: "valid json", 24 | obj: []byte(` 25 | { 26 | "apiVersion": "apps/v1", 27 | "kind": "Deployment", 28 | "metadata": { 29 | "annotations": { 30 | "deployment.kubernetes.io/revision": "1", 31 | "meta.helm.sh/release-name": "test-release", 32 | "meta.helm.sh/release-namespace": "test-ns", 33 | "other-annot": "value" 34 | }, 35 | "creationTimestamp": "2025-03-03T10:07:50Z", 36 | "generation": 1, 37 | "name": "nginx-deployment", 38 | "namespace": "test-ns", 39 | "resourceVersion": "33648", 40 | "uid": "7a8d3b74-6452-46f4-a31f-4fdacbe828ac" 41 | }, 42 | "spec": { 43 | "template": { 44 | "spec": { 45 | "containers": [ 46 | { 47 | "image": "nginx:1.14.2", 48 | "imagePullPolicy": "IfNotPresent", 49 | "name": "nginx" 50 | } 51 | ] 52 | } 53 | } 54 | }, 55 | "status": { 56 | "availableReplicas": 2 57 | } 58 | } 59 | `), 60 | want: map[string]interface{}{ 61 | "apiVersion": "apps/v1", 62 | "kind": "Deployment", 63 | "metadata": map[string]interface{}{ 64 | "annotations": map[string]interface{}{ 65 | "other-annot": "value", 66 | }, 67 | "name": "nginx-deployment", 68 | "namespace": "test-ns", 69 | }, 70 | "spec": map[string]interface{}{ 71 | "template": map[string]interface{}{ 72 | "spec": map[string]interface{}{ 73 | "containers": []interface{}{ 74 | map[string]interface{}{ 75 | "image": "nginx:1.14.2", 76 | "imagePullPolicy": "IfNotPresent", 77 | "name": "nginx", 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | wantErr: false, 85 | }, 86 | } 87 | for _, tt := range tests { 88 | t.Run(tt.name, func(t *testing.T) { 89 | got, err := deleteStatusAndTidyMetadata(tt.obj) 90 | if (err != nil) != tt.wantErr { 91 | t.Errorf("deleteStatusAndTidyMetadata() error = %v, wantErr %v", err, tt.wantErr) 92 | return 93 | } 94 | require.Equalf(t, tt.want, got, "deleteStatusAndTidyMetadata() = %v, want %v", got, tt.want) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /plugin.yaml: -------------------------------------------------------------------------------- 1 | name: "diff" 2 | # Version is the version of Helm plus the number of official builds for this 3 | # plugin 4 | version: "3.12.1" 5 | usage: "Preview helm upgrade changes as a diff" 6 | description: "Preview helm upgrade changes as a diff" 7 | useTunnel: true 8 | command: "$HELM_PLUGIN_DIR/bin/diff" 9 | hooks: 10 | install: "$HELM_PLUGIN_DIR/install-binary.sh" 11 | update: "$HELM_PLUGIN_DIR/install-binary.sh -u" 12 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | # == would end up with: scripts/release.sh: 5: [: v3.8.1: unexpected operator 5 | if [ "$1" = "" ]; then 6 | echo usage: "$0 VERSION" 7 | fi 8 | 9 | git tag $1 10 | git push origin $1 11 | gh release create $1 --draft --generate-notes --title "$1" release/*.tgz 12 | -------------------------------------------------------------------------------- /scripts/setup-apimachinery.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2016 The Kubernetes Authors All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Copies the current versions of apimachinery and client-go out of the 18 | # main kubernetes repo. These repos are currently out of sync and not 19 | # versioned. 20 | set -euo pipefail 21 | 22 | 23 | rm -rf ./vendor/k8s.io/{kube-aggregator,apiserver,apimachinery,client-go} 24 | 25 | cp -r ./vendor/k8s.io/kubernetes/staging/src/k8s.io/{kube-aggregator,apiserver,apimachinery,client-go} ./vendor/k8s.io 26 | -------------------------------------------------------------------------------- /scripts/update-gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # script credits : https://github.com/infracloudio/botkube 3 | 4 | set -o errexit 5 | set -o nounset 6 | set -o pipefail 7 | 8 | find_files() { 9 | find . -not \( \ 10 | \( \ 11 | -wholename '*/vendor/*' \ 12 | \) -prune \ 13 | \) -name '*.go' 14 | } 15 | 16 | find_files | xargs gofmt -w -s 17 | -------------------------------------------------------------------------------- /scripts/verify-gofmt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # script credits : https://github.com/infracloudio/botkube 3 | 4 | set -o errexit 5 | set -o nounset 6 | set -o pipefail 7 | 8 | find_files() { 9 | find . -not \( \ 10 | \( \ 11 | -wholename '*/vendor/*' \ 12 | \) -prune \ 13 | \) -name '*.go' 14 | } 15 | 16 | bad_files=$(find_files | xargs gofmt -d -s 2>&1) 17 | if [[ -n "${bad_files}" ]]; then 18 | echo "${bad_files}" >&2 19 | echo >&2 20 | echo "Run ./hack/update-gofmt.sh" >&2 21 | exit 1 22 | fi 23 | -------------------------------------------------------------------------------- /scripts/verify-govet.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # script credits : https://github.com/infracloudio/botkube 3 | 4 | set -x 5 | 6 | go vet . ./cmd/... ./manifest/... 7 | -------------------------------------------------------------------------------- /scripts/verify-staticcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # script credits : https://github.com/infracloudio/botkube 3 | 4 | set -o nounset 5 | set -o pipefail 6 | 7 | find_packages() { 8 | find . -not \( \ 9 | \( \ 10 | -wholename '*/vendor/*' \ 11 | \) -prune \ 12 | \) -name '*.go' -exec dirname '{}' ';' | sort -u 13 | } 14 | 15 | errors="$(find_packages | xargs -I@ bash -c "staticcheck @")" 16 | if [[ -n "${errors}" ]]; then 17 | echo "${errors}" 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = ["all", "-ST1000", "-ST1005"] 2 | -------------------------------------------------------------------------------- /testdata/Dockerfile.install: -------------------------------------------------------------------------------- 1 | FROM alpine/helm:2.16.9 2 | 3 | ADD . /workspace 4 | 5 | WORKDIR /workspace 6 | 7 | RUN helm init -c 8 | RUN helm plugin install . 9 | RUN helm version -c 10 | 11 | FROM alpine/helm:3.2.4 12 | 13 | ADD . /workspace 14 | 15 | WORKDIR /workspace 16 | 17 | RUN helm plugin install . 18 | RUN helm version -c 19 | 20 | FROM ubuntu:focal 21 | 22 | ADD . /workspace 23 | 24 | WORKDIR /workspace 25 | 26 | # See "From Apt (Debian/Ubuntu)" at https://helm.sh/docs/intro/install/ 27 | RUN apt-get update && \ 28 | apt-get install curl && \ 29 | curl https://helm.baltorepo.com/organization/signing.asc | sudo apt-key add - && \ 30 | apt-get install apt-transport-https --yes && \ 31 | echo "deb https://baltocdn.com/helm/stable/debian/ all main" | tee /etc/apt/sources.list.d/helm-stable-debian.list && \ 32 | apt-get update && \ 33 | apt-get install helm 34 | 35 | RUN helm plugin install . 36 | RUN helm version -c 37 | --------------------------------------------------------------------------------