├── .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 | [](https://goreportcard.com/report/github.com/databus23/helm-diff)
3 | [](https://godoc.org/github.com/databus23/helm-diff)
4 | [](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 |
--------------------------------------------------------------------------------