diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml index 4309d800b..1637d26a5 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -44,6 +44,7 @@ body: label: Helm version value: |
+ ```console $ helm version # paste output here diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 6a44c8afb..7ab07d524 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 965410793..3d72d1e17 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -7,7 +7,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.' diff --git a/.golangci.yml b/.golangci.yml index 3df31b997..236dadf7b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -43,9 +43,12 @@ linters: - std-error-handling rules: - - linters: - - revive - text: 'var-naming: avoid meaningless package names' + # This rule is triggered for packages like 'util'. When changes to those packages + # occur it triggers this rule. This exclusion enables making changes to existing + # packages. + - linters: + - revive + text: 'var-naming: avoid meaningless package names' warn-unused: true diff --git a/go.mod b/go.mod index 77e761de2..858c42fe4 100644 --- a/go.mod +++ b/go.mod @@ -10,8 +10,9 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/vcs v1.13.3 + github.com/ProtonMail/go-crypto v1.3.0 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/cyphar/filepath-securejoin v0.4.1 + github.com/cyphar/filepath-securejoin v0.5.0 github.com/distribution/distribution/v3 v3.0.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/extism/go-sdk v1.7.1 @@ -19,7 +20,7 @@ require ( github.com/fluxcd/cli-utils v0.36.0-flux.14 github.com/foxcpp/go-mockdns v1.1.0 github.com/gobwas/glob v0.2.3 - github.com/gofrs/flock v0.12.1 + github.com/gofrs/flock v0.13.0 github.com/gosuri/uitable v0.0.4 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 @@ -35,9 +36,9 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tetratelabs/wazero v1.9.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/crypto v0.42.0 - golang.org/x/term v0.35.0 - golang.org/x/text v0.29.0 + golang.org/x/crypto v0.43.0 + golang.org/x/term v0.36.0 + golang.org/x/text v0.30.0 gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.1 k8s.io/apiextensions-apiserver v0.34.1 @@ -48,7 +49,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubectl v0.34.1 oras.land/oras-go/v2 v2.6.0 - sigs.k8s.io/controller-runtime v0.22.1 + sigs.k8s.io/controller-runtime v0.22.3 sigs.k8s.io/kustomize/kyaml v0.20.1 sigs.k8s.io/yaml v1.6.0 ) @@ -64,6 +65,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -160,13 +162,13 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.27.0 // indirect - golang.org/x/net v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.45.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.37.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/grpc v1.72.1 // indirect diff --git a/go.sum b/go.sum index 9fa40e4d4..bbb849a4f 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= @@ -49,14 +51,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.5.0 h1:hIAhkRBMQ8nIeuVwcAoymp7MY4oherZdAxD+m0u9zaw= +github.com/cyphar/filepath-securejoin v0.5.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -125,8 +129,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= @@ -392,16 +396,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= -golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -415,8 +419,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -450,8 +454,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -459,8 +463,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -468,8 +472,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -480,8 +484,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -532,8 +536,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= -sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= -sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= +sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= +sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= diff --git a/internal/chart/v3/lint/lint_test.go b/internal/chart/v3/lint/lint_test.go index 6f5912ae7..d61a9a740 100644 --- a/internal/chart/v3/lint/lint_test.go +++ b/internal/chart/v3/lint/lint_test.go @@ -27,8 +27,6 @@ import ( chartutil "helm.sh/helm/v4/internal/chart/v3/util" ) -var values map[string]interface{} - const namespace = "testNamespace" const badChartDir = "rules/testdata/badchartfile" @@ -41,6 +39,7 @@ const malformedTemplate = "rules/testdata/malformed-template" const invalidChartFileDir = "rules/testdata/invalidchartfile" func TestBadChartV3(t *testing.T) { + var values map[string]any m := RunAll(badChartDir, values, namespace).Messages if len(m) != 8 { t.Errorf("Number of errors %v", len(m)) @@ -90,6 +89,7 @@ func TestBadChartV3(t *testing.T) { } func TestInvalidYaml(t *testing.T) { + var values map[string]any m := RunAll(badYamlFileDir, values, namespace).Messages if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) @@ -100,6 +100,7 @@ func TestInvalidYaml(t *testing.T) { } func TestInvalidChartYamlV3(t *testing.T) { + var values map[string]any m := RunAll(invalidChartFileDir, values, namespace).Messages t.Log(m) if len(m) != 3 { @@ -111,6 +112,7 @@ func TestInvalidChartYamlV3(t *testing.T) { } func TestBadValuesV3(t *testing.T) { + var values map[string]any m := RunAll(badValuesFileDir, values, namespace).Messages if len(m) < 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) @@ -121,6 +123,7 @@ func TestBadValuesV3(t *testing.T) { } func TestBadCrdFileV3(t *testing.T) { + var values map[string]any m := RunAll(badCrdFileDir, values, namespace).Messages assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'") @@ -128,6 +131,7 @@ func TestBadCrdFileV3(t *testing.T) { } func TestGoodChart(t *testing.T) { + var values map[string]any m := RunAll(goodChartDir, values, namespace).Messages if len(m) != 0 { t.Error("All returned linter messages when it shouldn't have") @@ -141,6 +145,7 @@ func TestGoodChart(t *testing.T) { // // See https://github.com/helm/helm/issues/7923 func TestHelmCreateChart(t *testing.T) { + var values map[string]any dir := t.TempDir() createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) @@ -190,11 +195,11 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { // Add values to enable hpa, and ingress which are disabled by default. // This is the equivalent of: // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' - updatedValues := map[string]interface{}{ - "autoscaling": map[string]interface{}{ + updatedValues := map[string]any{ + "autoscaling": map[string]any{ "enabled": true, }, - "ingress": map[string]interface{}{ + "ingress": map[string]any{ "enabled": true, }, } @@ -213,6 +218,7 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { // lint ignores import-values // See https://github.com/helm/helm/issues/9658 func TestSubChartValuesChart(t *testing.T) { + var values map[string]any m := RunAll(subChartValuesDir, values, namespace).Messages if len(m) != 0 { t.Error("All returned linter messages when it shouldn't have") @@ -225,6 +231,7 @@ func TestSubChartValuesChart(t *testing.T) { // lint stuck with malformed template object // See https://github.com/helm/helm/issues/11391 func TestMalformedTemplate(t *testing.T) { + var values map[string]any c := time.After(3 * time.Second) ch := make(chan int, 1) var m []support.Message diff --git a/internal/cli/output/color.go b/internal/cli/output/color.go index 93bbbe56e..e59cdde87 100644 --- a/internal/cli/output/color.go +++ b/internal/cli/output/color.go @@ -19,24 +19,24 @@ package output import ( "github.com/fatih/color" - release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/common" ) // ColorizeStatus returns a colorized version of the status string based on the status value -func ColorizeStatus(status release.Status, noColor bool) string { +func ColorizeStatus(status common.Status, noColor bool) string { // Disable color if requested if noColor { return status.String() } switch status { - case release.StatusDeployed: + case common.StatusDeployed: return color.GreenString(status.String()) - case release.StatusFailed: + case common.StatusFailed: return color.RedString(status.String()) - case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback, release.StatusUninstalling: + case common.StatusPendingInstall, common.StatusPendingUpgrade, common.StatusPendingRollback, common.StatusUninstalling: return color.YellowString(status.String()) - case release.StatusUnknown: + case common.StatusUnknown: return color.RedString(status.String()) default: // For uninstalled, superseded, and any other status diff --git a/internal/cli/output/color_test.go b/internal/cli/output/color_test.go index c84e2c359..3b8de39e8 100644 --- a/internal/cli/output/color_test.go +++ b/internal/cli/output/color_test.go @@ -20,63 +20,63 @@ import ( "strings" "testing" - release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/common" ) func TestColorizeStatus(t *testing.T) { tests := []struct { name string - status release.Status + status common.Status noColor bool envNoColor string wantColor bool // whether we expect color codes in output }{ { name: "deployed status with color", - status: release.StatusDeployed, + status: common.StatusDeployed, noColor: false, envNoColor: "", wantColor: true, }, { name: "deployed status without color flag", - status: release.StatusDeployed, + status: common.StatusDeployed, noColor: true, envNoColor: "", wantColor: false, }, { name: "deployed status with NO_COLOR env", - status: release.StatusDeployed, + status: common.StatusDeployed, noColor: false, envNoColor: "1", wantColor: false, }, { name: "failed status with color", - status: release.StatusFailed, + status: common.StatusFailed, noColor: false, envNoColor: "", wantColor: true, }, { name: "pending install status with color", - status: release.StatusPendingInstall, + status: common.StatusPendingInstall, noColor: false, envNoColor: "", wantColor: true, }, { name: "unknown status with color", - status: release.StatusUnknown, + status: common.StatusUnknown, noColor: false, envNoColor: "", wantColor: true, }, { name: "superseded status with color", - status: release.StatusSuperseded, + status: common.StatusSuperseded, noColor: false, envNoColor: "", wantColor: false, // superseded doesn't get colored diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index b65dac2f4..a6599c443 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -18,6 +18,7 @@ package installer import ( "errors" "fmt" + "log/slog" "net/http" "os" "path/filepath" @@ -79,6 +80,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) return nil, err } if _, pathErr := os.Stat(i.Path()); !os.IsNotExist(pathErr) { + slog.Warn("plugin already exists", "path", i.Path(), slog.Any("error", pathErr)) return nil, errors.New("plugin already exists") } @@ -130,6 +132,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) // Update updates a plugin. func Update(i Installer) error { if _, pathErr := os.Stat(i.Path()); os.IsNotExist(pathErr) { + slog.Warn("plugin does not exist", "path", i.Path(), slog.Any("error", pathErr)) return errors.New("plugin does not exist") } return i.Update() @@ -154,6 +157,7 @@ func NewForSource(source, version string) (Installer, error) { func FindSource(location string) (Installer, error) { installer, err := existingVCSRepo(location) if err != nil && err.Error() == "Cannot detect VCS" { + slog.Warn("cannot get information about plugin source", "location", location, slog.Any("error", err)) return installer, errors.New("cannot get information about plugin source") } return installer, err diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index 5e6676a00..802732b14 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -29,7 +29,7 @@ import ( "helm.sh/helm/v4/internal/plugin/schema" ) -// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protcol +// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protocol type SubprocessProtocolCommand struct { // Protocols are the list of schemes from the charts URL. Protocols []string `yaml:"protocols"` diff --git a/internal/plugin/schema/cli.go b/internal/plugin/schema/cli.go index 702b27e45..2282580f5 100644 --- a/internal/plugin/schema/cli.go +++ b/internal/plugin/schema/cli.go @@ -15,13 +15,10 @@ package schema import ( "bytes" - - "helm.sh/helm/v4/pkg/cli" ) type InputMessageCLIV1 struct { - ExtraArgs []string `json:"extraArgs"` - Settings *cli.EnvSettings `json:"settings"` + ExtraArgs []string `json:"extraArgs"` } type OutputMessageCLIV1 struct { diff --git a/internal/plugin/signing_info.go b/internal/plugin/signing_info.go index 43d01c893..61ee9cd15 100644 --- a/internal/plugin/signing_info.go +++ b/internal/plugin/signing_info.go @@ -23,7 +23,7 @@ import ( "path/filepath" "strings" - "golang.org/x/crypto/openpgp/clearsign" //nolint + "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint "helm.sh/helm/v4/pkg/helmpath" ) diff --git a/pkg/action/action.go b/pkg/action/action.go index 1aa9f9d19..fd75b85d3 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -47,6 +47,7 @@ import ( "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/registry" + ri "helm.sh/helm/v4/pkg/release" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage" @@ -70,6 +71,21 @@ var ( errPending = errors.New("another operation (install/upgrade/rollback) is in progress") ) +type DryRunStrategy string + +const ( + // DryRunNone indicates the client will make all mutating calls + DryRunNone DryRunStrategy = "none" + + // DryRunClient, or client-side dry-run, indicates the client will avoid + // making calls to the server + DryRunClient DryRunStrategy = "client" + + // DryRunServer, or server-side dry-run, indicates the client will send + // calls to the APIServer with the dry-run parameter to prevent persisting changes + DryRunServer DryRunStrategy = "server" +) + // Configuration injects the dependencies that all actions share. type Configuration struct { // RESTClientGetter is an interface that loads Kubernetes clients. @@ -397,7 +413,7 @@ func (cfg *Configuration) Now() time.Time { return Timestamper() } -func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) { +func (cfg *Configuration) releaseContent(name string, version int) (ri.Releaser, error) { if err := chartutil.ValidateReleaseName(name); err != nil { return nil, fmt.Errorf("releaseContent: Release name is invalid: %s", name) } @@ -528,3 +544,13 @@ func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod { } return release.ApplyMethodClientSideApply } + +// isDryRun returns true if the strategy is set to run as a DryRun +func isDryRun(strategy DryRunStrategy) bool { + return strategy == DryRunClient || strategy == DryRunServer +} + +// interactWithServer determine whether or not to interact with a remote Kubernetes server +func interactWithServer(strategy DryRunStrategy) bool { + return strategy == DryRunNone || strategy == DryRunServer +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 78ca01089..06329095e 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -36,6 +36,7 @@ import ( "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/registry" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" @@ -249,10 +250,10 @@ func withKube(version string) chartOption { // releaseStub creates a release stub, complete with the chartStub as its chart. func releaseStub() *release.Release { - return namedReleaseStub("angry-panda", release.StatusDeployed) + return namedReleaseStub("angry-panda", rcommon.StatusDeployed) } -func namedReleaseStub(name string, status release.Status) *release.Release { +func namedReleaseStub(name string, status rcommon.Status) *release.Release { now := time.Now() return &release.Release{ Name: name, @@ -951,3 +952,15 @@ func TestDetermineReleaseSSAApplyMethod(t *testing.T) { assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) } + +func TestIsDryRun(t *testing.T) { + assert.False(t, isDryRun(DryRunNone)) + assert.True(t, isDryRun(DryRunClient)) + assert.True(t, isDryRun(DryRunServer)) +} + +func TestInteractWithServer(t *testing.T) { + assert.True(t, interactWithServer(DryRunNone)) + assert.False(t, interactWithServer(DryRunClient)) + assert.True(t, interactWithServer(DryRunServer)) +} diff --git a/pkg/action/get.go b/pkg/action/get.go index dbe5f4cb3..b5e7c194b 100644 --- a/pkg/action/get.go +++ b/pkg/action/get.go @@ -17,7 +17,7 @@ limitations under the License. package action import ( - release "helm.sh/helm/v4/pkg/release/v1" + release "helm.sh/helm/v4/pkg/release" ) // Get is the action for checking a given release's information. @@ -38,7 +38,7 @@ func NewGet(cfg *Configuration) *Get { } // Run executes 'helm get' against the given release. -func (g *Get) Run(name string) (*release.Release, error) { +func (g *Get) Run(name string) (release.Releaser, error) { if err := g.cfg.KubeClient.IsReachable(); err != nil { return nil, err } diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 889545ddc..5312dac7f 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -17,11 +17,15 @@ limitations under the License. package action import ( + "errors" + "log/slog" "sort" "strings" "time" + ci "helm.sh/helm/v4/pkg/chart" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release" ) // GetMetadata is the action for checking a given release's metadata. @@ -41,13 +45,13 @@ type Metadata struct { // Annotations are fetched from the Chart.yaml file Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` // Labels of the release which are stored in driver metadata fields storage - Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` - Dependencies []*chart.Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` - Namespace string `json:"namespace" yaml:"namespace"` - Revision int `json:"revision" yaml:"revision"` - Status string `json:"status" yaml:"status"` - DeployedAt string `json:"deployedAt" yaml:"deployedAt"` - ApplyMethod string `json:"applyMethod,omitempty" yaml:"applyMethod,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Dependencies []ci.Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` + Namespace string `json:"namespace" yaml:"namespace"` + Revision int `json:"revision" yaml:"revision"` + Status string `json:"status" yaml:"status"` + DeployedAt string `json:"deployedAt" yaml:"deployedAt"` + ApplyMethod string `json:"applyMethod,omitempty" yaml:"applyMethod,omitempty"` } // NewGetMetadata creates a new GetMetadata object with the given configuration. @@ -68,19 +72,40 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) { return nil, err } + rac, err := release.NewAccessor(rel) + if err != nil { + return nil, err + } + ac, err := ci.NewAccessor(rac.Chart()) + if err != nil { + return nil, err + } + + charti := rac.Chart() + + var chrt *chart.Chart + switch c := charti.(type) { + case *chart.Chart: + chrt = c + case chart.Chart: + chrt = &c + default: + return nil, errors.New("invalid chart apiVersion") + } + return &Metadata{ - Name: rel.Name, - Chart: rel.Chart.Metadata.Name, - Version: rel.Chart.Metadata.Version, - AppVersion: rel.Chart.Metadata.AppVersion, - Dependencies: rel.Chart.Metadata.Dependencies, - Annotations: rel.Chart.Metadata.Annotations, - Labels: rel.Labels, - Namespace: rel.Namespace, - Revision: rel.Version, - Status: rel.Info.Status.String(), - DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339), - ApplyMethod: rel.ApplyMethod, + Name: rac.Name(), + Chart: chrt.Metadata.Name, + Version: chrt.Metadata.Version, + AppVersion: chrt.Metadata.AppVersion, + Dependencies: ac.MetaDependencies(), + Annotations: chrt.Metadata.Annotations, + Labels: rac.Labels(), + Namespace: rac.Namespace(), + Revision: rac.Version(), + Status: rac.Status(), + DeployedAt: rac.DeployedAt().Format(time.RFC3339), + ApplyMethod: rac.ApplyMethod(), }, nil } @@ -88,7 +113,13 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) { func (m *Metadata) FormattedDepNames() string { depsNames := make([]string, 0, len(m.Dependencies)) for _, dep := range m.Dependencies { - depsNames = append(depsNames, dep.Name) + ac, err := ci.NewDependencyAccessor(dep) + if err != nil { + slog.Error("unable to access dependency metadata", "error", err) + continue + } + depsNames = append(depsNames, ac.Name()) + } sort.StringSlice(depsNames).Sort() diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index 7962a2133..cd5988d8e 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -25,8 +25,10 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + ci "helm.sh/helm/v4/pkg/chart" chart "helm.sh/helm/v4/pkg/chart/v2" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -49,7 +51,7 @@ func TestGetMetadata_Run_BasicMetadata(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -63,7 +65,8 @@ func TestGetMetadata_Run_BasicMetadata(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + err := cfg.Releases.Create(rel) + require.NoError(t, err) result, err := client.Run(releaseName) require.NoError(t, err) @@ -103,7 +106,7 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -123,13 +126,18 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) + dep0, err := ci.NewDependencyAccessor(result.Dependencies[0]) + require.NoError(t, err) + dep1, err := ci.NewDependencyAccessor(result.Dependencies[1]) + require.NoError(t, err) + assert.Equal(t, releaseName, result.Name) assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "1.0.0", result.Version) - assert.Equal(t, dependencies, result.Dependencies) + assert.Equal(t, convertDeps(dependencies), result.Dependencies) assert.Len(t, result.Dependencies, 2) - assert.Equal(t, "mysql", result.Dependencies[0].Name) - assert.Equal(t, "redis", result.Dependencies[1].Name) + assert.Equal(t, "mysql", dep0.Name()) + assert.Equal(t, "redis", dep1.Name()) } func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { @@ -157,7 +165,7 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -177,15 +185,20 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) + dep0, err := ci.NewDependencyAccessor(result.Dependencies[0]) + require.NoError(t, err) + dep1, err := ci.NewDependencyAccessor(result.Dependencies[1]) + require.NoError(t, err) + assert.Equal(t, releaseName, result.Name) assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "1.0.0", result.Version) - assert.Equal(t, dependencies, result.Dependencies) + assert.Equal(t, convertDeps(dependencies), result.Dependencies) assert.Len(t, result.Dependencies, 2) - assert.Equal(t, "mysql", result.Dependencies[0].Name) - assert.Equal(t, "database", result.Dependencies[0].Alias) - assert.Equal(t, "redis", result.Dependencies[1].Name) - assert.Equal(t, "cache", result.Dependencies[1].Alias) + assert.Equal(t, "mysql", dep0.Name()) + assert.Equal(t, "database", dep0.Alias()) + assert.Equal(t, "redis", dep1.Name()) + assert.Equal(t, "cache", dep1.Alias()) } func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { @@ -223,7 +236,7 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -243,23 +256,32 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) + dep0, err := ci.NewDependencyAccessor(result.Dependencies[0]) + require.NoError(t, err) + dep1, err := ci.NewDependencyAccessor(result.Dependencies[1]) + require.NoError(t, err) + dep2, err := ci.NewDependencyAccessor(result.Dependencies[2]) + require.NoError(t, err) + dep3, err := ci.NewDependencyAccessor(result.Dependencies[3]) + require.NoError(t, err) + assert.Equal(t, releaseName, result.Name) assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "1.0.0", result.Version) - assert.Equal(t, dependencies, result.Dependencies) + assert.Equal(t, convertDeps(dependencies), result.Dependencies) assert.Len(t, result.Dependencies, 4) // Verify dependencies with aliases - assert.Equal(t, "mysql", result.Dependencies[0].Name) - assert.Equal(t, "database", result.Dependencies[0].Alias) - assert.Equal(t, "redis", result.Dependencies[2].Name) - assert.Equal(t, "cache", result.Dependencies[2].Alias) + assert.Equal(t, "mysql", dep0.Name()) + assert.Equal(t, "database", dep0.Alias()) + assert.Equal(t, "redis", dep2.Name()) + assert.Equal(t, "cache", dep2.Alias()) // Verify dependencies without aliases - assert.Equal(t, "nginx", result.Dependencies[1].Name) - assert.Equal(t, "", result.Dependencies[1].Alias) - assert.Equal(t, "postgresql", result.Dependencies[3].Name) - assert.Equal(t, "", result.Dependencies[3].Alias) + assert.Equal(t, "nginx", dep1.Name()) + assert.Equal(t, "", dep1.Alias()) + assert.Equal(t, "postgresql", dep3.Name()) + assert.Equal(t, "", dep3.Alias()) } func TestGetMetadata_Run_WithAnnotations(t *testing.T) { @@ -278,7 +300,7 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -317,7 +339,7 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) { rel1 := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusSuperseded, + Status: common.StatusSuperseded, LastDeployed: deployedTime.Add(-time.Hour), }, Chart: &chart.Chart{ @@ -334,7 +356,7 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) { rel2 := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -368,16 +390,16 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) { testCases := []struct { name string - status release.Status + status common.Status expected string }{ - {"deployed", release.StatusDeployed, "deployed"}, - {"failed", release.StatusFailed, "failed"}, - {"uninstalled", release.StatusUninstalled, "uninstalled"}, - {"pending-install", release.StatusPendingInstall, "pending-install"}, - {"pending-upgrade", release.StatusPendingUpgrade, "pending-upgrade"}, - {"pending-rollback", release.StatusPendingRollback, "pending-rollback"}, - {"superseded", release.StatusSuperseded, "superseded"}, + {"deployed", common.StatusDeployed, "deployed"}, + {"failed", common.StatusFailed, "failed"}, + {"uninstalled", common.StatusUninstalled, "uninstalled"}, + {"pending-install", common.StatusPendingInstall, "pending-install"}, + {"pending-upgrade", common.StatusPendingUpgrade, "pending-upgrade"}, + {"pending-rollback", common.StatusPendingRollback, "pending-rollback"}, + {"superseded", common.StatusSuperseded, "superseded"}, } for _, tc := range testCases { @@ -444,7 +466,7 @@ func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, LastDeployed: deployedTime, }, Chart: &chart.Chart{ @@ -515,8 +537,9 @@ func TestMetadata_FormattedDepNames(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + deps := convertDeps(tc.dependencies) metadata := &Metadata{ - Dependencies: tc.dependencies, + Dependencies: deps, } result := metadata.FormattedDepNames() @@ -525,6 +548,14 @@ func TestMetadata_FormattedDepNames(t *testing.T) { } } +func convertDeps(deps []*chart.Dependency) []ci.Dependency { + var newDeps = make([]ci.Dependency, len(deps)) + for i, c := range deps { + newDeps[i] = c + } + return newDeps +} + func TestMetadata_FormattedDepNames_WithComplexDependencies(t *testing.T) { dependencies := []*chart.Dependency{ { @@ -546,8 +577,9 @@ func TestMetadata_FormattedDepNames_WithComplexDependencies(t *testing.T) { }, } + deps := convertDeps(dependencies) metadata := &Metadata{ - Dependencies: dependencies, + Dependencies: deps, } result := metadata.FormattedDepNames() @@ -597,8 +629,9 @@ func TestMetadata_FormattedDepNames_WithAliases(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + deps := convertDeps(tc.dependencies) metadata := &Metadata{ - Dependencies: tc.dependencies, + Dependencies: deps, } result := metadata.FormattedDepNames() @@ -609,7 +642,7 @@ func TestMetadata_FormattedDepNames_WithAliases(t *testing.T) { func TestGetMetadata_Labels(t *testing.T) { rel := releaseStub() - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed customLabels := map[string]string{"key1": "value1", "key2": "value2"} rel.Labels = customLabels diff --git a/pkg/action/get_values.go b/pkg/action/get_values.go index a0b5d92c1..6475a140b 100644 --- a/pkg/action/get_values.go +++ b/pkg/action/get_values.go @@ -16,7 +16,13 @@ limitations under the License. package action -import "helm.sh/helm/v4/pkg/chart/common/util" +import ( + "fmt" + + "helm.sh/helm/v4/pkg/chart/common/util" + release "helm.sh/helm/v4/pkg/release" + rspb "helm.sh/helm/v4/pkg/release/v1" +) // GetValues is the action for checking a given release's values. // @@ -41,7 +47,12 @@ func (g *GetValues) Run(name string) (map[string]interface{}, error) { return nil, err } - rel, err := g.cfg.releaseContent(name, g.Version) + reli, err := g.cfg.releaseContent(name, g.Version) + if err != nil { + return nil, err + } + + rel, err := releaserToV1Release(reli) if err != nil { return nil, err } @@ -56,3 +67,18 @@ func (g *GetValues) Run(name string) (map[string]interface{}, error) { } return rel.Config, nil } + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} diff --git a/pkg/action/get_values_test.go b/pkg/action/get_values_test.go index b8630c322..8e6588454 100644 --- a/pkg/action/get_values_test.go +++ b/pkg/action/get_values_test.go @@ -26,6 +26,7 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -58,7 +59,7 @@ func TestGetValues_Run_UserConfigOnly(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -112,7 +113,7 @@ func TestGetValues_Run_AllValues(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -147,7 +148,7 @@ func TestGetValues_Run_EmptyValues(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -198,7 +199,7 @@ func TestGetValues_Run_NilConfig(t *testing.T) { rel := &release.Release{ Name: releaseName, Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ diff --git a/pkg/action/history.go b/pkg/action/history.go index d7af1d6a4..dc3ab51d4 100644 --- a/pkg/action/history.go +++ b/pkg/action/history.go @@ -22,7 +22,7 @@ import ( "fmt" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - release "helm.sh/helm/v4/pkg/release/v1" + release "helm.sh/helm/v4/pkg/release" ) // History is the action for checking the release's ledger. @@ -46,7 +46,7 @@ func NewHistory(cfg *Configuration) *History { } // Run executes 'helm history' against the given release. -func (h *History) Run(name string) ([]*release.Release, error) { +func (h *History) Run(name string) ([]release.Releaser, error) { if err := h.cfg.KubeClient.IsReachable(); err != nil { return nil, err } diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index 4808bc054..1e4fec9bd 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -154,7 +154,7 @@ func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.Hoo if err != nil { return fmt.Errorf("unable to build kubernetes object for deleting hook %s: %w", h.Path, err) } - _, errs := cfg.KubeClient.Delete(resources) + _, errs := cfg.KubeClient.Delete(resources, metav1.DeletePropagationBackground) if len(errs) > 0 { return joinErrors(errs, "; ") } @@ -223,16 +223,12 @@ func (cfg *Configuration) outputLogsByPolicy(h *release.Hook, releaseNamespace s } func (cfg *Configuration) outputContainerLogsForListOptions(namespace string, listOptions metav1.ListOptions) error { - // TODO Helm 4: Remove this check when GetPodList and OutputContainerLogsForPodList are moved from InterfaceLogs to Interface - if kubeClient, ok := cfg.KubeClient.(kube.InterfaceLogs); ok { - podList, err := kubeClient.GetPodList(namespace, listOptions) - if err != nil { - return err - } - err = kubeClient.OutputContainerLogsForPodList(podList, namespace, cfg.HookOutputFunc) + podList, err := cfg.KubeClient.GetPodList(namespace, listOptions) + if err != nil { return err } - return nil + + return cfg.KubeClient.OutputContainerLogsForPodList(podList, namespace, cfg.HookOutputFunc) } func (cfg *Configuration) deriveNamespace(h *release.Hook, namespace string) (string, error) { diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index fb7d1b4ec..9502737d7 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -27,12 +27,14 @@ import ( "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/cli-runtime/pkg/resource" "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" @@ -184,10 +186,12 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str } vals := map[string]interface{}{} - res, err := instAction.Run(buildChartWithTemplates(templates), vals) + resi, err := instAction.Run(buildChartWithTemplates(templates), vals) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) is.Equal(expectedOutput, outBuffer.String()) - is.Equal(release.StatusDeployed, res.Info.Status) + is.Equal(rcommon.StatusDeployed, res.Info.Status) } func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace string, shouldOutput bool) { @@ -211,11 +215,13 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str } vals := map[string]interface{}{} - res, err := instAction.Run(buildChartWithTemplates(templates), vals) + resi, err := instAction.Run(buildChartWithTemplates(templates), vals) is.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "failed pre-install") is.Equal(expectedOutput, outBuffer.String()) - is.Equal(release.StatusFailed, res.Info.Status) + is.Equal(rcommon.StatusFailed, res.Info.Status) } type HookFailedError struct{} @@ -259,7 +265,7 @@ func (h *HookFailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ t return nil } -func (h *HookFailingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, []error) { +func (h *HookFailingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) { for _, res := range resources { h.deleteRecord = append(h.deleteRecord, resource.Info{ Name: res.Name, @@ -267,7 +273,7 @@ func (h *HookFailingKubeClient) Delete(resources kube.ResourceList) (*kube.Resul }) } - return h.PrintingKubeClient.Delete(resources) + return h.PrintingKubeClient.Delete(resources, deletionPropagation) } func (h *HookFailingKubeClient) GetWaiter(strategy kube.WaitStrategy) (kube.Waiter, error) { diff --git a/pkg/action/install.go b/pkg/action/install.go index e8c03db3d..ba5973c67 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -26,7 +26,6 @@ import ( "log/slog" "net/url" "os" - "path" "path/filepath" "strings" "sync" @@ -54,6 +53,8 @@ import ( kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/registry" + ri "helm.sh/helm/v4/pkg/release" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/repo/v1" @@ -75,7 +76,6 @@ type Install struct { ChartPathOptions - ClientOnly bool // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway. // // This should be used with caution. @@ -87,8 +87,8 @@ type Install struct { // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ ServerSideApply bool CreateNamespace bool - DryRun bool - DryRunOption string + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // HideSecret can be set to true when DryRun is enabled in order to hide // Kubernetes Secrets in the output. It cannot be used outside of DryRun. HideSecret bool @@ -160,6 +160,7 @@ func NewInstall(cfg *Configuration) *Install { in := &Install{ cfg: cfg, ServerSideApply: true, + DryRunStrategy: DryRunNone, } in.registryClient = cfg.RegistryClient @@ -245,7 +246,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error { // // If DryRun is set to true, this will prepare the release, but not install it -func (i *Install) Run(chrt ci.Charter, vals map[string]interface{}) (*release.Release, error) { +func (i *Install) Run(chrt ci.Charter, vals map[string]interface{}) (ri.Releaser, error) { ctx := context.Background() return i.RunWithContext(ctx, chrt, vals) } @@ -254,7 +255,7 @@ func (i *Install) Run(chrt ci.Charter, vals map[string]interface{}) (*release.Re // // When the task is cancelled through ctx, the function returns and the install // proceeds in the background. -func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[string]interface{}) (*release.Release, error) { +func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[string]interface{}) (ri.Releaser, error) { var chrt *chart.Chart switch c := ch.(type) { case *chart.Chart: @@ -265,8 +266,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, errors.New("invalid chart apiVersion") } - // Check reachability of cluster unless in client-only mode (e.g. `helm template` without `--validate`) - if !i.ClientOnly { + if interactWithServer(i.DryRunStrategy) { if err := i.cfg.KubeClient.IsReachable(); err != nil { slog.Error(fmt.Sprintf("cluster reachability check failed: %v", err)) return nil, fmt.Errorf("cluster reachability check failed: %w", err) @@ -274,7 +274,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // HideSecret must be used with dry run. Otherwise, return an error. - if !i.isDryRun() && i.HideSecret { + if !isDryRun(i.DryRunStrategy) && i.HideSecret { slog.Error("hiding Kubernetes secrets requires a dry-run mode") return nil, errors.New("hiding Kubernetes secrets requires a dry-run mode") } @@ -289,23 +289,18 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st return nil, fmt.Errorf("chart dependencies processing failed: %w", err) } - var interactWithRemote bool - if !i.isDryRun() || i.DryRunOption == "server" || i.DryRunOption == "none" || i.DryRunOption == "false" { - interactWithRemote = true - } - // Pre-install anything in the crd/ directory. We do this before Helm // contacts the upstream server and builds the capabilities object. - if crds := chrt.CRDObjects(); !i.ClientOnly && !i.SkipCRDs && len(crds) > 0 { + if crds := chrt.CRDObjects(); interactWithServer(i.DryRunStrategy) && !i.SkipCRDs && len(crds) > 0 { // On dry run, bail here - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { slog.Warn("This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.") } else if err := i.installCRDs(crds); err != nil { return nil, err } } - if i.ClientOnly { + if !interactWithServer(i.DryRunStrategy) { // Add mock objects in here so it doesn't use Kube API server // NOTE(bacongobbler): used for `helm template` i.cfg.Capabilities = common.DefaultCapabilities.Copy() @@ -318,7 +313,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st mem := driver.NewMemory() mem.SetNamespace(i.Namespace) i.cfg.Releases = storage.Init(mem) - } else if !i.ClientOnly && len(i.APIVersions) > 0 { + } else if interactWithServer(i.DryRunStrategy) && len(i.APIVersions) > 0 { slog.Debug("API Version list given outside of client only mode, this list will be ignored") } @@ -334,7 +329,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // special case for helm template --is-upgrade - isUpgrade := i.IsUpgrade && i.isDryRun() + isUpgrade := i.IsUpgrade && isDryRun(i.DryRunStrategy) options := common.ReleaseOptions{ Name: i.ReleaseName, Namespace: i.Namespace, @@ -354,20 +349,20 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) var manifestDoc *bytes.Buffer - rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithRemote, i.EnableDNS, i.HideSecret) + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() } // Check error from render if err != nil { - rel.SetStatus(release.StatusFailed, fmt.Sprintf("failed to render resource: %s", err.Error())) + rel.SetStatus(rcommon.StatusFailed, fmt.Sprintf("failed to render resource: %s", err.Error())) // Return a release with partial data so that the client can show debugging information. return rel, err } // Mark this release as in-progress - rel.SetStatus(release.StatusPendingInstall, "Initial install underway") + rel.SetStatus(rcommon.StatusPendingInstall, "Initial install underway") var toBeAdopted kube.ResourceList resources, err := i.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), !i.DisableOpenAPIValidation) @@ -387,7 +382,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st // we'll end up in a state where we will delete those resources upon // deleting the release because the manifest will be pointing at that // resource - if !i.ClientOnly && !isUpgrade && len(resources) > 0 { + if interactWithServer(i.DryRunStrategy) && !isUpgrade && len(resources) > 0 { if i.TakeOwnership { toBeAdopted, err = requireAdoption(resources) } else { @@ -399,7 +394,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st } // Bail out here if it is a dry run - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { rel.Info.Description = "Dry run complete" return rel, nil } @@ -481,14 +476,6 @@ func (i *Install) getGoroutineCount() int32 { return i.goroutineCount.Load() } -// isDryRun returns true if Upgrade is set to run as a DryRun -func (i *Install) isDryRun() bool { - if i.DryRun || i.DryRunOption == "client" || i.DryRunOption == "server" || i.DryRunOption == "true" { - return true - } - return false -} - func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { var err error // pre-install hooks @@ -540,9 +527,9 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource } if len(i.Description) > 0 { - rel.SetStatus(release.StatusDeployed, i.Description) + rel.SetStatus(rcommon.StatusDeployed, i.Description) } else { - rel.SetStatus(release.StatusDeployed, "Install complete") + rel.SetStatus(rcommon.StatusDeployed, "Install complete") } // This is a tricky case. The release has been created, but the result @@ -560,7 +547,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource } func (i *Install) failRelease(rel *release.Release, err error) (*release.Release, error) { - rel.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", i.ReleaseName, err.Error())) + rel.SetStatus(rcommon.StatusFailed, fmt.Sprintf("Release %q failed: %s", i.ReleaseName, err.Error())) if i.RollbackOnFailure { slog.Debug("install failed and rollback-on-failure is set, uninstalling release", "release", i.ReleaseName) uninstall := NewUninstall(i.cfg) @@ -591,7 +578,7 @@ func (i *Install) availableName() error { return fmt.Errorf("release name %q: %w", start, err) } // On dry run, bail here - if i.isDryRun() { + if isDryRun(i.DryRunStrategy) { return nil } @@ -599,15 +586,43 @@ func (i *Install) availableName() error { if err != nil || len(h) < 1 { return nil } - releaseutil.Reverse(h, releaseutil.SortByRevision) - rel := h[0] - if st := rel.Info.Status; i.Replace && (st == release.StatusUninstalled || st == release.StatusFailed) { + hl, err := releaseListToV1List(h) + if err != nil { + return err + } + + releaseutil.Reverse(hl, releaseutil.SortByRevision) + rel := hl[0] + + if st := rel.Info.Status; i.Replace && (st == rcommon.StatusUninstalled || st == rcommon.StatusFailed) { return nil } return errors.New("cannot reuse a name that is still in use") } +func releaseListToV1List(ls []ri.Releaser) ([]*release.Release, error) { + rls := make([]*release.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil +} + +func releaseV1ListToReleaserList(ls []*release.Release) ([]ri.Releaser, error) { + rls := make([]ri.Releaser, 0, len(ls)) + for _, val := range ls { + rls = append(rls, val) + } + + return rls, nil +} + // createRelease creates a new release object func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release { ts := i.cfg.Now() @@ -620,7 +635,7 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{ Info: &release.Info{ FirstDeployed: ts, LastDeployed: ts, - Status: release.StatusUnknown, + Status: rcommon.StatusUnknown, }, Version: 1, Labels: labels, @@ -646,20 +661,24 @@ func (i *Install) replaceRelease(rel *release.Release) error { // No releases exist for this name, so we can return early return nil } + hl, err := releaseListToV1List(hist) + if err != nil { + return err + } - releaseutil.Reverse(hist, releaseutil.SortByRevision) - last := hist[0] + releaseutil.Reverse(hl, releaseutil.SortByRevision) + last := hl[0] // Update version to the next available rel.Version = last.Version + 1 // Do not change the status of a failed release. - if last.Info.Status == release.StatusFailed { + if last.Info.Status == rcommon.StatusFailed { return nil } // For any other status, mark it as superseded and store the old record - last.SetStatus(release.StatusSuperseded, "superseded by new release") + last.SetStatus(rcommon.StatusSuperseded, "superseded by new release") return i.recordRelease(last) } @@ -698,7 +717,7 @@ func createOrOpenFile(filename string, appendData bool) (*os.File, error) { // check if the directory exists to create file. creates if doesn't exist func ensureDirectoryForFile(file string) error { - baseDir := path.Dir(file) + baseDir := filepath.Dir(file) _, err := os.Stat(baseDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err @@ -829,7 +848,7 @@ func urlEqual(u1, u2 *url.URL) bool { // This does not ensure that the chart is well-formed; only that the requested filename exists. // // Order of resolution: -// - relative to current working directory +// - relative to current working directory when --repo flag is not presented // - if path is absolute or begins with '.', error out here // - URL // @@ -842,20 +861,22 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) ( name = strings.TrimSpace(name) version := strings.TrimSpace(c.Version) - if _, err := os.Stat(name); err == nil { - abs, err := filepath.Abs(name) - if err != nil { - return abs, err - } - if c.Verify { - if _, err := downloader.VerifyChart(abs, abs+".prov", c.Keyring); err != nil { - return "", err + if c.RepoURL == "" { + if _, err := os.Stat(name); err == nil { + abs, err := filepath.Abs(name) + if err != nil { + return abs, err } + if c.Verify { + if _, err := downloader.VerifyChart(abs, abs+".prov", c.Keyring); err != nil { + return "", err + } + } + return abs, nil + } + if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { + return name, fmt.Errorf("path %q not found", name) } - return abs, nil - } - if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { - return name, fmt.Errorf("path %q not found", name) } dl := downloader.ChartDownloader{ diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index aae36152d..3900c0633 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -47,6 +47,7 @@ import ( "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -130,14 +131,19 @@ func TestInstallRelease(t *testing.T) { instAction := installAction(t) vals := map[string]interface{}{} ctx, done := context.WithCancel(t.Context()) - res, err := instAction.RunWithContext(ctx, buildChart(), vals) + resi, err := instAction.RunWithContext(ctx, buildChart(), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Equal(res.Name, "test-install-release", "Expected release name.") is.Equal(res.Namespace, "spaced") - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + rel, err := releaserToV1Release(r) is.NoError(err) is.Len(rel.Hooks, 1) @@ -156,7 +162,9 @@ func TestInstallRelease(t *testing.T) { time.Sleep(time.Millisecond * 100) lastRelease, err := instAction.cfg.Releases.Last(rel.Name) req.NoError(err) - is.Equal(lastRelease.Info.Status, release.StatusDeployed) + lrel, err := releaserToV1Release(lastRelease) + is.NoError(err) + is.Equal(lrel.Info.Status, rcommon.StatusDeployed) } func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) { @@ -175,12 +183,17 @@ func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) { config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false)) instAction := installActionWithConfig(config) instAction.TakeOwnership = true - res, err := instAction.Run(buildChart(), nil) + resi, err := instAction.Run(buildChart(), nil) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) + + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + rel, err := releaserToV1Release(r) is.NoError(err) is.Equal(rel.Info.Description, "Install complete") @@ -193,11 +206,16 @@ func TestInstallReleaseWithTakeOwnership_ResourceOwned(t *testing.T) { config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(true)) instAction := installActionWithConfig(config) instAction.TakeOwnership = false - res, err := instAction.Run(buildChart(), nil) + resi, err := instAction.Run(buildChart(), nil) if err != nil { t.Fatalf("Failed install: %s", err) } - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + res, err := releaserToV1Release(resi) + is.NoError(err) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + rel, err := releaserToV1Release(r) is.NoError(err) is.Equal(rel.Info.Description, "Install complete") @@ -227,14 +245,19 @@ func TestInstallReleaseWithValues(t *testing.T) { "simpleKey": "simpleValue", }, } - res, err := instAction.Run(buildChart(withSampleValues()), userVals) + resi, err := instAction.Run(buildChart(withSampleValues()), userVals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Equal(res.Name, "test-install-release", "Expected release name.") is.Equal(res.Namespace, "spaced") - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + rel, err := releaserToV1Release(r) is.NoError(err) is.Len(rel.Hooks, 1) @@ -249,16 +272,6 @@ func TestInstallReleaseWithValues(t *testing.T) { is.Equal(expectedUserValues, rel.Config) } -func TestInstallReleaseClientOnly(t *testing.T) { - is := assert.New(t) - instAction := installAction(t) - instAction.ClientOnly = true - instAction.Run(buildChart(), nil) // disregard output - - is.Equal(instAction.cfg.Capabilities, common.DefaultCapabilities) - is.Equal(instAction.cfg.KubeClient, &kubefake.PrintingKubeClient{Out: io.Discard}) -} - func TestInstallRelease_NoName(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "" @@ -275,15 +288,19 @@ func TestInstallRelease_WithNotes(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "with-notes" vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withNotes("note here")), vals) + resi, err := instAction.Run(buildChart(withNotes("note here")), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Equal(res.Name, "with-notes") is.Equal(res.Namespace, "spaced") - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + rel, err := releaserToV1Release(r) is.NoError(err) is.Len(rel.Hooks, 1) is.Equal(rel.Hooks[0].Manifest, manifestWithHook) @@ -302,12 +319,16 @@ func TestInstallRelease_WithNotesRendered(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "with-notes" vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withNotes("got-{{.Release.Name}}")), vals) + resi, err := instAction.Run(buildChart(withNotes("got-{{.Release.Name}}")), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + rel, err := releaserToV1Release(r) is.NoError(err) expectedNotes := fmt.Sprintf("got-%s", res.Name) @@ -321,12 +342,16 @@ func TestInstallRelease_WithChartAndDependencyParentNotes(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "with-notes" vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) + resi, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + rel, err := releaserToV1Release(r) is.NoError(err) is.Equal("with-notes", rel.Name) is.Equal("parent", rel.Info.Notes) @@ -340,12 +365,16 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { instAction.ReleaseName = "with-notes" instAction.SubNotes = true vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) + resi, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) - rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + r, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + rel, err := releaserToV1Release(r) is.NoError(err) is.Equal("with-notes", rel.Name) // test run can return as either 'parent\nchild' or 'child\nparent' @@ -355,27 +384,32 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { is.Equal(rel.Info.Description, "Install complete") } -func TestInstallRelease_DryRun(t *testing.T) { - is := assert.New(t) - instAction := installAction(t) - instAction.DryRun = true - vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withSampleTemplates()), vals) - if err != nil { - t.Fatalf("Failed install: %s", err) - } +func TestInstallRelease_DryRunClient(t *testing.T) { + for _, dryRunStrategy := range []DryRunStrategy{DryRunClient, DryRunServer} { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRunStrategy = dryRunStrategy + + vals := map[string]interface{}{} + resi, err := instAction.Run(buildChart(withSampleTemplates()), vals) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + res, err := releaserToV1Release(resi) + is.NoError(err) - is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") - is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") - is.Contains(res.Manifest, "hello: Earth") - is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") - is.NotContains(res.Manifest, "empty") + is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") + is.Contains(res.Manifest, "hello: Earth") + is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") + is.NotContains(res.Manifest, "empty") - _, err = instAction.cfg.Releases.Get(res.Name, res.Version) - is.Error(err) - is.Len(res.Hooks, 1) - is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") - is.Equal(res.Info.Description, "Dry run complete") + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Len(res.Hooks, 1) + is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") + is.Equal(res.Info.Description, "Dry run complete") + } } func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { @@ -383,12 +417,14 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { instAction := installAction(t) // First perform a normal dry-run with the secret and confirm its presence. - instAction.DryRun = true + instAction.DryRunStrategy = DryRunClient vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) + resi, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret") _, err = instAction.cfg.Releases.Get(res.Name, res.Version) @@ -398,10 +434,12 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { // Perform a dry-run where the secret should not be present instAction.HideSecret = true vals = map[string]interface{}{} - res2, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) + res2i, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res2, err := releaserToV1Release(res2i) + is.NoError(err) is.NotContains(res2.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret") @@ -410,7 +448,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { is.Equal(res2.Info.Description, "Dry run complete") // Ensure there is an error when HideSecret True but not in a dry-run mode - instAction.DryRun = false + instAction.DryRunStrategy = DryRunNone vals = map[string]interface{}{} _, err = instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err == nil { @@ -422,7 +460,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { func TestInstallRelease_DryRun_Lookup(t *testing.T) { is := assert.New(t) instAction := installAction(t) - instAction.DryRun = true + instAction.DryRunStrategy = DryRunNone vals := map[string]interface{}{} mockChart := buildChart(withSampleTemplates()) @@ -431,10 +469,12 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), }) - res, err := instAction.Run(mockChart, vals) + resi, err := instAction.Run(mockChart, vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Manifest, "goodbye: map[]") } @@ -442,7 +482,7 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { is := assert.New(t) instAction := installAction(t) - instAction.DryRun = true + instAction.DryRunStrategy = DryRunNone vals := map[string]interface{}{} _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) expectedErr := `hello/templates/incorrect:1:10 @@ -462,10 +502,12 @@ func TestInstallRelease_NoHooks(t *testing.T) { instAction.cfg.Releases.Create(releaseStub()) vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "hooks should not run with no-hooks") } @@ -481,11 +523,13 @@ func TestInstallRelease_FailedHooks(t *testing.T) { failer.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) is.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "failed post-install") is.Equal("", outBuffer.String()) - is.Equal(release.StatusFailed, res.Info.Status) + is.Equal(rcommon.StatusFailed, res.Info.Status) } func TestInstallRelease_ReplaceRelease(t *testing.T) { @@ -494,21 +538,25 @@ func TestInstallRelease_ReplaceRelease(t *testing.T) { instAction.Replace = true rel := releaseStub() - rel.Info.Status = release.StatusUninstalled + rel.Info.Status = rcommon.StatusUninstalled instAction.cfg.Releases.Create(rel) instAction.ReleaseName = rel.Name vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) // This should have been auto-incremented is.Equal(2, res.Version) is.Equal(res.Name, rel.Name) - getres, err := instAction.cfg.Releases.Get(rel.Name, res.Version) + r, err := instAction.cfg.Releases.Get(rel.Name, res.Version) + is.NoError(err) + getres, err := releaserToV1Release(r) is.NoError(err) - is.Equal(getres.Info.Status, release.StatusDeployed) + is.Equal(getres.Info.Status, rcommon.StatusDeployed) } func TestInstallRelease_KubeVersion(t *testing.T) { @@ -538,10 +586,12 @@ func TestInstallRelease_Wait(t *testing.T) { goroutines := instAction.getGoroutineCount() - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) is.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "I timed out") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, rcommon.StatusFailed) is.Equal(goroutines, instAction.getGoroutineCount()) } @@ -579,10 +629,12 @@ func TestInstallRelease_WaitForJobs(t *testing.T) { instAction.WaitForJobs = true vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) is.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "I timed out") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, rcommon.StatusFailed) } func TestInstallRelease_RollbackOnFailure(t *testing.T) { @@ -600,11 +652,13 @@ func TestInstallRelease_RollbackOnFailure(t *testing.T) { instAction.DisableHooks = true vals := map[string]interface{}{} - res, err := instAction.Run(buildChart(), vals) + resi, err := instAction.Run(buildChart(), vals) is.Error(err) is.Contains(err.Error(), "I timed out") is.Contains(err.Error(), "rollback-on-failure") + res, err := releaserToV1Release(resi) + is.NoError(err) // Now make sure it isn't in storage anymore _, err = instAction.cfg.Releases.Get(res.Name, res.Version) is.Error(err) @@ -644,12 +698,14 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { goroutines := instAction.getGoroutineCount() - res, err := instAction.RunWithContext(ctx, buildChart(), vals) + resi, err := instAction.RunWithContext(ctx, buildChart(), vals) is.Error(err) is.Contains(err.Error(), "context canceled") is.Contains(err.Error(), "rollback-on-failure") is.Contains(err.Error(), "uninstalled") + res, err := releaserToV1Release(resi) + is.NoError(err) // Now make sure it isn't in storage anymore _, err = instAction.cfg.Releases.Get(res.Name, res.Version) is.Error(err) @@ -906,10 +962,12 @@ func TestInstallWithLabels(t *testing.T) { "key1": "val1", "key2": "val2", } - res, err := instAction.Run(buildChart(), nil) + resi, err := instAction.Run(buildChart(), nil) if err != nil { t.Fatalf("Failed install: %s", err) } + res, err := releaserToV1Release(resi) + is.NoError(err) is.Equal(instAction.Labels, res.Labels) } @@ -1001,7 +1059,6 @@ func TestInstallRun_UnreachableKubeClient(t *testing.T) { config.KubeClient = &failingKubeClient instAction := NewInstall(config) - instAction.ClientOnly = false ctx, done := context.WithCancel(t.Context()) chrt := buildChart() res, err := instAction.RunWithContext(ctx, chrt, nil) diff --git a/pkg/action/list.go b/pkg/action/list.go index c6d6f2037..06727bd9a 100644 --- a/pkg/action/list.go +++ b/pkg/action/list.go @@ -22,6 +22,7 @@ import ( "k8s.io/apimachinery/pkg/labels" + ri "helm.sh/helm/v4/pkg/release" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) @@ -139,13 +140,13 @@ type List struct { // NewList constructs a new *List func NewList(cfg *Configuration) *List { return &List{ - StateMask: ListDeployed | ListFailed, + StateMask: ListAll, cfg: cfg, } } // Run executes the list command, returning a set of matches. -func (l *List) Run() ([]*release.Release, error) { +func (l *List) Run() ([]ri.Releaser, error) { if err := l.cfg.KubeClient.IsReachable(); err != nil { return nil, err } @@ -159,9 +160,13 @@ func (l *List) Run() ([]*release.Release, error) { } } - results, err := l.cfg.Releases.List(func(rel *release.Release) bool { + results, err := l.cfg.Releases.List(func(rel ri.Releaser) bool { + r, err := releaserToV1Release(rel) + if err != nil { + return false + } // Skip anything that doesn't match the filter. - if filter != nil && !filter.MatchString(rel.Name) { + if filter != nil && !filter.MatchString(r.Name) { return false } @@ -176,30 +181,35 @@ func (l *List) Run() ([]*release.Release, error) { return results, nil } + rresults, err := releaseListToV1List(results) + if err != nil { + return nil, err + } + // by definition, superseded releases are never shown if // only the latest releases are returned. so if requested statemask // is _only_ ListSuperseded, skip the latest release filter if l.StateMask != ListSuperseded { - results = filterLatestReleases(results) + rresults = filterLatestReleases(rresults) } // State mask application must occur after filtering to // latest releases, otherwise outdated entries can be returned - results = l.filterStateMask(results) + rresults = l.filterStateMask(rresults) // Skip anything that doesn't match the selector selectorObj, err := labels.Parse(l.Selector) if err != nil { return nil, err } - results = l.filterSelector(results, selectorObj) + rresults = l.filterSelector(rresults, selectorObj) // Unfortunately, we have to sort before truncating, which can incur substantial overhead - l.sort(results) + l.sort(rresults) // Guard on offset - if l.Offset >= len(results) { - return []*release.Release{}, nil + if l.Offset >= len(rresults) { + return releaseV1ListToReleaserList([]*release.Release{}) } // Calculate the limit and offset, and then truncate results if necessary. @@ -208,12 +218,12 @@ func (l *List) Run() ([]*release.Release, error) { limit = l.Limit } last := l.Offset + limit - if l := len(results); l < last { + if l := len(rresults); l < last { last = l } - results = results[l.Offset:last] + rresults = rresults[l.Offset:last] - return results, err + return releaseV1ListToReleaserList(rresults) } // sort is an in-place sort where order is based on the value of a.Sort @@ -317,7 +327,7 @@ func (l *List) SetStateMask() { // Apply a default if state == 0 { - state = ListDeployed | ListFailed + state = ListAll } l.StateMask = state diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go index 75737d635..643bcea42 100644 --- a/pkg/action/list_test.go +++ b/pkg/action/list_test.go @@ -24,6 +24,8 @@ import ( "github.com/stretchr/testify/assert" kubefake "helm.sh/helm/v4/pkg/kube/fake" + ri "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" ) @@ -96,8 +98,11 @@ func TestList_Sort(t *testing.T) { lister := newListFixture(t) lister.Sort = ByNameDesc // Other sorts are tested elsewhere makeMeSomeReleases(t, lister.cfg.Releases) - list, err := lister.Run() + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) + is.Len(list, 3) is.Equal("two", list[0].Name) is.Equal("three", list[1].Name) @@ -109,7 +114,9 @@ func TestList_Limit(t *testing.T) { lister := newListFixture(t) lister.Limit = 2 makeMeSomeReleases(t, lister.cfg.Releases) - list, err := lister.Run() + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 2) // Lex order means one, three, two @@ -122,7 +129,9 @@ func TestList_BigLimit(t *testing.T) { lister := newListFixture(t) lister.Limit = 20 makeMeSomeReleases(t, lister.cfg.Releases) - list, err := lister.Run() + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 3) @@ -138,7 +147,9 @@ func TestList_LimitOffset(t *testing.T) { lister.Limit = 2 lister.Offset = 1 makeMeSomeReleases(t, lister.cfg.Releases) - list, err := lister.Run() + l, err := lister.Run() + is.NoError(err) + list, err := releaseListToV1List(l) is.NoError(err) is.Len(list, 2) @@ -168,23 +179,45 @@ func TestList_StateMask(t *testing.T) { is := assert.New(t) lister := newListFixture(t) makeMeSomeReleases(t, lister.cfg.Releases) - one, err := lister.cfg.Releases.Get("one", 1) + oner, err := lister.cfg.Releases.Get("one", 1) is.NoError(err) - one.SetStatus(release.StatusUninstalled, "uninstalled") + + var one release.Release + switch v := oner.(type) { + case release.Release: + one = v + case *release.Release: + one = *v + default: + t.Fatal("unsupported release type") + } + + one.SetStatus(common.StatusUninstalled, "uninstalled") err = lister.cfg.Releases.Update(one) is.NoError(err) res, err := lister.Run() is.NoError(err) - is.Len(res, 2) - is.Equal("three", res[0].Name) - is.Equal("two", res[1].Name) + is.Len(res, 3) + + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + ac1, err := ri.NewAccessor(res[1]) + is.NoError(err) + ac2, err := ri.NewAccessor(res[2]) + is.NoError(err) + + is.Equal("one", ac0.Name()) + is.Equal("three", ac1.Name()) + is.Equal("two", ac2.Name()) lister.StateMask = ListUninstalled res, err = lister.Run() is.NoError(err) is.Len(res, 1) - is.Equal("one", res[0].Name) + ac0, err = ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("one", ac0.Name()) lister.StateMask |= ListDeployed res, err = lister.Run() @@ -206,28 +239,30 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) { // "dirty" release should _not_ be present as most recent // release is deployed despite failed release in past - is.Equal("failed", res[0].Name) + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("failed", ac0.Name()) } func makeMeSomeReleasesWithStaleFailure(t *testing.T, store *storage.Storage) { t.Helper() - one := namedReleaseStub("clean", release.StatusDeployed) + one := namedReleaseStub("clean", common.StatusDeployed) one.Namespace = "default" one.Version = 1 - two := namedReleaseStub("dirty", release.StatusDeployed) + two := namedReleaseStub("dirty", common.StatusDeployed) two.Namespace = "default" two.Version = 1 - three := namedReleaseStub("dirty", release.StatusFailed) + three := namedReleaseStub("dirty", common.StatusFailed) three.Namespace = "default" three.Version = 2 - four := namedReleaseStub("dirty", release.StatusDeployed) + four := namedReleaseStub("dirty", common.StatusDeployed) four.Namespace = "default" four.Version = 3 - five := namedReleaseStub("failed", release.StatusFailed) + five := namedReleaseStub("failed", common.StatusFailed) five.Namespace = "default" five.Version = 1 @@ -251,7 +286,9 @@ func TestList_Filter(t *testing.T) { res, err := lister.Run() is.NoError(err) is.Len(res, 1) - is.Equal("three", res[0].Name) + ac0, err := ri.NewAccessor(res[0]) + is.NoError(err) + is.Equal("three", ac0.Name()) } func TestList_FilterFailsCompile(t *testing.T) { diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go index 009f4d793..b649579f4 100644 --- a/pkg/action/release_testing.go +++ b/pkg/action/release_testing.go @@ -28,6 +28,7 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" + ri "helm.sh/helm/v4/pkg/release" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -45,7 +46,6 @@ type ReleaseTesting struct { // Used for fetching logs from test pods Namespace string Filters map[string][]string - HideNotes bool } // NewReleaseTesting creates a new ReleaseTesting object with the given configuration. @@ -57,7 +57,7 @@ func NewReleaseTesting(cfg *Configuration) *ReleaseTesting { } // Run executes 'helm test' against the given release. -func (r *ReleaseTesting) Run(name string) (*release.Release, error) { +func (r *ReleaseTesting) Run(name string) (ri.Releaser, error) { if err := r.cfg.KubeClient.IsReachable(); err != nil { return nil, err } @@ -67,7 +67,12 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) { } // finds the non-deleted release with the given name - rel, err := r.cfg.Releases.Last(name) + reli, err := r.cfg.Releases.Last(name) + if err != nil { + return reli, err + } + + rel, err := releaserToV1Release(reli) if err != nil { return rel, err } diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index f56052988..992f6979f 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -23,8 +23,11 @@ import ( "strings" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -39,7 +42,8 @@ type Rollback struct { WaitStrategy kube.WaitStrategy WaitForJobs bool DisableHooks bool - DryRun bool + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway. // // This should be used with caution. @@ -59,7 +63,8 @@ type Rollback struct { // NewRollback creates a new Rollback object with the given configuration. func NewRollback(cfg *Configuration) *Rollback { return &Rollback{ - cfg: cfg, + cfg: cfg, + DryRunStrategy: DryRunNone, } } @@ -77,7 +82,7 @@ func (r *Rollback) Run(name string) error { return err } - if !r.DryRun { + if !isDryRun(r.DryRunStrategy) { slog.Debug("creating rolled back release", "name", name) if err := r.cfg.Releases.Create(targetRelease); err != nil { return err @@ -89,7 +94,7 @@ func (r *Rollback) Run(name string) error { return err } - if !r.DryRun { + if !isDryRun(r.DryRunStrategy) { slog.Debug("updating status for rolled back release", "name", name) if err := r.cfg.Releases.Update(targetRelease); err != nil { return err @@ -109,7 +114,12 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele return nil, nil, false, errInvalidRevision } - currentRelease, err := r.cfg.Releases.Last(name) + currentReleasei, err := r.cfg.Releases.Last(name) + if err != nil { + return nil, nil, false, err + } + + currentRelease, err := releaserToV1Release(currentReleasei) if err != nil { return nil, nil, false, err } @@ -126,7 +136,11 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele // Check if the history version to be rolled back exists previousVersionExist := false - for _, historyRelease := range historyReleases { + for _, historyReleasei := range historyReleases { + historyRelease, err := releaserToV1Release(historyReleasei) + if err != nil { + return nil, nil, false, err + } version := historyRelease.Version if previousVersion == version { previousVersionExist = true @@ -139,7 +153,11 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele slog.Debug("rolling back", "name", name, "currentVersion", currentRelease.Version, "targetVersion", previousVersion) - previousRelease, err := r.cfg.Releases.Get(name, previousVersion) + previousReleasei, err := r.cfg.Releases.Get(name, previousVersion) + if err != nil { + return nil, nil, false, err + } + previousRelease, err := releaserToV1Release(previousReleasei) if err != nil { return nil, nil, false, err } @@ -158,7 +176,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Info: &release.Info{ FirstDeployed: currentRelease.Info.FirstDeployed, LastDeployed: time.Now(), - Status: release.StatusPendingRollback, + Status: common.StatusPendingRollback, Notes: previousRelease.Info.Notes, // Because we lose the reference to previous version elsewhere, we set the // message here, and only override it later if we experience failure. @@ -175,7 +193,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele } func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { - if r.DryRun { + if isDryRun(r.DryRunStrategy) { slog.Debug("dry run", "name", targetRelease.Name) return targetRelease, nil } @@ -215,14 +233,14 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas if err != nil { msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) slog.Warn(msg) - currentRelease.Info.Status = release.StatusSuperseded - targetRelease.Info.Status = release.StatusFailed + currentRelease.Info.Status = common.StatusSuperseded + targetRelease.Info.Status = common.StatusFailed targetRelease.Info.Description = msg r.cfg.recordRelease(currentRelease) r.cfg.recordRelease(targetRelease) if r.CleanupOnFail { slog.Debug("cleanup on fail set, cleaning up resources", "count", len(results.Created)) - _, errs := r.cfg.KubeClient.Delete(results.Created) + _, errs := r.cfg.KubeClient.Delete(results.Created, metav1.DeletePropagationBackground) if errs != nil { return targetRelease, fmt.Errorf( "an error occurred while cleaning up resources. original rollback error: %w", @@ -239,14 +257,14 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas } if r.WaitForJobs { if err := waiter.WaitWithJobs(target, r.Timeout); err != nil { - targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) + targetRelease.SetStatus(common.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) r.cfg.recordRelease(currentRelease) r.cfg.recordRelease(targetRelease) return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err) } } else { if err := waiter.Wait(target, r.Timeout); err != nil { - targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) + targetRelease.SetStatus(common.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error())) r.cfg.recordRelease(currentRelease) r.cfg.recordRelease(targetRelease) return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err) @@ -265,13 +283,17 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas return nil, err } // Supersede all previous deployments, see issue #2941. - for _, rel := range deployed { + for _, reli := range deployed { + rel, err := releaserToV1Release(reli) + if err != nil { + return nil, err + } slog.Debug("superseding previous deployment", "version", rel.Version) - rel.Info.Status = release.StatusSuperseded + rel.Info.Status = common.StatusSuperseded r.cfg.recordRelease(rel) } - targetRelease.Info.Status = release.StatusDeployed + targetRelease.Info.Status = common.StatusDeployed return targetRelease, nil } diff --git a/pkg/action/status.go b/pkg/action/status.go index 509c52cd9..2e6a1992c 100644 --- a/pkg/action/status.go +++ b/pkg/action/status.go @@ -18,10 +18,9 @@ package action import ( "bytes" - "errors" "helm.sh/helm/v4/pkg/kube" - release "helm.sh/helm/v4/pkg/release/v1" + ri "helm.sh/helm/v4/pkg/release" ) // Status is the action for checking the deployment status of releases. @@ -45,38 +44,40 @@ func NewStatus(cfg *Configuration) *Status { } // Run executes 'helm status' against the given release. -func (s *Status) Run(name string) (*release.Release, error) { +func (s *Status) Run(name string) (ri.Releaser, error) { if err := s.cfg.KubeClient.IsReachable(); err != nil { return nil, err } - rel, err := s.cfg.releaseContent(name, s.Version) + reli, err := s.cfg.releaseContent(name, s.Version) if err != nil { return nil, err } - if kubeClient, ok := s.cfg.KubeClient.(kube.InterfaceResources); ok { - var resources kube.ResourceList - if s.ShowResourcesTable { - resources, err = kubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false) - if err != nil { - return nil, err - } - } else { - resources, err = s.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), false) - if err != nil { - return nil, err - } - } + rel, err := releaserToV1Release(reli) + if err != nil { + return nil, err + } - resp, err := kubeClient.Get(resources, true) + var resources kube.ResourceList + if s.ShowResourcesTable { + resources, err = s.cfg.KubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false) if err != nil { return nil, err } + } else { + resources, err = s.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), false) + if err != nil { + return nil, err + } + } - rel.Info.Resources = resp - - return rel, nil + resp, err := s.cfg.KubeClient.Get(resources, true) + if err != nil { + return nil, err } - return nil, errors.New("unable to get kubeClient with interface InterfaceResources") + + rel.Info.Resources = resp + + return rel, nil } diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 057c2118f..4ce6068ec 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -27,6 +27,8 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" + releasei "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" @@ -56,7 +58,7 @@ func NewUninstall(cfg *Configuration) *Uninstall { } // Run uninstalls the given release. -func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) { +func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) { if err := u.cfg.KubeClient.IsReachable(); err != nil { return nil, err } @@ -67,51 +69,61 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) } if u.DryRun { - r, err := u.cfg.releaseContent(name, 0) + ri, err := u.cfg.releaseContent(name, 0) + if err != nil { if u.IgnoreNotFound && errors.Is(err, driver.ErrReleaseNotFound) { return nil, nil } - return &release.UninstallReleaseResponse{}, err + return &releasei.UninstallReleaseResponse{}, err + } + r, err := releaserToV1Release(ri) + if err != nil { + return nil, err } - return &release.UninstallReleaseResponse{Release: r}, nil + return &releasei.UninstallReleaseResponse{Release: r}, nil } if err := chartutil.ValidateReleaseName(name); err != nil { return nil, fmt.Errorf("uninstall: Release name is invalid: %s", name) } - rels, err := u.cfg.Releases.History(name) + relsi, err := u.cfg.Releases.History(name) if err != nil { if u.IgnoreNotFound { return nil, nil } return nil, fmt.Errorf("uninstall: Release not loaded: %s: %w", name, err) } - if len(rels) < 1 { + if len(relsi) < 1 { return nil, errMissingRelease } + rels, err := releaseListToV1List(relsi) + if err != nil { + return nil, err + } + releaseutil.SortByRevision(rels) rel := rels[len(rels)-1] // TODO: Are there any cases where we want to force a delete even if it's // already marked deleted? - if rel.Info.Status == release.StatusUninstalled { + if rel.Info.Status == common.StatusUninstalled { if !u.KeepHistory { if err := u.purgeReleases(rels...); err != nil { return nil, fmt.Errorf("uninstall: Failed to purge the release: %w", err) } - return &release.UninstallReleaseResponse{Release: rel}, nil + return &releasei.UninstallReleaseResponse{Release: rel}, nil } return nil, fmt.Errorf("the release named %q is already deleted", name) } slog.Debug("uninstall: deleting release", "name", name) - rel.Info.Status = release.StatusUninstalling + rel.Info.Status = common.StatusUninstalling rel.Info.Deleted = time.Now() rel.Info.Description = "Deletion in progress (or silently failed)" - res := &release.UninstallReleaseResponse{Release: rel} + res := &releasei.UninstallReleaseResponse{Release: rel} if !u.DisableHooks { serverSideApply := true @@ -150,7 +162,7 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) } } - rel.Info.Status = release.StatusUninstalled + rel.Info.Status = common.StatusUninstalled if len(u.Description) > 0 { rel.Info.Description = u.Description } else { @@ -245,11 +257,7 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)} } if len(resources) > 0 { - if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok { - _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.DeletionPropagation)) - return resources, kept, errs - } - _, errs = u.cfg.KubeClient.Delete(resources) + _, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation)) } return resources, kept, errs } diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index 7c7344383..fba1e391f 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -27,7 +27,7 @@ import ( "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" - release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/common" ) func uninstallAction(t *testing.T) *Uninstall { @@ -116,10 +116,12 @@ func TestUninstallRelease_Wait(t *testing.T) { failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitForDeleteError = fmt.Errorf("U timed out") unAction.cfg.KubeClient = failer - res, err := unAction.Run(rel.Name) + resi, err := unAction.Run(rel.Name) is.Error(err) is.Contains(err.Error(), "U timed out") - is.Equal(res.Release.Info.Status, release.StatusUninstalled) + res, err := releaserToV1Release(resi.Release) + is.NoError(err) + is.Equal(res.Info.Status, common.StatusUninstalled) } func TestUninstallRelease_Cascade(t *testing.T) { @@ -146,7 +148,7 @@ func TestUninstallRelease_Cascade(t *testing.T) { }` unAction.cfg.Releases.Create(rel) failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.DeleteWithPropagationError = fmt.Errorf("Uninstall with cascade failed") + failer.DeleteError = fmt.Errorf("Uninstall with cascade failed") failer.BuildDummy = true unAction.cfg.KubeClient = failer _, err := unAction.Run(rel.Name) diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index e41437a9d..ba920098e 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -26,6 +26,7 @@ import ( "sync" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/cli-runtime/pkg/resource" "helm.sh/helm/v4/pkg/chart" @@ -36,6 +37,8 @@ import ( "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/registry" + ri "helm.sh/helm/v4/pkg/release" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" @@ -73,10 +76,8 @@ type Upgrade struct { WaitForJobs bool // DisableHooks disables hook processing if set to true. DisableHooks bool - // DryRun controls whether the operation is prepared, but not executed. - DryRun bool - // DryRunOption controls whether the operation is prepared, but not executed with options on whether or not to interact with the remote cluster. - DryRunOption string + // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster + DryRunStrategy DryRunStrategy // HideSecret can be set to true when DryRun is enabled in order to hide // Kubernetes Secrets in the output. It cannot be used outside of DryRun. HideSecret bool @@ -142,6 +143,7 @@ func NewUpgrade(cfg *Configuration) *Upgrade { up := &Upgrade{ cfg: cfg, ServerSideApply: "auto", + DryRunStrategy: DryRunNone, } up.registryClient = cfg.RegistryClient @@ -154,13 +156,13 @@ func (u *Upgrade) SetRegistryClient(client *registry.Client) { } // Run executes the upgrade on the given release. -func (u *Upgrade) Run(name string, chart chart.Charter, vals map[string]interface{}) (*release.Release, error) { +func (u *Upgrade) Run(name string, chart chart.Charter, vals map[string]interface{}) (ri.Releaser, error) { ctx := context.Background() return u.RunWithContext(ctx, name, chart, vals) } // RunWithContext executes the upgrade on the given release with context. -func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Charter, vals map[string]interface{}) (*release.Release, error) { +func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Charter, vals map[string]interface{}) (ri.Releaser, error) { if err := u.cfg.KubeClient.IsReachable(); err != nil { return nil, err } @@ -200,7 +202,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char } // Do not update for dry runs - if !u.isDryRun() { + if !isDryRun(u.DryRunStrategy) { slog.Debug("updating status for upgraded release", "name", name) if err := u.cfg.Releases.Update(upgradedRelease); err != nil { return res, err @@ -210,14 +212,6 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char return res, nil } -// isDryRun returns true if Upgrade is set to run as a DryRun -func (u *Upgrade) isDryRun() bool { - if u.DryRun || u.DryRunOption == "client" || u.DryRunOption == "server" || u.DryRunOption == "true" { - return true - } - return false -} - // prepareUpgrade builds an upgraded release for an upgrade operation. func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) { if chart == nil { @@ -225,12 +219,12 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str } // HideSecret must be used with dry run. Otherwise, return an error. - if !u.isDryRun() && u.HideSecret { + if !isDryRun(u.DryRunStrategy) && u.HideSecret { return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode") } // finds the last non-deleted release with the given name - lastRelease, err := u.cfg.Releases.Last(name) + lastReleasei, err := u.cfg.Releases.Last(name) if err != nil { // to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist if errors.Is(err, driver.ErrReleaseNotFound) { @@ -239,26 +233,37 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } + lastRelease, err := releaserToV1Release(lastReleasei) + if err != nil { + return nil, nil, false, err + } + // Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock. if lastRelease.Info.Status.IsPending() { return nil, nil, false, errPending } var currentRelease *release.Release - if lastRelease.Info.Status == release.StatusDeployed { + if lastRelease.Info.Status == rcommon.StatusDeployed { // no need to retrieve the last deployed release from storage as the last release is deployed currentRelease = lastRelease } else { // finds the deployed release with the given name - currentRelease, err = u.cfg.Releases.Deployed(name) + currentReleasei, err := u.cfg.Releases.Deployed(name) + var cerr error + currentRelease, cerr = releaserToV1Release(currentReleasei) + if cerr != nil { + return nil, nil, false, err + } if err != nil { if errors.Is(err, driver.ErrNoDeployedReleases) && - (lastRelease.Info.Status == release.StatusFailed || lastRelease.Info.Status == release.StatusSuperseded) { + (lastRelease.Info.Status == rcommon.StatusFailed || lastRelease.Info.Status == rcommon.StatusSuperseded) { currentRelease = lastRelease } else { return nil, nil, false, err } } + } // determine if values will be reused @@ -291,13 +296,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } - // Determine whether or not to interact with remote - var interactWithRemote bool - if !u.isDryRun() || u.DryRunOption == "server" || u.DryRunOption == "none" || u.DryRunOption == "false" { - interactWithRemote = true - } - - hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS, u.HideSecret) + hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret) if err != nil { return nil, nil, false, err } @@ -322,7 +321,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str Info: &release.Info{ FirstDeployed: currentRelease.Info.FirstDeployed, LastDeployed: Timestamper(), - Status: release.StatusPendingUpgrade, + Status: rcommon.StatusPendingUpgrade, Description: "Preparing upgrade", // This should be overwritten later. }, Version: revision, @@ -393,8 +392,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR return nil }) - // Run if it is a dry run - if u.isDryRun() { + if isDryRun(u.DryRunStrategy) { slog.Debug("dry run for release", "name", upgradedRelease.Name) if len(u.Description) > 0 { upgradedRelease.Info.Description = u.Description @@ -505,10 +503,10 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele } } - originalRelease.Info.Status = release.StatusSuperseded + originalRelease.Info.Status = rcommon.StatusSuperseded u.cfg.recordRelease(originalRelease) - upgradedRelease.Info.Status = release.StatusDeployed + upgradedRelease.Info.Status = rcommon.StatusDeployed if len(u.Description) > 0 { upgradedRelease.Info.Description = u.Description } else { @@ -521,12 +519,12 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err) slog.Warn("upgrade failed", "name", rel.Name, slog.Any("error", err)) - rel.Info.Status = release.StatusFailed + rel.Info.Status = rcommon.StatusFailed rel.Info.Description = msg u.cfg.recordRelease(rel) if u.CleanupOnFail && len(created) > 0 { slog.Debug("cleanup on fail set", "cleaning_resources", len(created)) - _, errs := u.cfg.KubeClient.Delete(created) + _, errs := u.cfg.KubeClient.Delete(created, metav1.DeletePropagationBackground) if errs != nil { return rel, fmt.Errorf( "an error occurred while cleaning up resources. original upgrade error: %w: %w", @@ -551,12 +549,16 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e return rel, fmt.Errorf("an error occurred while finding last successful release. original upgrade error: %w: %w", err, herr) } + fullHistoryV1, herr := releaseListToV1List(fullHistory) + if herr != nil { + return nil, herr + } // There isn't a way to tell if a previous release was successful, but // generally failed releases do not get superseded unless the next // release is successful, so this should be relatively safe filteredHistory := releaseutil.FilterFunc(func(r *release.Release) bool { - return r.Info.Status == release.StatusSuperseded || r.Info.Status == release.StatusDeployed - }).Filter(fullHistory) + return r.Info.Status == rcommon.StatusSuperseded || r.Info.Status == rcommon.StatusDeployed + }).Filter(fullHistoryV1) if len(filteredHistory) == 0 { return rel, fmt.Errorf("unable to find a previously successful release when attempting to rollback. original upgrade error: %w", err) } diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index 0a436534f..e1eac3f9f 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -33,6 +33,7 @@ import ( "github.com/stretchr/testify/require" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -52,24 +53,28 @@ func TestUpgradeRelease_Success(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "previous-release" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed req.NoError(upAction.cfg.Releases.Create(rel)) upAction.WaitStrategy = kube.StatusWatcherStrategy vals := map[string]interface{}{} ctx, done := context.WithCancel(t.Context()) - res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) - done() + resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) req.NoError(err) - is.Equal(res.Info.Status, release.StatusDeployed) + res, err := releaserToV1Release(resi) + is.NoError(err) + is.Equal(res.Info.Status, common.StatusDeployed) + done() // Detecting previous bug where context termination after successful release // caused release to fail. time.Sleep(time.Millisecond * 100) - lastRelease, err := upAction.cfg.Releases.Last(rel.Name) + lastReleasei, err := upAction.cfg.Releases.Last(rel.Name) + req.NoError(err) + lastRelease, err := releaserToV1Release(lastReleasei) req.NoError(err) - is.Equal(lastRelease.Info.Status, release.StatusDeployed) + is.Equal(lastRelease.Info.Status, common.StatusDeployed) } func TestUpgradeRelease_Wait(t *testing.T) { @@ -79,7 +84,7 @@ func TestUpgradeRelease_Wait(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "come-fail-away" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -88,10 +93,12 @@ func TestUpgradeRelease_Wait(t *testing.T) { upAction.WaitStrategy = kube.StatusWatcherStrategy vals := map[string]interface{}{} - res, err := upAction.Run(rel.Name, buildChart(), vals) + resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "I timed out") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, common.StatusFailed) } func TestUpgradeRelease_WaitForJobs(t *testing.T) { @@ -101,7 +108,7 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "come-fail-away" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -111,10 +118,12 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) { upAction.WaitForJobs = true vals := map[string]interface{}{} - res, err := upAction.Run(rel.Name, buildChart(), vals) + resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "I timed out") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, common.StatusFailed) } func TestUpgradeRelease_CleanupOnFail(t *testing.T) { @@ -124,7 +133,7 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "come-fail-away" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -135,11 +144,13 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) { upAction.CleanupOnFail = true vals := map[string]interface{}{} - res, err := upAction.Run(rel.Name, buildChart(), vals) + resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) is.NotContains(err.Error(), "unable to cleanup resources") + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "I timed out") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, common.StatusFailed) } func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { @@ -151,7 +162,7 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { rel := releaseStub() rel.Name = "nuketown" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -161,23 +172,27 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { upAction.RollbackOnFailure = true vals := map[string]interface{}{} - res, err := upAction.Run(rel.Name, buildChart(), vals) + resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) is.Contains(err.Error(), "arming key removed") is.Contains(err.Error(), "rollback-on-failure") + res, err := releaserToV1Release(resi) + is.NoError(err) // Now make sure it is actually upgraded - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 3) + is.NoError(err) + updatedRes, err := releaserToV1Release(updatedResi) is.NoError(err) // Should have rolled back to the previous - is.Equal(updatedRes.Info.Status, release.StatusDeployed) + is.Equal(updatedRes.Info.Status, common.StatusDeployed) }) t.Run("rollback-on-failure uninstall fails", func(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "fallout" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -218,7 +233,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { rel := releaseStub() rel.Name = "nuketown" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed rel.Config = existingValues err := upAction.cfg.Releases.Create(rel) @@ -226,18 +241,23 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { upAction.ReuseValues = true // setting newValues and upgrading - res, err := upAction.Run(rel.Name, buildChart(), newValues) + resi, err := upAction.Run(rel.Name, buildChart(), newValues) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) // Now make sure it is actually upgraded - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 2) is.NoError(err) - if updatedRes == nil { + if updatedResi == nil { is.Fail("Updated Release is nil") return } - is.Equal(release.StatusDeployed, updatedRes.Info.Status) + updatedRes, err := releaserToV1Release(updatedResi) + is.NoError(err) + + is.Equal(common.StatusDeployed, updatedRes.Info.Status) is.Equal(expectedValues, updatedRes.Config) }) @@ -270,7 +290,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { Info: &release.Info{ FirstDeployed: now, LastDeployed: now, - Status: release.StatusDeployed, + Status: common.StatusDeployed, Description: "Named Release Stub", }, Chart: sampleChart, @@ -288,18 +308,23 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { withMetadataDependency(dependency), ) // reusing values and upgrading - res, err := upAction.Run(rel.Name, sampleChartWithSubChart, map[string]interface{}{}) + resi, err := upAction.Run(rel.Name, sampleChartWithSubChart, map[string]interface{}{}) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) // Now get the upgraded release - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 2) is.NoError(err) - if updatedRes == nil { + if updatedResi == nil { is.Fail("Updated Release is nil") return } - is.Equal(release.StatusDeployed, updatedRes.Info.Status) + updatedRes, err := releaserToV1Release(updatedResi) + is.NoError(err) + + is.Equal(common.StatusDeployed, updatedRes.Info.Status) is.Equal(0, len(updatedRes.Chart.Dependencies()), "expected 0 dependencies") expectedValues := map[string]interface{}{ @@ -339,7 +364,7 @@ func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) { rel := releaseStub() rel.Name = "nuketown" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed rel.Config = existingValues err := upAction.cfg.Releases.Create(rel) @@ -347,18 +372,23 @@ func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) { upAction.ResetThenReuseValues = true // setting newValues and upgrading - res, err := upAction.Run(rel.Name, buildChart(withValues(newChartValues)), newValues) + resi, err := upAction.Run(rel.Name, buildChart(withValues(newChartValues)), newValues) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) // Now make sure it is actually upgraded - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 2) is.NoError(err) - if updatedRes == nil { + if updatedResi == nil { is.Fail("Updated Release is nil") return } - is.Equal(release.StatusDeployed, updatedRes.Info.Status) + updatedRes, err := releaserToV1Release(updatedResi) + is.NoError(err) + + is.Equal(common.StatusDeployed, updatedRes.Info.Status) is.Equal(expectedValues, updatedRes.Config) is.Equal(newChartValues, updatedRes.Chart.Values) }) @@ -370,11 +400,11 @@ func TestUpgradeRelease_Pending(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "come-fail-away" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) rel2 := releaseStub() rel2.Name = "come-fail-away" - rel2.Info.Status = release.StatusPendingUpgrade + rel2.Info.Status = common.StatusPendingUpgrade rel2.Version = 2 upAction.cfg.Releases.Create(rel2) @@ -391,7 +421,7 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "interrupted-release" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -403,11 +433,13 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) - res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) + resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) req.Error(err) + res, err := releaserToV1Release(resi) + is.NoError(err) is.Contains(res.Info.Description, "Upgrade \"interrupted-release\" failed: context canceled") - is.Equal(res.Info.Status, release.StatusFailed) + is.Equal(res.Info.Status, common.StatusFailed) } func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { @@ -418,7 +450,7 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "interrupted-release" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed upAction.cfg.Releases.Create(rel) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) @@ -430,16 +462,19 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) - res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) + resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) req.Error(err) is.Contains(err.Error(), "release interrupted-release failed, and has been rolled back due to rollback-on-failure being set: context canceled") - + res, err := releaserToV1Release(resi) + is.NoError(err) // Now make sure it is actually upgraded - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 3) + is.NoError(err) + updatedRes, err := releaserToV1Release(updatedResi) is.NoError(err) // Should have rolled back to the previous - is.Equal(updatedRes.Info.Status, release.StatusDeployed) + is.Equal(updatedRes.Info.Status, common.StatusDeployed) } func TestMergeCustomLabels(t *testing.T) { @@ -468,7 +503,7 @@ func TestUpgradeRelease_Labels(t *testing.T) { "key1": "val1", "key2": "val2.1", } - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed err := upAction.cfg.Releases.Create(rel) is.NoError(err) @@ -479,29 +514,35 @@ func TestUpgradeRelease_Labels(t *testing.T) { "key3": "val3", } // setting newValues and upgrading - res, err := upAction.Run(rel.Name, buildChart(), nil) + resi, err := upAction.Run(rel.Name, buildChart(), nil) + is.NoError(err) + res, err := releaserToV1Release(resi) is.NoError(err) // Now make sure it is actually upgraded and labels were merged - updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2) + updatedResi, err := upAction.cfg.Releases.Get(res.Name, 2) is.NoError(err) - if updatedRes == nil { + if updatedResi == nil { is.Fail("Updated Release is nil") return } - is.Equal(release.StatusDeployed, updatedRes.Info.Status) + updatedRes, err := releaserToV1Release(updatedResi) + is.NoError(err) + is.Equal(common.StatusDeployed, updatedRes.Info.Status) is.Equal(mergeCustomLabels(rel.Labels, upAction.Labels), updatedRes.Labels) // Now make sure it is suppressed release still contains original labels - initialRes, err := upAction.cfg.Releases.Get(res.Name, 1) + initialResi, err := upAction.cfg.Releases.Get(res.Name, 1) is.NoError(err) - if initialRes == nil { + if initialResi == nil { is.Fail("Updated Release is nil") return } - is.Equal(initialRes.Info.Status, release.StatusSuperseded) + initialRes, err := releaserToV1Release(initialResi) + is.NoError(err) + is.Equal(initialRes.Info.Status, common.StatusSuperseded) is.Equal(initialRes.Labels, rel.Labels) } @@ -516,7 +557,7 @@ func TestUpgradeRelease_SystemLabels(t *testing.T) { "key1": "val1", "key2": "val2.1", } - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed err := upAction.cfg.Releases.Create(rel) is.NoError(err) @@ -542,22 +583,26 @@ func TestUpgradeRelease_DryRun(t *testing.T) { upAction := upgradeAction(t) rel := releaseStub() rel.Name = "previous-release" - rel.Info.Status = release.StatusDeployed + rel.Info.Status = common.StatusDeployed req.NoError(upAction.cfg.Releases.Create(rel)) - upAction.DryRun = true + upAction.DryRunStrategy = DryRunClient vals := map[string]interface{}{} ctx, done := context.WithCancel(t.Context()) - res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) + resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) done() req.NoError(err) - is.Equal(release.StatusPendingUpgrade, res.Info.Status) + res, err := releaserToV1Release(resi) + is.NoError(err) + is.Equal(common.StatusPendingUpgrade, res.Info.Status) is.Contains(res.Manifest, "kind: Secret") - lastRelease, err := upAction.cfg.Releases.Last(rel.Name) + lastReleasei, err := upAction.cfg.Releases.Last(rel.Name) req.NoError(err) - is.Equal(lastRelease.Info.Status, release.StatusDeployed) + lastRelease, err := releaserToV1Release(lastReleasei) + req.NoError(err) + is.Equal(lastRelease.Info.Status, common.StatusDeployed) is.Equal(1, lastRelease.Version) // Test the case for hiding the secret to ensure it is not displayed @@ -565,19 +610,23 @@ func TestUpgradeRelease_DryRun(t *testing.T) { vals = map[string]interface{}{} ctx, done = context.WithCancel(t.Context()) - res, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) + resi, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) done() req.NoError(err) - is.Equal(release.StatusPendingUpgrade, res.Info.Status) + res, err = releaserToV1Release(resi) + is.NoError(err) + is.Equal(common.StatusPendingUpgrade, res.Info.Status) is.NotContains(res.Manifest, "kind: Secret") - lastRelease, err = upAction.cfg.Releases.Last(rel.Name) + lastReleasei, err = upAction.cfg.Releases.Last(rel.Name) + req.NoError(err) + lastRelease, err = releaserToV1Release(lastReleasei) req.NoError(err) - is.Equal(lastRelease.Info.Status, release.StatusDeployed) + is.Equal(lastRelease.Info.Status, common.StatusDeployed) is.Equal(1, lastRelease.Version) // Ensure in a dry run mode when using HideSecret - upAction.DryRun = false + upAction.DryRunStrategy = DryRunNone vals = map[string]interface{}{} ctx, done = context.WithCancel(t.Context()) diff --git a/pkg/action/verify.go b/pkg/action/verify.go index ca2f4fa63..6e4562f61 100644 --- a/pkg/action/verify.go +++ b/pkg/action/verify.go @@ -28,7 +28,6 @@ import ( // It provides the implementation of 'helm verify'. type Verify struct { Keyring string - Out string } // NewVerify creates a new Verify object with the given configuration. @@ -37,23 +36,18 @@ func NewVerify() *Verify { } // Run executes 'helm verify'. -func (v *Verify) Run(chartfile string) error { +func (v *Verify) Run(chartfile string) (string, error) { var out strings.Builder p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring) if err != nil { - return err + return "", err } for name := range p.SignedBy.Identities { - fmt.Fprintf(&out, "Signed by: %v\n", name) + _, _ = fmt.Fprintf(&out, "Signed by: %v\n", name) } - fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint) - fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) + _, _ = fmt.Fprintf(&out, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint) + _, _ = fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) - // TODO(mattfarina): The output is set as a property rather than returned - // to maintain the Go API. In Helm v4 this function should return the out - // and the property on the struct can be removed. - v.Out = out.String() - - return nil + return out.String(), err } diff --git a/pkg/chart/dependency.go b/pkg/chart/dependency.go index 9f7c90364..864fe6d2c 100644 --- a/pkg/chart/dependency.go +++ b/pkg/chart/dependency.go @@ -47,6 +47,10 @@ func (r *v2DependencyAccessor) Name() string { return r.dep.Name } +func (r *v2DependencyAccessor) Alias() string { + return r.dep.Alias +} + type v3DependencyAccessor struct { dep *v3chart.Dependency } @@ -54,3 +58,7 @@ type v3DependencyAccessor struct { func (r *v3DependencyAccessor) Name() string { return r.dep.Name } + +func (r *v3DependencyAccessor) Alias() string { + return r.dep.Alias +} diff --git a/pkg/chart/interfaces.go b/pkg/chart/interfaces.go index f9c61c35c..4001bc548 100644 --- a/pkg/chart/interfaces.go +++ b/pkg/chart/interfaces.go @@ -40,4 +40,5 @@ type Accessor interface { type DependencyAccessor interface { Name() string + Alias() string } diff --git a/pkg/chart/v2/lint/lint.go b/pkg/chart/v2/lint/lint.go index b26d65a34..1c871d936 100644 --- a/pkg/chart/v2/lint/lint.go +++ b/pkg/chart/v2/lint/lint.go @@ -58,7 +58,12 @@ func RunAll(baseDir string, values map[string]interface{}, namespace string, opt rules.Chartfile(&result) rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation) - rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) + rules.Templates( + &result, + namespace, + values, + rules.TemplateLinterKubeVersion(lo.KubeVersion), + rules.TemplateLinterSkipSchemaValidation(lo.SkipSchemaValidation)) rules.Dependencies(&result) rules.Crds(&result) diff --git a/pkg/chart/v2/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go index bd3ec1f1f..6f8f137f4 100644 --- a/pkg/chart/v2/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -27,8 +27,6 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) -var values map[string]interface{} - const namespace = "testNamespace" const badChartDir = "rules/testdata/badchartfile" @@ -41,6 +39,7 @@ const malformedTemplate = "rules/testdata/malformed-template" const invalidChartFileDir = "rules/testdata/invalidchartfile" func TestBadChart(t *testing.T) { + var values map[string]any m := RunAll(badChartDir, values, namespace).Messages if len(m) != 9 { t.Errorf("Number of errors %v", len(m)) @@ -95,6 +94,7 @@ func TestBadChart(t *testing.T) { } func TestInvalidYaml(t *testing.T) { + var values map[string]any m := RunAll(badYamlFileDir, values, namespace).Messages if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) @@ -105,6 +105,7 @@ func TestInvalidYaml(t *testing.T) { } func TestInvalidChartYaml(t *testing.T) { + var values map[string]any m := RunAll(invalidChartFileDir, values, namespace).Messages if len(m) != 2 { t.Fatalf("All didn't fail with expected errors, got %#v", m) @@ -115,6 +116,7 @@ func TestInvalidChartYaml(t *testing.T) { } func TestBadValues(t *testing.T) { + var values map[string]any m := RunAll(badValuesFileDir, values, namespace).Messages if len(m) < 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) @@ -125,6 +127,7 @@ func TestBadValues(t *testing.T) { } func TestBadCrdFile(t *testing.T) { + var values map[string]any m := RunAll(badCrdFileDir, values, namespace).Messages assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'") @@ -132,6 +135,7 @@ func TestBadCrdFile(t *testing.T) { } func TestGoodChart(t *testing.T) { + var values map[string]any m := RunAll(goodChartDir, values, namespace).Messages if len(m) != 0 { t.Error("All returned linter messages when it shouldn't have") @@ -145,6 +149,7 @@ func TestGoodChart(t *testing.T) { // // See https://github.com/helm/helm/issues/7923 func TestHelmCreateChart(t *testing.T) { + var values map[string]any dir := t.TempDir() createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) @@ -194,11 +199,11 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { // Add values to enable hpa, and ingress which are disabled by default. // This is the equivalent of: // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' - updatedValues := map[string]interface{}{ - "autoscaling": map[string]interface{}{ + updatedValues := map[string]any{ + "autoscaling": map[string]any{ "enabled": true, }, - "ingress": map[string]interface{}{ + "ingress": map[string]any{ "enabled": true, }, } @@ -217,6 +222,7 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { // lint ignores import-values // See https://github.com/helm/helm/issues/9658 func TestSubChartValuesChart(t *testing.T) { + var values map[string]any m := RunAll(subChartValuesDir, values, namespace).Messages if len(m) != 0 { t.Error("All returned linter messages when it shouldn't have") @@ -229,6 +235,7 @@ func TestSubChartValuesChart(t *testing.T) { // lint stuck with malformed template object // See https://github.com/helm/helm/issues/11391 func TestMalformedTemplate(t *testing.T) { + var values map[string]any c := time.After(3 * time.Second) ch := make(chan int, 1) var m []support.Message diff --git a/pkg/chart/v2/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go index 5c84d0f68..b21050a9e 100644 --- a/pkg/chart/v2/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -42,35 +42,66 @@ import ( ) // Templates lints the templates in the Linter. -func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { - TemplatesWithKubeVersion(linter, values, namespace, nil) +func Templates(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) { + templateLinter := newTemplateLinter(linter, namespace, values, options...) + templateLinter.Lint() } -// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. -func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) { - TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) +type TemplateLinterOption func(*templateLinter) + +func TemplateLinterKubeVersion(kubeVersion *common.KubeVersion) TemplateLinterOption { + return func(tl *templateLinter) { + tl.kubeVersion = kubeVersion + } +} + +func TemplateLinterSkipSchemaValidation(skipSchemaValidation bool) TemplateLinterOption { + return func(tl *templateLinter) { + tl.skipSchemaValidation = skipSchemaValidation + } } -// TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not. -func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { - fpath := "templates/" - templatesPath := filepath.Join(linter.ChartDir, fpath) +func newTemplateLinter(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) templateLinter { + + result := templateLinter{ + linter: linter, + values: values, + namespace: namespace, + } - // Templates directory is optional for now - templatesDirExists := linter.RunLinterRule(support.WarningSev, fpath, templatesDirExists(templatesPath)) + for _, o := range options { + o(&result) + } + + return result +} + +type templateLinter struct { + linter *support.Linter + values map[string]any + namespace string + kubeVersion *common.KubeVersion + skipSchemaValidation bool +} + +func (t *templateLinter) Lint() { + templatesDir := "templates/" + templatesPath := filepath.Join(t.linter.ChartDir, templatesDir) + + templatesDirExists := t.linter.RunLinterRule(support.WarningSev, templatesDir, templatesDirExists(templatesPath)) if !templatesDirExists { return } - validTemplatesDir := linter.RunLinterRule(support.ErrorSev, fpath, validateTemplatesDir(templatesPath)) + validTemplatesDir := t.linter.RunLinterRule(support.ErrorSev, templatesDir, validateTemplatesDir(templatesPath)) if !validTemplatesDir { return } // Load chart and parse templates - chart, err := loader.Load(linter.ChartDir) + chart, err := loader.Load(t.linter.ChartDir) - chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + chartLoaded := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) if !chartLoaded { return @@ -78,35 +109,35 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string options := common.ReleaseOptions{ Name: "test-release", - Namespace: namespace, + Namespace: t.namespace, } caps := common.DefaultCapabilities.Copy() - if kubeVersion != nil { - caps.KubeVersion = *kubeVersion + if t.kubeVersion != nil { + caps.KubeVersion = *t.kubeVersion } // lint ignores import-values // See https://github.com/helm/helm/issues/9658 - if err := chartutil.ProcessDependencies(chart, values); err != nil { + if err := chartutil.ProcessDependencies(chart, t.values); err != nil { return } - cvals, err := util.CoalesceValues(chart, values) + cvals, err := util.CoalesceValues(chart, t.values) if err != nil { return } - valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, t.skipSchemaValidation) if err != nil { - linter.RunLinterRule(support.ErrorSev, fpath, err) + t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) return } var e engine.Engine e.LintMode = true renderedContentMap, err := e.Render(chart, valuesToRender) - renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) + renderOk := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) if !renderOk { return @@ -121,9 +152,8 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string */ for _, template := range chart.Templates { fileName := template.Name - fpath = fileName - linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) + t.linter.RunLinterRule(support.ErrorSev, fileName, validateAllowedExtension(fileName)) // We only apply the following lint rules to yaml files if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { @@ -139,7 +169,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] if strings.TrimSpace(renderedContent) != "" { - linter.RunLinterRule(support.WarningSev, fpath, validateTopIndentLevel(renderedContent)) + t.linter.RunLinterRule(support.WarningSev, fileName, validateTopIndentLevel(renderedContent)) decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096) @@ -156,17 +186,17 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string // If YAML linting fails here, it will always fail in the next block as well, so we should return here. // fix https://github.com/helm/helm/issues/11391 - if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + if !t.linter.RunLinterRule(support.ErrorSev, fileName, validateYamlContent(err)) { return } if yamlStruct != nil { // NOTE: set to warnings to allow users to support out-of-date kubernetes // Refs https://github.com/helm/helm/issues/8596 - linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct)) - linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion)) + t.linter.RunLinterRule(support.WarningSev, fileName, validateMetadataName(yamlStruct)) + t.linter.RunLinterRule(support.WarningSev, fileName, validateNoDeprecations(yamlStruct, t.kubeVersion)) - linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent)) - linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent)) + t.linter.RunLinterRule(support.ErrorSev, fileName, validateMatchSelector(yamlStruct, renderedContent)) + t.linter.RunLinterRule(support.ErrorSev, fileName, validateListAnnotations(yamlStruct, renderedContent)) } } } @@ -234,6 +264,7 @@ func validateYamlContent(err error) error { if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) } + return nil } diff --git a/pkg/chart/v2/lint/rules/template_test.go b/pkg/chart/v2/lint/rules/template_test.go index 3e8e0b371..7629d3de5 100644 --- a/pkg/chart/v2/lint/rules/template_test.go +++ b/pkg/chart/v2/lint/rules/template_test.go @@ -51,11 +51,14 @@ func TestValidateAllowedExtension(t *testing.T) { var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} const namespace = "testNamespace" -const strict = false func TestTemplateParsing(t *testing.T) { linter := support.Linter{ChartDir: templateTestBasedir} - Templates(&linter, values, namespace, strict) + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) res := linter.Messages if len(res) != 1 { @@ -78,7 +81,11 @@ func TestTemplateIntegrationHappyPath(t *testing.T) { defer os.Rename(ignoredTemplatePath, wrongTemplatePath) linter := support.Linter{ChartDir: templateTestBasedir} - Templates(&linter, values, namespace, strict) + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) res := linter.Messages if len(res) != 0 { @@ -88,7 +95,11 @@ func TestTemplateIntegrationHappyPath(t *testing.T) { func TestMultiTemplateFail(t *testing.T) { linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} - Templates(&linter, values, namespace, strict) + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) res := linter.Messages if len(res) != 1 { @@ -208,7 +219,11 @@ func TestDeprecatedAPIFails(t *testing.T) { } linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} - Templates(&linter, values, namespace, strict) + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) if l := len(linter.Messages); l != 1 { for i, msg := range linter.Messages { t.Logf("Message %d: %s", i, msg) @@ -264,7 +279,11 @@ func TestStrictTemplateParsingMapError(t *testing.T) { linter := &support.Linter{ ChartDir: filepath.Join(dir, ch.Metadata.Name), } - Templates(linter, ch.Values, namespace, strict) + Templates( + linter, + namespace, + ch.Values, + TemplateLinterSkipSchemaValidation(false)) if len(linter.Messages) != 0 { t.Errorf("expected zero messages, got %d", len(linter.Messages)) for i, msg := range linter.Messages { @@ -393,7 +412,11 @@ func TestEmptyWithCommentsManifests(t *testing.T) { } linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} - Templates(&linter, values, namespace, strict) + Templates( + &linter, + namespace, + values, + TemplateLinterSkipSchemaValidation(false)) if l := len(linter.Messages); l > 0 { for i, msg := range linter.Messages { t.Logf("Message %d: %s", i, msg) diff --git a/pkg/cmd/completion_test.go b/pkg/cmd/completion_test.go index 375a9a97d..81c1ee2ad 100644 --- a/pkg/cmd/completion_test.go +++ b/pkg/cmd/completion_test.go @@ -22,6 +22,7 @@ import ( "testing" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -31,7 +32,7 @@ func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) { storage := storageFixture() storage.Create(&release.Release{ Name: "myrelease", - Info: &release.Info{Status: release.StatusDeployed}, + Info: &release.Info{Status: common.StatusDeployed}, Chart: &chart.Chart{ Metadata: &chart.Metadata{ Name: "Myrelease-Chart", diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index 8d79716f0..614970252 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -25,6 +25,7 @@ import ( "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -64,35 +65,35 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { cmd: fmt.Sprintf("__complete %s --output ''", cmdName), golden: "output/output-comp.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }, { name: "completion for output flag long and after arg", cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), golden: "output/output-comp.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }, { name: "completion for output flag short and before arg", cmd: fmt.Sprintf("__complete %s -o ''", cmdName), golden: "output/output-comp.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }, { name: "completion for output flag short and after arg", cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), golden: "output/output-comp.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }, { name: "completion for output flag, no filter", cmd: fmt.Sprintf("__complete %s --output jso", cmdName), golden: "output/output-comp.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }} runTestCmd(t, tests) diff --git a/pkg/cmd/get_hooks.go b/pkg/cmd/get_hooks.go index 7ffefd93c..d344307cb 100644 --- a/pkg/cmd/get_hooks.go +++ b/pkg/cmd/get_hooks.go @@ -25,6 +25,7 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/release" ) const getHooksHelp = ` @@ -52,8 +53,16 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - for _, hook := range res.Hooks { - fmt.Fprintf(out, "---\n# Source: %s\n%s\n", hook.Path, hook.Manifest) + rac, err := release.NewAccessor(res) + if err != nil { + return err + } + for _, hook := range rac.Hooks() { + hac, err := release.NewHookAccessor(hook) + if err != nil { + return err + } + fmt.Fprintf(out, "---\n# Source: %s\n%s\n", hac.Path(), hac.Manifest()) } return nil }, diff --git a/pkg/cmd/get_manifest.go b/pkg/cmd/get_manifest.go index 021495d8d..253b011c1 100644 --- a/pkg/cmd/get_manifest.go +++ b/pkg/cmd/get_manifest.go @@ -25,6 +25,7 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/release" ) var getManifestHelp = ` @@ -54,7 +55,11 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command if err != nil { return err } - fmt.Fprintln(out, res.Manifest) + rac, err := release.NewAccessor(res) + if err != nil { + return err + } + fmt.Fprintln(out, rac.Manifest()) return nil }, } diff --git a/pkg/cmd/get_notes.go b/pkg/cmd/get_notes.go index ae79d8bcc..46fbeeaf5 100644 --- a/pkg/cmd/get_notes.go +++ b/pkg/cmd/get_notes.go @@ -25,6 +25,7 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/release" ) var getNotesHelp = ` @@ -50,8 +51,12 @@ func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - if len(res.Info.Notes) > 0 { - fmt.Fprintf(out, "NOTES:\n%s\n", res.Info.Notes) + rac, err := release.NewAccessor(res) + if err != nil { + return err + } + if len(rac.Notes()) > 0 { + fmt.Fprintf(out, "NOTES:\n%s\n", rac.Notes()) } return nil }, diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go new file mode 100644 index 000000000..e555dd18b --- /dev/null +++ b/pkg/cmd/helpers.go @@ -0,0 +1,83 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "log/slog" + "strconv" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/pkg/action" +) + +func addDryRunFlag(cmd *cobra.Command) { + // --dry-run options with expected outcome: + // - Not set means no dry run and server is contacted. + // - Set with no value, a value of client, or a value of true and the server is not contacted + // - Set with a value of false, none, or false and the server is contacted + // The true/false part is meant to reflect some legacy behavior while none is equal to "". + f := cmd.Flags() + f.String( + "dry-run", + "none", + `simulates the operation without persisting changes. Must be one of: "none" (default), "client", or "server". '--dry-run=none' executes the operation normally and persists changes (no simulation). '--dry-run=client' simulates the operation client-side only and avoids cluster connections. '--dry-run=server' simulates the operation on the server, requiring cluster connectivity.`) + f.Lookup("dry-run").NoOptDefVal = "unset" +} + +// Determine the `action.DryRunStrategy` given -dry-run=` flag (or absence of) +// Legacy usage of the flag: boolean values, and `--dry-run` (without value) are supported, and log warnings emitted +func cmdGetDryRunFlagStrategy(cmd *cobra.Command, isTemplate bool) (action.DryRunStrategy, error) { + + f := cmd.Flag("dry-run") + v := f.Value.String() + + switch v { + case f.NoOptDefVal: + slog.Warn(`--dry-run is deprecated and should be replaced with '--dry-run=client'`) + return action.DryRunClient, nil + case string(action.DryRunClient): + return action.DryRunClient, nil + case string(action.DryRunServer): + return action.DryRunServer, nil + case string(action.DryRunNone): + if isTemplate { + // Special case hack for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + return action.DryRunNone, nil + } + + b, err := strconv.ParseBool(v) + if err != nil { + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "none", "server", or "client"`, v) + } + + if isTemplate && !b { + // Special case for `helm template`, which is always a dry run + return action.DryRunNone, fmt.Errorf(`invalid dry-run value (%q). Must be "server" or "client"`, v) + } + + result := action.DryRunNone + if b { + result = action.DryRunClient + } + slog.Warn(fmt.Sprintf(`boolean '--dry-run=%v' flag is deprecated and must be replaced with '--dry-run=%s'`, v, result)) + + return result, nil +} diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 07f01b42b..f2bb92303 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -18,8 +18,10 @@ package cmd import ( "bytes" + "encoding/json" "fmt" "io" + "log/slog" "os" "path/filepath" "strings" @@ -28,6 +30,8 @@ import ( shellwords "github.com/mattn/go-shellwords" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/internal/test" "helm.sh/helm/v4/pkg/action" @@ -204,3 +208,155 @@ func resetChartDependencyState(chartPath string, recursive bool) error { return nil } + +func TestCmdGetDryRunFlagStrategy(t *testing.T) { + + type testCaseExpectedLog struct { + Level string + Msg string + } + testCases := map[string]struct { + DryRunFlagArg string + IsTemplate bool + ExpectedStrategy action.DryRunStrategy + ExpectedError bool + ExpectedLog *testCaseExpectedLog + }{ + "unset_value": { + DryRunFlagArg: "--dry-run", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "unset_special": { + DryRunFlagArg: "--dry-run=unset", // Special value that matches cmd.Flags("dry-run").NoOptDefVal + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "none": { + DryRunFlagArg: "--dry-run=none", + ExpectedStrategy: action.DryRunNone, + }, + "client": { + DryRunFlagArg: "--dry-run=client", + ExpectedStrategy: action.DryRunClient, + }, + "server": { + DryRunFlagArg: "--dry-run=server", + ExpectedStrategy: action.DryRunServer, + }, + "bool_false": { + DryRunFlagArg: "--dry-run=false", + ExpectedStrategy: action.DryRunNone, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=false' flag is deprecated and must be replaced with '--dry-run=none'`, + }, + }, + "bool_true": { + DryRunFlagArg: "--dry-run=true", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=true' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "bool_0": { + DryRunFlagArg: "--dry-run=0", + ExpectedStrategy: action.DryRunNone, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=0' flag is deprecated and must be replaced with '--dry-run=none'`, + }, + }, + "bool_1": { + DryRunFlagArg: "--dry-run=1", + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=1' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "invalid": { + DryRunFlagArg: "--dry-run=invalid", + ExpectedError: true, + }, + "template_unset_value": { + DryRunFlagArg: "--dry-run", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `--dry-run is deprecated and should be replaced with '--dry-run=client'`, + }, + }, + "template_bool_false": { + DryRunFlagArg: "--dry-run=false", + IsTemplate: true, + ExpectedError: true, + }, + "template_bool_template_true": { + DryRunFlagArg: "--dry-run=true", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + ExpectedLog: &testCaseExpectedLog{ + Level: "WARN", + Msg: `boolean '--dry-run=true' flag is deprecated and must be replaced with '--dry-run=client'`, + }, + }, + "template_none": { + DryRunFlagArg: "--dry-run=none", + IsTemplate: true, + ExpectedError: true, + }, + "template_client": { + DryRunFlagArg: "--dry-run=client", + IsTemplate: true, + ExpectedStrategy: action.DryRunClient, + }, + "template_server": { + DryRunFlagArg: "--dry-run=server", + IsTemplate: true, + ExpectedStrategy: action.DryRunServer, + }, + } + + for name, tc := range testCases { + + logBuf := new(bytes.Buffer) + logger := slog.New(slog.NewJSONHandler(logBuf, nil)) + slog.SetDefault(logger) + + cmd := &cobra.Command{ + Use: "helm", + } + addDryRunFlag(cmd) + cmd.Flags().Parse([]string{"helm", tc.DryRunFlagArg}) + + t.Run(name, func(t *testing.T) { + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, tc.IsTemplate) + if tc.ExpectedError { + assert.Error(t, err) + } else { + assert.Nil(t, err) + assert.Equal(t, tc.ExpectedStrategy, dryRunStrategy) + } + + if tc.ExpectedLog != nil { + logResult := map[string]string{} + err = json.Unmarshal(logBuf.Bytes(), &logResult) + require.Nil(t, err) + + assert.Equal(t, tc.ExpectedLog.Level, logResult["level"]) + assert.Equal(t, tc.ExpectedLog.Msg, logResult["msg"]) + } else { + assert.Equal(t, 0, logBuf.Len()) + } + }) + } +} diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index f4dde95e4..b294a9da7 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "encoding/json" "fmt" "io" "strconv" @@ -91,6 +92,75 @@ type releaseInfo struct { Description string `json:"description"` } +// releaseInfoJSON is used for custom JSON marshaling/unmarshaling +type releaseInfoJSON struct { + Revision int `json:"revision"` + Updated *time.Time `json:"updated,omitempty"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + Description string `json:"description"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (r *releaseInfo) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + if val, ok := raw["updated"]; ok { + if str, ok := val.(string); ok && str == "" { + raw["updated"] = nil + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time field + var tmp releaseInfoJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to releaseInfo struct + r.Revision = tmp.Revision + if tmp.Updated != nil { + r.Updated = *tmp.Updated + } + r.Status = tmp.Status + r.Chart = tmp.Chart + r.AppVersion = tmp.AppVersion + r.Description = tmp.Description + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (r releaseInfo) MarshalJSON() ([]byte, error) { + tmp := releaseInfoJSON{ + Revision: r.Revision, + Status: r.Status, + Chart: r.Chart, + AppVersion: r.AppVersion, + Description: r.Description, + } + + if !r.Updated.IsZero() { + tmp.Updated = &r.Updated + } + + return json.Marshal(tmp) +} + type releaseHistory []releaseInfo func (r releaseHistory) WriteJSON(out io.Writer) error { @@ -111,7 +181,11 @@ func (r releaseHistory) WriteTable(out io.Writer) error { } func getHistory(client *action.History, name string) (releaseHistory, error) { - hist, err := client.Run(name) + histi, err := client.Run(name) + if err != nil { + return nil, err + } + hist, err := releaseListToV1List(histi) if err != nil { return nil, err } @@ -180,7 +254,11 @@ func compListRevisions(_ string, cfg *action.Configuration, releaseName string) client := action.NewHistory(cfg) var revisions []string - if hist, err := client.Run(releaseName); err == nil { + if histi, err := client.Run(releaseName); err == nil { + hist, err := releaseListToV1List(histi) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } for _, version := range hist { appVersion := fmt.Sprintf("App: %s", version.Chart.Metadata.AppVersion) chartDesc := fmt.Sprintf("Chart: %s-%s", version.Chart.Metadata.Name, version.Chart.Metadata.Version) diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index d26ed9ecf..d8adc2d19 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -17,14 +17,20 @@ limitations under the License. package cmd import ( + "encoding/json" "fmt" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) func TestHistoryCmd(t *testing.T) { - mk := func(name string, vers int, status release.Status) *release.Release { + mk := func(name string, vers int, status common.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{ Name: name, Version: vers, @@ -36,34 +42,34 @@ func TestHistoryCmd(t *testing.T) { name: "get history for release", cmd: "history angry-bird", rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), - mk("angry-bird", 2, release.StatusSuperseded), - mk("angry-bird", 1, release.StatusSuperseded), + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), + mk("angry-bird", 2, common.StatusSuperseded), + mk("angry-bird", 1, common.StatusSuperseded), }, golden: "output/history.txt", }, { name: "get history with max limit set", cmd: "history angry-bird --max 2", rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), }, golden: "output/history-limit.txt", }, { name: "get history with yaml output format", cmd: "history angry-bird --output yaml", rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), }, golden: "output/history.yaml", }, { name: "get history with json output format", cmd: "history angry-bird --output json", rels: []*release.Release{ - mk("angry-bird", 4, release.StatusDeployed), - mk("angry-bird", 3, release.StatusSuperseded), + mk("angry-bird", 4, common.StatusDeployed), + mk("angry-bird", 3, common.StatusSuperseded), }, golden: "output/history.json", }} @@ -76,7 +82,7 @@ func TestHistoryOutputCompletion(t *testing.T) { func revisionFlagCompletionTest(t *testing.T, cmdName string) { t.Helper() - mk := func(name string, vers int, status release.Status) *release.Release { + mk := func(name string, vers int, status common.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{ Name: name, Version: vers, @@ -85,10 +91,10 @@ func revisionFlagCompletionTest(t *testing.T, cmdName string) { } releases := []*release.Release{ - mk("musketeers", 11, release.StatusDeployed), - mk("musketeers", 10, release.StatusSuperseded), - mk("musketeers", 9, release.StatusSuperseded), - mk("musketeers", 8, release.StatusSuperseded), + mk("musketeers", 11, common.StatusDeployed), + mk("musketeers", 10, common.StatusSuperseded), + mk("musketeers", 9, common.StatusSuperseded), + mk("musketeers", 8, common.StatusSuperseded), } tests := []cmdTestCase{{ @@ -123,3 +129,205 @@ func TestHistoryFileCompletion(t *testing.T) { checkFileCompletion(t, "history", false) checkFileCompletion(t, "history myrelease", false) } + +func TestReleaseInfoMarshalJSON(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info releaseInfo + expected string + }{ + { + name: "all fields populated", + info: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + expected: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + }, + { + name: "without updated time", + info: releaseInfo{ + Revision: 2, + Status: "superseded", + Chart: "mychart-1.0.1", + AppVersion: "1.0.1", + Description: "Upgraded", + }, + expected: `{"revision":2,"status":"superseded","chart":"mychart-1.0.1","app_version":"1.0.1","description":"Upgraded"}`, + }, + { + name: "with zero revision", + info: releaseInfo{ + Revision: 0, + Updated: updated, + Status: "failed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Install failed", + }, + expected: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"failed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install failed"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestReleaseInfoUnmarshalJSON(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected releaseInfo + wantErr bool + }{ + { + name: "all fields populated", + input: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + expected: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + }, + { + name: "empty string updated field", + input: `{"revision":2,"updated":"","status":"superseded","chart":"mychart-1.0.1","app_version":"1.0.1","description":"Upgraded"}`, + expected: releaseInfo{ + Revision: 2, + Status: "superseded", + Chart: "mychart-1.0.1", + AppVersion: "1.0.1", + Description: "Upgraded", + }, + }, + { + name: "missing updated field", + input: `{"revision":3,"status":"deployed","chart":"mychart-1.0.2","app_version":"1.0.2","description":"Upgraded"}`, + expected: releaseInfo{ + Revision: 3, + Status: "deployed", + Chart: "mychart-1.0.2", + AppVersion: "1.0.2", + Description: "Upgraded", + }, + }, + { + name: "null updated field", + input: `{"revision":4,"updated":null,"status":"failed","chart":"mychart-1.0.3","app_version":"1.0.3","description":"Failed"}`, + expected: releaseInfo{ + Revision: 4, + Status: "failed", + Chart: "mychart-1.0.3", + AppVersion: "1.0.3", + Description: "Failed", + }, + }, + { + name: "invalid time format", + input: `{"revision":5,"updated":"invalid-time","status":"deployed","chart":"mychart-1.0.4","app_version":"1.0.4","description":"Test"}`, + wantErr: true, + }, + { + name: "zero revision", + input: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"pending-install","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Installing"}`, + expected: releaseInfo{ + Revision: 0, + Updated: updated, + Status: "pending-install", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Installing", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info releaseInfo + err := json.Unmarshal([]byte(tt.input), &info) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.Revision, info.Revision) + assert.Equal(t, tt.expected.Updated.Unix(), info.Updated.Unix()) + assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.Chart, info.Chart) + assert.Equal(t, tt.expected.AppVersion, info.AppVersion) + assert.Equal(t, tt.expected.Description, info.Description) + }) + } +} + +func TestReleaseInfoRoundTrip(t *testing.T) { + updated := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + + original := releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded releaseInfo + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.Revision, decoded.Revision) + assert.Equal(t, original.Updated.Unix(), decoded.Updated.Unix()) + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Chart, decoded.Chart) + assert.Equal(t, original.AppVersion, decoded.AppVersion) + assert.Equal(t, original.Description, decoded.Description) +} + +func TestReleaseInfoEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"revision":1,"updated":"","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Test"}` + + var info releaseInfo + err := json.Unmarshal([]byte(input), &info) + require.NoError(t, err) + + // Verify time field is zero value + assert.True(t, info.Updated.IsZero()) + assert.Equal(t, 1, info.Revision) + assert.Equal(t, "deployed", info.Status) + + // Marshal back and verify empty time field is omitted + data, err := json.Marshal(&info) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time value should be omitted + assert.NotContains(t, result, "updated") + assert.Equal(t, float64(1), result["revision"]) + assert.Equal(t, "deployed", result["status"]) + assert.Equal(t, "mychart-1.0.0", result["chart"]) +} diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index 240d60868..9bd999826 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -18,14 +18,12 @@ package cmd import ( "context" - "errors" "fmt" "io" "log" "log/slog" "os" "os/signal" - "slices" "syscall" "time" @@ -144,7 +142,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compInstall(args, toComplete, client) }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password) if err != nil { @@ -152,12 +150,12 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "none" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err } + client.DryRunStrategy = dryRunStrategy + rel, err := runInstall(args, client, valueOpts, out) if err != nil { return fmt.Errorf("INSTALLATION FAILED: %w", err) @@ -173,11 +171,12 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, } - addInstallFlags(cmd, cmd.Flags(), client, valueOpts) + f := cmd.Flags() + addInstallFlags(cmd, f, client, valueOpts) // hide-secret is not available in all places the install flags are used so // it is added separately - f := cmd.Flags() f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") + addDryRunFlag(cmd) bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer, settings) @@ -186,13 +185,6 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Install, valueOpts *values.Options) { f.BoolVar(&client.CreateNamespace, "create-namespace", false, "create the release namespace if not present") - // --dry-run options with expected outcome: - // - Not set means no dry run and server is contacted. - // - Set with no value, a value of client, or a value of true and the server is not contacted - // - Set with a value of false, none, or false and the server is contacted - // The true/false part is meant to reflect some legacy behavior while none is equal to "". - f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") - f.Lookup("dry-run").NoOptDefVal = "client" f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -318,11 +310,6 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options client.Namespace = settings.Namespace() - // Validate DryRunOption member is one of the allowed values - if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { - return nil, err - } - // Create context and prepare the handle of SIGTERM ctx := context.Background() ctx, cancel := context.WithCancel(ctx) @@ -338,7 +325,12 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options cancel() }() - return client.RunWithContext(ctx, chartRequested, vals) + ri, err := client.RunWithContext(ctx, chartRequested, vals) + rel, rerr := releaserToV1Release(ri) + if rerr != nil { + return nil, rerr + } + return rel, err } // checkIfInstallable validates if a chart can be installed @@ -365,13 +357,3 @@ func compInstall(args []string, toComplete string, client *action.Install) ([]st } return nil, cobra.ShellCompDirectiveNoFileComp } - -func validateDryRunOptionFlag(dryRunOptionFlagValue string) error { - // Validate dry-run flag value with a set of allowed value - allowedDryRunValues := []string{"false", "true", "none", "client", "server"} - isAllowed := slices.Contains(allowedDryRunValues, dryRunOptionFlagValue) - if !isAllowed { - return errors.New("invalid dry-run flag. Flag must one of the following: false, true, none, client, server") - } - return nil -} diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index 71540f1be..ccc53ddd0 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -30,6 +30,7 @@ import ( "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/v2/lint/support" "helm.sh/helm/v4/pkg/cli/values" + "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/getter" ) @@ -51,11 +52,9 @@ func newLintCmd(out io.Writer) *cobra.Command { Use: "lint PATH", Short: "examine a chart for possible issues", Long: longLintHelp, + Args: require.MinimumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { - paths := []string{"."} - if len(args) > 0 { - paths = args - } + paths := args if kubeVersion != "" { parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) diff --git a/pkg/cmd/lint_test.go b/pkg/cmd/lint_test.go index 401c84d74..270273116 100644 --- a/pkg/cmd/lint_test.go +++ b/pkg/cmd/lint_test.go @@ -91,6 +91,15 @@ func TestLintCmdWithKubeVersionFlag(t *testing.T) { runTestCmd(t, tests) } +func TestLintCmdRequiresArgs(t *testing.T) { + tests := []cmdTestCase{{ + name: "lint without arguments should fail", + cmd: "lint", + wantError: true, + }} + runTestCmd(t, tests) +} + func TestLintFileCompletion(t *testing.T) { checkFileCompletion(t, "lint", true) checkFileCompletion(t, "lint mypath", true) // Multiple paths can be given diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 55d828036..3c15a0954 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -30,15 +30,17 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) var listHelp = ` This command lists all of the releases for a specified namespace (uses current namespace context if namespace not specified). -By default, it lists only releases that are deployed or failed. Flags like -'--uninstalled' and '--all' will alter this behavior. Such flags can be combined: -'--uninstalled --failed'. +By default, it lists all releases in any status. Individual status filters like '--deployed', '--failed', +'--pending', '--uninstalled', '--superseded', and '--uninstalling' can be used +to show only releases in specific states. Such flags can be combined: +'--deployed --failed'. By default, items are sorted alphabetically. Use the '-d' flag to sort by release date. @@ -79,7 +81,11 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetStateMask() - results, err := client.Run() + resultsi, err := client.Run() + if err != nil { + return err + } + results, err := releaseListToV1List(resultsi) if err != nil { return err } @@ -117,11 +123,10 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&client.TimeFormat, "time-format", "", `format time using golang time formatter. Example: --time-format "2006-01-02 15:04:05Z0700"`) f.BoolVarP(&client.ByDate, "date", "d", false, "sort by release date") f.BoolVarP(&client.SortReverse, "reverse", "r", false, "reverse the sort order") - f.BoolVarP(&client.All, "all", "a", false, "show all releases without any filter applied") f.BoolVar(&client.Uninstalled, "uninstalled", false, "show uninstalled releases (if 'helm uninstall --keep-history' was used)") f.BoolVar(&client.Superseded, "superseded", false, "show superseded releases") f.BoolVar(&client.Uninstalling, "uninstalling", false, "show releases that are currently being uninstalled") - f.BoolVar(&client.Deployed, "deployed", false, "show deployed releases. If no other is specified, this will be automatically enabled") + f.BoolVar(&client.Deployed, "deployed", false, "show deployed releases") f.BoolVar(&client.Failed, "failed", false, "show failed releases") f.BoolVar(&client.Pending, "pending", false, "show pending releases") f.BoolVarP(&client.AllNamespaces, "all-namespaces", "A", false, "list releases across all namespaces") @@ -193,28 +198,28 @@ func (w *releaseListWriter) WriteTable(out io.Writer) error { } for _, r := range w.releases { // Parse the status string back to a release.Status to use color - var status release.Status + var status common.Status switch r.Status { case "deployed": - status = release.StatusDeployed + status = common.StatusDeployed case "failed": - status = release.StatusFailed + status = common.StatusFailed case "pending-install": - status = release.StatusPendingInstall + status = common.StatusPendingInstall case "pending-upgrade": - status = release.StatusPendingUpgrade + status = common.StatusPendingUpgrade case "pending-rollback": - status = release.StatusPendingRollback + status = common.StatusPendingRollback case "uninstalling": - status = release.StatusUninstalling + status = common.StatusUninstalling case "uninstalled": - status = release.StatusUninstalled + status = common.StatusUninstalled case "superseded": - status = release.StatusSuperseded + status = common.StatusSuperseded case "unknown": - status = release.StatusUnknown + status = common.StatusUnknown default: - status = release.Status(r.Status) + status = common.Status(r.Status) } table.AddRow(r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion) } @@ -264,7 +269,11 @@ func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *acti // client.Filter = fmt.Sprintf("^%s", toComplete) client.SetStateMask() - releases, err := client.Run() + releasesi, err := client.Run() + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + releases, err := releaseListToV1List(releasesi) if err != nil { return nil, cobra.ShellCompDirectiveDefault } diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 22a948fff..097e62d11 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -21,6 +21,7 @@ import ( "time" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -47,7 +48,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusSuperseded, + Status: common.StatusSuperseded, }, Chart: chartInfo, }, @@ -57,7 +58,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: chartInfo, }, @@ -67,7 +68,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusUninstalled, + Status: common.StatusUninstalled, }, Chart: chartInfo, }, @@ -77,7 +78,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusSuperseded, + Status: common.StatusSuperseded, }, Chart: chartInfo, }, @@ -87,7 +88,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp2, - Status: release.StatusFailed, + Status: common.StatusFailed, }, Chart: chartInfo, }, @@ -97,7 +98,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusUninstalling, + Status: common.StatusUninstalling, }, Chart: chartInfo, }, @@ -107,7 +108,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusPendingInstall, + Status: common.StatusPendingInstall, }, Chart: chartInfo, }, @@ -117,7 +118,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp3, - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: chartInfo, }, @@ -127,7 +128,7 @@ func TestListCmd(t *testing.T) { Namespace: defaultNamespace, Info: &release.Info{ LastDeployed: timestamp4, - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: chartInfo, }, @@ -137,7 +138,7 @@ func TestListCmd(t *testing.T) { Namespace: "milano", Info: &release.Info{ LastDeployed: timestamp1, - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: chartInfo, }, @@ -146,22 +147,17 @@ func TestListCmd(t *testing.T) { tests := []cmdTestCase{{ name: "list releases", cmd: "list", - golden: "output/list.txt", + golden: "output/list-all.txt", rels: releaseFixture, }, { name: "list without headers", cmd: "list --no-headers", - golden: "output/list-no-headers.txt", - rels: releaseFixture, - }, { - name: "list all releases", - cmd: "list --all", - golden: "output/list-all.txt", + golden: "output/list-all-no-headers.txt", rels: releaseFixture, }, { name: "list releases sorted by release date", cmd: "list --date", - golden: "output/list-date.txt", + golden: "output/list-all-date.txt", rels: releaseFixture, }, { name: "list failed releases", @@ -171,17 +167,17 @@ func TestListCmd(t *testing.T) { }, { name: "list filtered releases", cmd: "list --filter='.*'", - golden: "output/list-filter.txt", + golden: "output/list-all.txt", rels: releaseFixture, }, { name: "list releases, limited to one release", cmd: "list --max 1", - golden: "output/list-max.txt", + golden: "output/list-all-max.txt", rels: releaseFixture, }, { name: "list releases, offset by one", cmd: "list --offset 1", - golden: "output/list-offset.txt", + golden: "output/list-all-offset.txt", rels: releaseFixture, }, { name: "list pending releases", @@ -191,27 +187,32 @@ func TestListCmd(t *testing.T) { }, { name: "list releases in reverse order", cmd: "list --reverse", - golden: "output/list-reverse.txt", + golden: "output/list-all-reverse.txt", rels: releaseFixture, }, { name: "list releases sorted by reversed release date", cmd: "list --date --reverse", - golden: "output/list-date-reversed.txt", + golden: "output/list-all-date-reversed.txt", rels: releaseFixture, }, { name: "list releases in short output format", cmd: "list --short", - golden: "output/list-short.txt", + golden: "output/list-all-short.txt", rels: releaseFixture, }, { name: "list releases in short output format", cmd: "list --short --output yaml", - golden: "output/list-short-yaml.txt", + golden: "output/list-all-short-yaml.txt", rels: releaseFixture, }, { name: "list releases in short output format", cmd: "list --short --output json", - golden: "output/list-short-json.txt", + golden: "output/list-all-short-json.txt", + rels: releaseFixture, + }, { + name: "list deployed and failed releases only", + cmd: "list --deployed --failed", + golden: "output/list.txt", rels: releaseFixture, }, { name: "list superseded releases", diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index c0593f384..534113bde 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -113,7 +113,6 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { input := &plugin.Input{ Message: schema.InputMessageCLIV1{ ExtraArgs: extraArgs, - Settings: settings, }, Env: env, Stdin: os.Stdin, diff --git a/pkg/cmd/release_testing.go b/pkg/cmd/release_testing.go index b660a16c5..88a6f351f 100644 --- a/pkg/cmd/release_testing.go +++ b/pkg/cmd/release_testing.go @@ -65,19 +65,23 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command client.Filters[action.ExcludeNameFilter] = append(client.Filters[action.ExcludeNameFilter], notName.ReplaceAllLiteralString(f, "")) } } - rel, runErr := client.Run(args[0]) + reli, runErr := client.Run(args[0]) // We only return an error if we weren't even able to get the // release, otherwise we keep going so we can print status and logs // if requested - if runErr != nil && rel == nil { + if runErr != nil && reli == nil { return runErr } + rel, err := releaserToV1Release(reli) + if err != nil { + return err + } if err := outfmt.Write(out, &statusPrinter{ release: rel, debug: settings.Debug, showMetadata: false, - hideNotes: client.HideNotes, + hideNotes: true, noColor: settings.ShouldDisableColor(), }); err != nil { return err @@ -99,7 +103,6 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") f.BoolVar(&outputLogs, "logs", false, "dump the logs from test pods (this runs after all tests are complete, but before any cleanup)") f.StringSliceVar(&filter, "filter", []string{}, "specify tests by attribute (currently \"name\") using attribute=value syntax or '!attribute=value' to exclude a test (can specify multiple or separate values with commas: name=test1,name=test2)") - f.BoolVar(&client.HideNotes, "hide-notes", false, "if set, do not show notes in test output. Does not affect presence in chart metadata") return cmd } diff --git a/pkg/cmd/release_testing_test.go b/pkg/cmd/release_testing_test.go index 43599ad0d..fdb5df1e9 100644 --- a/pkg/cmd/release_testing_test.go +++ b/pkg/cmd/release_testing_test.go @@ -17,7 +17,17 @@ limitations under the License. package cmd import ( + "bytes" + "io" + "strings" "testing" + + "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" ) func TestReleaseTestingCompletion(t *testing.T) { @@ -28,3 +38,44 @@ func TestReleaseTestingFileCompletion(t *testing.T) { checkFileCompletion(t, "test", false) checkFileCompletion(t, "test myrelease", false) } + +func TestReleaseTestNotesHandling(t *testing.T) { + // Test that ensures notes behavior is correct for test command + // This is a simpler test that focuses on the core functionality + + rel := &release.Release{ + Name: "test-release", + Namespace: "default", + Info: &release.Info{ + Status: rcommon.StatusDeployed, + Notes: "Some important notes that should be hidden by default", + }, + Chart: &chart.Chart{Metadata: &chart.Metadata{Name: "test", Version: "1.0.0"}}, + } + + // Set up storage + store := storageFixture() + store.Create(rel) + + // Set up action configuration properly + actionConfig := &action.Configuration{ + Releases: store, + KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}}, + Capabilities: common.DefaultCapabilities, + } + + // Test the newReleaseTestCmd function directly + var buf1 bytes.Buffer + + // Test 1: Default behavior (should hide notes) + cmd1 := newReleaseTestCmd(actionConfig, &buf1) + cmd1.SetArgs([]string{"test-release"}) + err1 := cmd1.Execute() + if err1 != nil { + t.Fatalf("Unexpected error for default test: %v", err1) + } + output1 := buf1.String() + if strings.Contains(output1, "NOTES:") { + t.Errorf("Expected notes to be hidden by default, but found NOTES section in output: %s", output1) + } +} diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index ff60aaedf..00a2725bc 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -57,7 +57,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return noMoreArgsComp() }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 1 { ver, err := strconv.Atoi(args[1]) if err != nil { @@ -66,6 +66,12 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Version = ver } + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err + } + client.DryRunStrategy = dryRunStrategy + if err := client.Run(args[0]); err != nil { return err } @@ -76,7 +82,6 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } f := cmd.Flags() - f.BoolVar(&client.DryRun, "dry-run", false, "simulate a rollback") f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -87,6 +92,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails") f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") + addDryRunFlag(cmd) AddWaitFlag(cmd, &client.WaitStrategy) cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") diff --git a/pkg/cmd/rollback_test.go b/pkg/cmd/rollback_test.go index 53c63613e..116e158fd 100644 --- a/pkg/cmd/rollback_test.go +++ b/pkg/cmd/rollback_test.go @@ -22,6 +22,7 @@ import ( "testing" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -29,13 +30,13 @@ func TestRollbackCmd(t *testing.T) { rels := []*release.Release{ { Name: "funny-honey", - Info: &release.Info{Status: release.StatusSuperseded}, + Info: &release.Info{Status: common.StatusSuperseded}, Chart: &chart.Chart{}, Version: 1, }, { Name: "funny-honey", - Info: &release.Info{Status: release.StatusDeployed}, + Info: &release.Info{Status: common.StatusDeployed}, Chart: &chart.Chart{}, Version: 2, }, @@ -83,7 +84,7 @@ func TestRollbackCmd(t *testing.T) { } func TestRollbackRevisionCompletion(t *testing.T) { - mk := func(name string, vers int, status release.Status) *release.Release { + mk := func(name string, vers int, status common.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{ Name: name, Version: vers, @@ -92,11 +93,11 @@ func TestRollbackRevisionCompletion(t *testing.T) { } releases := []*release.Release{ - mk("musketeers", 11, release.StatusDeployed), - mk("musketeers", 10, release.StatusSuperseded), - mk("musketeers", 9, release.StatusSuperseded), - mk("musketeers", 8, release.StatusSuperseded), - mk("carabins", 1, release.StatusSuperseded), + mk("musketeers", 11, common.StatusDeployed), + mk("musketeers", 10, common.StatusSuperseded), + mk("musketeers", 9, common.StatusSuperseded), + mk("musketeers", 8, common.StatusSuperseded), + mk("carabins", 1, common.StatusSuperseded), } tests := []cmdTestCase{{ @@ -132,14 +133,14 @@ func TestRollbackWithLabels(t *testing.T) { rels := []*release.Release{ { Name: releaseName, - Info: &release.Info{Status: release.StatusSuperseded}, + Info: &release.Info{Status: common.StatusSuperseded}, Chart: &chart.Chart{}, Version: 1, Labels: labels1, }, { Name: releaseName, - Info: &release.Info{Status: release.StatusDeployed}, + Info: &release.Info{Status: common.StatusDeployed}, Chart: &chart.Chart{}, Version: 2, Labels: labels2, @@ -155,7 +156,11 @@ func TestRollbackWithLabels(t *testing.T) { if err != nil { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := storage.Get(releaseName, 3) + updatedReli, err := storage.Get(releaseName, 3) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 4f1be88d6..48dbd760d 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -39,6 +39,7 @@ import ( "helm.sh/helm/v4/pkg/cli" kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/registry" + ri "helm.sh/helm/v4/pkg/release" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/repo/v1" "helm.sh/helm/v4/pkg/storage/driver" @@ -465,3 +466,31 @@ type CommandError struct { error ExitCode int } + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel ri.Releaser) (*release.Release, error) { + switch r := rel.(type) { + case release.Release: + return &r, nil + case *release.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} + +func releaseListToV1List(ls []ri.Releaser) ([]*release.Release, error) { + rls := make([]*release.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil +} diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index 3d1309c3e..f68316c6c 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -33,7 +33,8 @@ import ( "helm.sh/helm/v4/pkg/chart/common/util" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" - release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release" + releasev1 "helm.sh/helm/v4/pkg/release/v1" ) // NOTE: Keep the list of statuses up-to-date with pkg/release/status.go. @@ -72,7 +73,11 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if outfmt == output.Table { client.ShowResourcesTable = true } - rel, err := client.Run(args[0]) + reli, err := client.Run(args[0]) + if err != nil { + return err + } + rel, err := releaserToV1Release(reli) if err != nil { return err } @@ -110,54 +115,65 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } type statusPrinter struct { - release *release.Release + release release.Releaser debug bool showMetadata bool hideNotes bool noColor bool } +func (s statusPrinter) getV1Release() *releasev1.Release { + switch rel := s.release.(type) { + case releasev1.Release: + return &rel + case *releasev1.Release: + return rel + } + return &releasev1.Release{} +} + func (s statusPrinter) WriteJSON(out io.Writer) error { - return output.EncodeJSON(out, s.release) + return output.EncodeJSON(out, s.getV1Release()) } func (s statusPrinter) WriteYAML(out io.Writer) error { - return output.EncodeYAML(out, s.release) + return output.EncodeYAML(out, s.getV1Release()) } func (s statusPrinter) WriteTable(out io.Writer) error { if s.release == nil { return nil } - _, _ = fmt.Fprintf(out, "NAME: %s\n", s.release.Name) - if !s.release.Info.LastDeployed.IsZero() { - _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC)) + rel := s.getV1Release() + _, _ = fmt.Fprintf(out, "NAME: %s\n", rel.Name) + if !rel.Info.LastDeployed.IsZero() { + _, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", rel.Info.LastDeployed.Format(time.ANSIC)) } - _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(s.release.Namespace, s.noColor)) - _, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(s.release.Info.Status, s.noColor)) - _, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version) + _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(rel.Namespace, s.noColor)) + _, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(rel.Info.Status, s.noColor)) + _, _ = fmt.Fprintf(out, "REVISION: %d\n", rel.Version) if s.showMetadata { - _, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name) - _, _ = fmt.Fprintf(out, "VERSION: %s\n", s.release.Chart.Metadata.Version) - _, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", s.release.Chart.Metadata.AppVersion) + _, _ = fmt.Fprintf(out, "CHART: %s\n", rel.Chart.Metadata.Name) + _, _ = fmt.Fprintf(out, "VERSION: %s\n", rel.Chart.Metadata.Version) + _, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", rel.Chart.Metadata.AppVersion) } - _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description) + _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", rel.Info.Description) - if len(s.release.Info.Resources) > 0 { + if len(rel.Info.Resources) > 0 { buf := new(bytes.Buffer) printFlags := get.NewHumanPrintFlags() typePrinter, _ := printFlags.ToPrinter("") printer := &get.TablePrinter{Delegate: typePrinter} var keys []string - for key := range s.release.Info.Resources { + for key := range rel.Info.Resources { keys = append(keys, key) } for _, t := range keys { _, _ = fmt.Fprintf(buf, "==> %s\n", t) - vk := s.release.Info.Resources[t] + vk := rel.Info.Resources[t] for _, resource := range vk { if err := printer.PrintObj(resource, buf); err != nil { _, _ = fmt.Fprintf(buf, "failed to print object type %s: %v\n", t, err) @@ -170,8 +186,8 @@ func (s statusPrinter) WriteTable(out io.Writer) error { _, _ = fmt.Fprintf(out, "RESOURCES:\n%s\n", buf.String()) } - executions := executionsByHookEvent(s.release) - if tests, ok := executions[release.HookTest]; !ok || len(tests) == 0 { + executions := executionsByHookEvent(rel) + if tests, ok := executions[releasev1.HookTest]; !ok || len(tests) == 0 { _, _ = fmt.Fprintln(out, "TEST SUITE: None") } else { for _, h := range tests { @@ -190,14 +206,14 @@ func (s statusPrinter) WriteTable(out io.Writer) error { if s.debug { _, _ = fmt.Fprintln(out, "USER-SUPPLIED VALUES:") - err := output.EncodeYAML(out, s.release.Config) + err := output.EncodeYAML(out, rel.Config) if err != nil { return err } // Print an extra newline _, _ = fmt.Fprintln(out) - cfg, err := util.CoalesceValues(s.release.Chart, s.release.Config) + cfg, err := util.CoalesceValues(rel.Chart, rel.Config) if err != nil { return err } @@ -211,28 +227,28 @@ func (s statusPrinter) WriteTable(out io.Writer) error { _, _ = fmt.Fprintln(out) } - if strings.EqualFold(s.release.Info.Description, "Dry run complete") || s.debug { + if strings.EqualFold(rel.Info.Description, "Dry run complete") || s.debug { _, _ = fmt.Fprintln(out, "HOOKS:") - for _, h := range s.release.Hooks { + for _, h := range rel.Hooks { _, _ = fmt.Fprintf(out, "---\n# Source: %s\n%s\n", h.Path, h.Manifest) } - _, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest) + _, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", rel.Manifest) } // Hide notes from output - option in install and upgrades - if !s.hideNotes && len(s.release.Info.Notes) > 0 { - _, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes)) + if !s.hideNotes && len(rel.Info.Notes) > 0 { + _, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(rel.Info.Notes)) } return nil } -func executionsByHookEvent(rel *release.Release) map[release.HookEvent][]*release.Hook { - result := make(map[release.HookEvent][]*release.Hook) +func executionsByHookEvent(rel *releasev1.Release) map[releasev1.HookEvent][]*releasev1.Hook { + result := make(map[releasev1.HookEvent][]*releasev1.Hook) for _, h := range rel.Hooks { for _, e := range h.Events { executions, ok := result[e] if !ok { - executions = []*release.Hook{} + executions = []*releasev1.Hook{} } result[e] = append(executions, h) } diff --git a/pkg/cmd/status_test.go b/pkg/cmd/status_test.go index 8c251b76b..b96a0d19a 100644 --- a/pkg/cmd/status_test.go +++ b/pkg/cmd/status_test.go @@ -21,6 +21,7 @@ import ( "time" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -41,14 +42,14 @@ func TestStatusCmd(t *testing.T) { cmd: "status flummoxed-chickadee", golden: "output/status.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }), }, { name: "get status of a deployed release, with desc", cmd: "status flummoxed-chickadee", golden: "output/status-with-desc.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, Description: "Mock description", }), }, { @@ -56,7 +57,7 @@ func TestStatusCmd(t *testing.T) { cmd: "status flummoxed-chickadee", golden: "output/status-with-notes.txt", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, Notes: "release notes", }), }, { @@ -64,7 +65,7 @@ func TestStatusCmd(t *testing.T) { cmd: "status flummoxed-chickadee -o json", golden: "output/status.json", rels: releasesMockWithStatus(&release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, Notes: "release notes", }), }, { @@ -73,7 +74,7 @@ func TestStatusCmd(t *testing.T) { golden: "output/status-with-resources.txt", rels: releasesMockWithStatus( &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, ), }, { @@ -82,7 +83,7 @@ func TestStatusCmd(t *testing.T) { golden: "output/status-with-resources.json", rels: releasesMockWithStatus( &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, ), }, { @@ -91,7 +92,7 @@ func TestStatusCmd(t *testing.T) { golden: "output/status-with-test-suite.txt", rels: releasesMockWithStatus( &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, &release.Hook{ Name: "never-run-test", @@ -140,7 +141,7 @@ func TestStatusCompletion(t *testing.T) { Name: "athos", Namespace: "default", Info: &release.Info{ - Status: release.StatusDeployed, + Status: common.StatusDeployed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -152,7 +153,7 @@ func TestStatusCompletion(t *testing.T) { Name: "porthos", Namespace: "default", Info: &release.Info{ - Status: release.StatusFailed, + Status: common.StatusFailed, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -164,7 +165,7 @@ func TestStatusCompletion(t *testing.T) { Name: "aramis", Namespace: "default", Info: &release.Info{ - Status: release.StatusUninstalled, + Status: common.StatusUninstalled, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -176,7 +177,7 @@ func TestStatusCompletion(t *testing.T) { Name: "dartagnan", Namespace: "gascony", Info: &release.Info{ - Status: release.StatusUnknown, + Status: common.StatusUnknown, }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 81c112d51..3ede31077 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -23,7 +23,6 @@ import ( "io" "io/fs" "os" - "path" "path/filepath" "regexp" "slices" @@ -67,7 +66,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return compInstall(args, toComplete, client) }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { if kubeVersion != "" { parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) if err != nil { @@ -83,16 +82,17 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "true" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, true) + if err != nil { + return err + } + if validate { + // Mimic deprecated --validate flag behavior by enabling server dry run + dryRunStrategy = action.DryRunServer } - client.DryRun = true + client.DryRunStrategy = dryRunStrategy client.ReleaseName = "release-name" client.Replace = true // Skip the name check - client.ClientOnly = !validate client.APIVersions = common.VersionSet(extraAPIs) client.IncludeCRDs = includeCrds rel, err := runInstall(args, client, valueOpts, out) @@ -196,14 +196,21 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addInstallFlags(cmd, f, client, valueOpts) f.StringArrayVarP(&showFiles, "show-only", "s", []string{}, "only show manifests rendered from the given templates") f.StringVar(&client.OutputDir, "output-dir", "", "writes the executed templates to files in output-dir instead of stdout") - f.BoolVar(&validate, "validate", false, "validate your manifests against the Kubernetes cluster you are currently pointing at. This is the same validation performed on an install") + f.BoolVar(&validate, "validate", false, "deprecated") + f.MarkDeprecated("validate", "use '--dry-run=server' instead") f.BoolVar(&includeCrds, "include-crds", false, "include CRDs in the templated output") f.BoolVar(&skipTests, "skip-tests", false, "skip tests from templated output") f.BoolVar(&client.IsUpgrade, "is-upgrade", false, "set .Release.IsUpgrade instead of .Release.IsInstall") f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") + f.String( + "dry-run", + "client", + `simulates the operation either client-side or server-side. Must be either: "client", or "server". '--dry-run=client simulates the operation client-side only and avoids cluster connections. '--dry-run=server' simulates/validates the operation on the server, requiring cluster connectivity.`) + f.Lookup("dry-run").NoOptDefVal = "unset" bindPostRenderFlag(cmd, &client.PostRenderer, settings) + cmd.MarkFlagsMutuallyExclusive("validate", "dry-run") return cmd } @@ -250,7 +257,7 @@ func createOrOpenFile(filename string, appendData bool) (*os.File, error) { } func ensureDirectoryForFile(file string) error { - baseDir := path.Dir(file) + baseDir := filepath.Dir(file) _, err := os.Stat(baseDir) if err != nil && !errors.Is(err, fs.ErrNotExist) { return err diff --git a/pkg/cmd/testdata/output/list-all-date-reversed.txt b/pkg/cmd/testdata/output/list-all-date-reversed.txt new file mode 100644 index 000000000..d185334a2 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-date-reversed.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-date.txt b/pkg/cmd/testdata/output/list-all-date.txt new file mode 100644 index 000000000..5e5f9efee --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-date.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-max.txt b/pkg/cmd/testdata/output/list-all-max.txt new file mode 100644 index 000000000..922896391 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-max.txt @@ -0,0 +1,2 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-no-headers.txt b/pkg/cmd/testdata/output/list-all-no-headers.txt new file mode 100644 index 000000000..33581d8c5 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-no-headers.txt @@ -0,0 +1,8 @@ +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-offset.txt b/pkg/cmd/testdata/output/list-all-offset.txt new file mode 100644 index 000000000..e17fd7b00 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-offset.txt @@ -0,0 +1,8 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-reverse.txt b/pkg/cmd/testdata/output/list-all-reverse.txt new file mode 100644 index 000000000..31bb3de96 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-reverse.txt @@ -0,0 +1,9 @@ +NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION +thanos default 1 2016-01-16 00:00:01 +0000 UTC pending-install chickadee-1.0.0 0.0.1 +starlord default 2 2016-01-16 00:00:01 +0000 UTC deployed chickadee-1.0.0 0.0.1 +rocket default 1 2016-01-16 00:00:02 +0000 UTC failed chickadee-1.0.0 0.0.1 +iguana default 2 2016-01-16 00:00:04 +0000 UTC deployed chickadee-1.0.0 0.0.1 +hummingbird default 1 2016-01-16 00:00:03 +0000 UTC deployed chickadee-1.0.0 0.0.1 +groot default 1 2016-01-16 00:00:01 +0000 UTC uninstalled chickadee-1.0.0 0.0.1 +gamora default 1 2016-01-16 00:00:01 +0000 UTC superseded chickadee-1.0.0 0.0.1 +drax default 1 2016-01-16 00:00:01 +0000 UTC uninstalling chickadee-1.0.0 0.0.1 diff --git a/pkg/cmd/testdata/output/list-all-short-json.txt b/pkg/cmd/testdata/output/list-all-short-json.txt new file mode 100644 index 000000000..6dac52c43 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-short-json.txt @@ -0,0 +1 @@ +["drax","gamora","groot","hummingbird","iguana","rocket","starlord","thanos"] diff --git a/pkg/cmd/testdata/output/list-all-short-yaml.txt b/pkg/cmd/testdata/output/list-all-short-yaml.txt new file mode 100644 index 000000000..2ae0e88ad --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-short-yaml.txt @@ -0,0 +1,8 @@ +- drax +- gamora +- groot +- hummingbird +- iguana +- rocket +- starlord +- thanos diff --git a/pkg/cmd/testdata/output/list-all-short.txt b/pkg/cmd/testdata/output/list-all-short.txt new file mode 100644 index 000000000..52871d8b4 --- /dev/null +++ b/pkg/cmd/testdata/output/list-all-short.txt @@ -0,0 +1,8 @@ +drax +gamora +groot +hummingbird +iguana +rocket +starlord +thanos diff --git a/pkg/cmd/testdata/output/version-client-shorthand.txt b/pkg/cmd/testdata/output/version-client-shorthand.txt deleted file mode 100644 index 3b138ae77..000000000 --- a/pkg/cmd/testdata/output/version-client-shorthand.txt +++ /dev/null @@ -1 +0,0 @@ -version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/pkg/cmd/testdata/output/version-client.txt b/pkg/cmd/testdata/output/version-client.txt deleted file mode 100644 index 3b138ae77..000000000 --- a/pkg/cmd/testdata/output/version-client.txt +++ /dev/null @@ -1 +0,0 @@ -version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""} diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 8e0ba9335..fc3c19e35 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -37,7 +37,8 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/downloader" "helm.sh/helm/v4/pkg/getter" - release "helm.sh/helm/v4/pkg/release/v1" + ri "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -100,7 +101,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } return noMoreArgsComp() }, - RunE: func(_ *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, args []string) error { client.Namespace = settings.Namespace() registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, @@ -110,12 +111,12 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - // This is for the case where "" is specifically passed in as a - // value. When there is no value passed in NoOptDefVal will be used - // and it is set to client. See addInstallFlags. - if client.DryRunOption == "" { - client.DryRunOption = "none" + dryRunStrategy, err := cmdGetDryRunFlagStrategy(cmd, false) + if err != nil { + return err } + client.DryRunStrategy = dryRunStrategy + // Fixes #7002 - Support reading values from STDIN for `upgrade` command // Must load values AFTER determining if we have to call install so that values loaded from stdin are not read twice if client.Install { @@ -132,8 +133,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.CreateNamespace = createNamespace instClient.ChartPathOptions = client.ChartPathOptions instClient.ForceReplace = client.ForceReplace - instClient.DryRun = client.DryRun - instClient.DryRunOption = client.DryRunOption + instClient.DryRunStrategy = client.DryRunStrategy instClient.DisableHooks = client.DisableHooks instClient.SkipCRDs = client.SkipCRDs instClient.Timeout = client.Timeout @@ -184,10 +184,6 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - // Validate dry-run flag value is one of the allowed values - if err := validateDryRunOptionFlag(client.DryRunOption); err != nil { - return err - } p := getter.All(settings) vals, err := valueOpts.MergeValues(p) @@ -275,9 +271,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&createNamespace, "create-namespace", false, "if --install is set, create the release namespace if not present") f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install") f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") - f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.") f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") - f.Lookup("dry-run").NoOptDefVal = "client" f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") @@ -304,6 +298,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources") + addDryRunFlag(cmd) addChartPathOptionsFlags(f, &client.ChartPathOptions) addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt) @@ -325,6 +320,11 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return cmd } -func isReleaseUninstalled(versions []*release.Release) bool { - return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled +func isReleaseUninstalled(versionsi []ri.Releaser) bool { + versions, err := releaseListToV1List(versionsi) + if err != nil { + slog.Error("cannot convert release list to v1 release list", "error", err) + return false + } + return len(versions) > 0 && versions[len(versions)-1].Info.Status == common.StatusUninstalled } diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index 9b17f187d..fd715a1fa 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -28,6 +28,7 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -82,7 +83,7 @@ func TestUpgradeCmd(t *testing.T) { badDepsPath := "testdata/testcharts/chart-bad-requirements" presentDepsPath := "testdata/testcharts/chart-with-subchart-update" - relWithStatusMock := func(n string, v int, ch *chart.Chart, status release.Status) *release.Release { + relWithStatusMock := func(n string, v int, ch *chart.Chart, status rcommon.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch, Status: status}) } @@ -173,20 +174,20 @@ func TestUpgradeCmd(t *testing.T) { name: "upgrade a failed release", cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), golden: "output/upgrade.txt", - rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusFailed)}, + rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, rcommon.StatusFailed)}, }, { name: "upgrade a pending install release", cmd: fmt.Sprintf("upgrade funny-bunny '%s'", chartPath), golden: "output/upgrade-with-pending-install.txt", wantError: true, - rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusPendingInstall)}, + rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, rcommon.StatusPendingInstall)}, }, { name: "install a previously uninstalled release with '--keep-history' using 'upgrade --install'", cmd: fmt.Sprintf("upgrade funny-bunny -i '%s'", chartPath), golden: "output/upgrade-uninstalled-with-keep-history.txt", - rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusUninstalled)}, + rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, rcommon.StatusUninstalled)}, }, } runTestCmd(t, tests) @@ -208,7 +209,11 @@ func TestUpgradeWithValue(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 4) + updatedReli, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -235,7 +240,11 @@ func TestUpgradeWithStringValue(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 4) + updatedReli, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -263,7 +272,11 @@ func TestUpgradeInstallWithSubchartNotes(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - upgradedRel, err := store.Get(releaseName, 2) + upgradedReli, err := store.Get(releaseName, 2) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + upgradedRel, err := releaserToV1Release(upgradedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -295,7 +308,11 @@ func TestUpgradeWithValuesFile(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 4) + updatedReli, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -328,7 +345,11 @@ func TestUpgradeWithValuesFromStdin(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 4) + updatedReli, err := store.Get(releaseName, 4) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -358,7 +379,11 @@ func TestUpgradeInstallWithValuesFromStdin(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 1) + updatedReli, err := store.Get(releaseName, 1) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } @@ -463,7 +488,11 @@ func TestUpgradeInstallWithLabels(t *testing.T) { t.Errorf("unexpected error, got '%v'", err) } - updatedRel, err := store.Get(releaseName, 1) + updatedReli, err := store.Get(releaseName, 1) + if err != nil { + t.Errorf("unexpected error, got '%v'", err) + } + updatedRel, err := releaserToV1Release(updatedReli) if err != nil { t.Errorf("unexpected error, got '%v'", err) } diff --git a/pkg/cmd/verify.go b/pkg/cmd/verify.go index 50f1ea914..3b7574386 100644 --- a/pkg/cmd/verify.go +++ b/pkg/cmd/verify.go @@ -53,12 +53,12 @@ func newVerifyCmd(out io.Writer) *cobra.Command { return noMoreArgsComp() }, RunE: func(_ *cobra.Command, args []string) error { - err := client.Run(args[0]) + result, err := client.Run(args[0]) if err != nil { return err } - fmt.Fprint(out, client.Out) + fmt.Fprint(out, result) return nil }, diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 0211716fe..80fb0d712 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -62,7 +62,7 @@ func newVersionCmd(out io.Writer) *cobra.Command { cmd := &cobra.Command{ Use: "version", - Short: "print the client version information", + Short: "print the helm version information", Long: versionDesc, Args: require.NoArgs, ValidArgsFunction: noMoreArgsCompFunc, @@ -73,8 +73,6 @@ func newVersionCmd(out io.Writer) *cobra.Command { f := cmd.Flags() f.BoolVar(&o.short, "short", false, "print the version number") f.StringVar(&o.template, "template", "", "template for version string format") - f.BoolP("client", "c", true, "display client version information") - f.MarkHidden("client") return cmd } diff --git a/pkg/cmd/version_test.go b/pkg/cmd/version_test.go index c06c72309..9551de767 100644 --- a/pkg/cmd/version_test.go +++ b/pkg/cmd/version_test.go @@ -32,14 +32,6 @@ func TestVersion(t *testing.T) { name: "template", cmd: "version --template='Version: {{.Version}}'", golden: "output/version-template.txt", - }, { - name: "client", - cmd: "version --client", - golden: "output/version-client.txt", - }, { - name: "client shorthand", - cmd: "version -c", - golden: "output/version-client-shorthand.txt", }} runTestCmd(t, tests) } diff --git a/pkg/kube/client.go b/pkg/kube/client.go index 26ba7abfc..30c014ad5 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -86,6 +86,8 @@ type Client struct { kubeClient kubernetes.Interface } +var _ Interface = (*Client)(nil) + type WaitStrategy string const ( @@ -271,10 +273,6 @@ func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) ( return nil, fmt.Errorf("invalid client create option(s): %w", err) } - if createOptions.forceConflicts && !createOptions.serverSideApply { - return nil, fmt.Errorf("invalid operation: force conflicts can only be used with server-side apply") - } - makeCreateApplyFunc := func() func(target *resource.Info) error { if createOptions.serverSideApply { slog.Debug("using server-side apply for resource creation", slog.Bool("forceConflicts", createOptions.forceConflicts), slog.Bool("dryRun", createOptions.dryRun), slog.String("fieldValidationDirective", string(createOptions.fieldValidationDirective))) @@ -769,29 +767,17 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate return c.update(originals, targets, makeUpdateApplyFunc()) } -// Delete deletes Kubernetes resources specified in the resources list with -// background cascade deletion. It will attempt to delete all resources even -// if one or more fail and collect any errors. All successfully deleted items -// will be returned in the `Deleted` ResourceList that is part of the result. -func (c *Client) Delete(resources ResourceList) (*Result, []error) { - return deleteResources(resources, metav1.DeletePropagationBackground) -} - // Delete deletes Kubernetes resources specified in the resources list with // given deletion propagation policy. It will attempt to delete all resources even // if one or more fail and collect any errors. All successfully deleted items // will be returned in the `Deleted` ResourceList that is part of the result. -func (c *Client) DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) { - return deleteResources(resources, policy) -} - -func deleteResources(resources ResourceList, propagation metav1.DeletionPropagation) (*Result, []error) { +func (c *Client) Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) { var errs []error res := &Result{} mtx := sync.Mutex{} err := perform(resources, func(target *resource.Info) error { slog.Debug("starting delete resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind) - err := deleteResource(target, propagation) + err := deleteResource(target, policy) if err == nil || apierrors.IsNotFound(err) { if err != nil { slog.Debug("ignoring delete failure", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) @@ -1031,7 +1017,7 @@ func patchResourceClientSide(original runtime.Object, target *resource.Info, thr } // upgradeClientSideFieldManager is simply a wrapper around csaupgrade.UpgradeManagedFields -// that ugrade CSA managed fields to SSA apply +// that upgrade CSA managed fields to SSA apply // see: https://github.com/kubernetes/kubernetes/pull/112905 func upgradeClientSideFieldManager(info *resource.Info, dryRun bool, fieldValidationDirective FieldValidationDirective) (bool, error) { diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index a8a8668c7..d8c0fba5f 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -142,7 +142,7 @@ func NewRequestResponseLogClient(t *testing.T, cb RoundTripperTestFunc) RequestR } // RequestResponseLogClient is a test client that logs requests and responses -// Satifying http.RoundTripper interface, it can be used to mock HTTP requests in tests. +// Satisfying http.RoundTripper interface, it can be used to mock HTTP requests in tests. // Forwarding requests to a callback function (cb) that can be used to simulate server responses. type RequestResponseLogClient struct { t *testing.T @@ -816,7 +816,7 @@ func TestWaitDelete(t *testing.T) { if len(result.Created) != 1 { t.Errorf("expected 1 resource created, got %d", len(result.Created)) } - if _, err := c.Delete(resources); err != nil { + if _, err := c.Delete(resources, metav1.DeletePropagationBackground); err != nil { t.Fatal(err) } @@ -855,7 +855,7 @@ func TestReal(t *testing.T) { t.Fatal(err) } - if _, errs := c.Delete(resources); errs != nil { + if _, errs := c.Delete(resources, metav1.DeletePropagationBackground); errs != nil { t.Fatal(errs) } @@ -864,7 +864,7 @@ func TestReal(t *testing.T) { t.Fatal(err) } // ensures that delete does not fail if a resource is not found - if _, errs := c.Delete(resources); errs != nil { + if _, errs := c.Delete(resources, metav1.DeletePropagationBackground); errs != nil { t.Fatal(errs) } } diff --git a/pkg/kube/fake/failing_kube_client.go b/pkg/kube/fake/failing_kube_client.go index 154419ebf..f340c045f 100644 --- a/pkg/kube/fake/failing_kube_client.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -33,22 +33,23 @@ import ( // delegates all its calls to `PrintingKubeClient` type FailingKubeClient struct { PrintingKubeClient - CreateError error - GetError error - DeleteError error - DeleteWithPropagationError error - UpdateError error - BuildError error - BuildTableError error - ConnectionError error - BuildDummy bool - DummyResources kube.ResourceList - BuildUnstructuredError error - WaitError error - WaitForDeleteError error - WatchUntilReadyError error - WaitDuration time.Duration -} + CreateError error + GetError error + DeleteError error + UpdateError error + BuildError error + BuildTableError error + ConnectionError error + BuildDummy bool + DummyResources kube.ResourceList + BuildUnstructuredError error + WaitError error + WaitForDeleteError error + WatchUntilReadyError error + WaitDuration time.Duration +} + +var _ kube.Interface = &FailingKubeClient{} // FailingKubeWaiter implements kube.Waiter for testing purposes. // It also has additional errors you can set to fail different functions, otherwise it delegates all its calls to `PrintingKubeWaiter` @@ -102,11 +103,12 @@ func (f *FailingKubeWaiter) WaitForDelete(resources kube.ResourceList, d time.Du } // Delete returns the configured error if set or prints -func (f *FailingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, []error) { +func (f *FailingKubeClient) Delete(resources kube.ResourceList, deletionPropagation metav1.DeletionPropagation) (*kube.Result, []error) { if f.DeleteError != nil { return nil, []error{f.DeleteError} } - return f.PrintingKubeClient.Delete(resources) + + return f.PrintingKubeClient.Delete(resources, deletionPropagation) } // WatchUntilReady returns the configured error if set or prints @@ -147,14 +149,6 @@ func (f *FailingKubeClient) BuildTable(r io.Reader, _ bool) (kube.ResourceList, return f.PrintingKubeClient.BuildTable(r, false) } -// DeleteWithPropagationPolicy returns the configured error if set or prints -func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) { - if f.DeleteWithPropagationError != nil { - return nil, []error{f.DeleteWithPropagationError} - } - return f.PrintingKubeClient.DeleteWithPropagationPolicy(resources, policy) -} - func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) { waiter, _ := f.PrintingKubeClient.GetWaiter(ws) printingKubeWaiter, _ := waiter.(*PrintingKubeWaiter) diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index 16c93615a..a7aad1dac 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -43,6 +43,8 @@ type PrintingKubeWaiter struct { LogOutput io.Writer } +var _ kube.Interface = &PrintingKubeClient{} + // IsReachable checks if the cluster is reachable func (p *PrintingKubeClient) IsReachable() error { return nil @@ -89,7 +91,7 @@ func (p *PrintingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time // Delete implements KubeClient delete. // // It only prints out the content to be deleted. -func (p *PrintingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, []error) { +func (p *PrintingKubeClient) Delete(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) { _, err := io.Copy(p.Out, bufferize(resources)) if err != nil { return nil, []error{err} diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go index 7339ae0ff..cc934ae1e 100644 --- a/pkg/kube/interface.go +++ b/pkg/kube/interface.go @@ -29,11 +29,18 @@ import ( // // A KubernetesClient must be concurrency safe. type Interface interface { + // Get details of deployed resources. + // The first argument is a list of resources to get. The second argument + // specifies if related pods should be fetched. For example, the pods being + // managed by a deployment. + Get(resources ResourceList, related bool) (map[string][]runtime.Object, error) + // Create creates one or more resources. Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) - // Delete destroys one or more resources. - Delete(resources ResourceList) (*Result, []error) + // Delete destroys one or more resources using the specified deletion propagation policy. + // The 'policy' parameter determines how child resources are handled during deletion. + Delete(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) // Update updates one or more resources or creates the resource // if it doesn't exist. @@ -51,6 +58,23 @@ type Interface interface { // Get Waiter gets the Kube.Waiter GetWaiter(ws WaitStrategy) (Waiter, error) + + // GetPodList lists all pods that match the specified listOptions + GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error) + + // OutputContainerLogsForPodList outputs the logs for a pod list + OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error + + // BuildTable creates a resource list from a Reader. This differs from + // Interface.Build() in that a table kind is returned. A table is useful + // if you want to use a printer to display the information. + // + // Reader must contain a YAML stream (one or more YAML documents separated + // by "\n---\n") + // + // Validates against OpenAPI schema if validate is true. + // TODO Helm 4: Integrate into Build with an argument + BuildTable(reader io.Reader, validate bool) (ResourceList, error) } // Waiter defines methods related to waiting for resource states. @@ -75,49 +99,3 @@ type Waiter interface { // error. WatchUntilReady(resources ResourceList, timeout time.Duration) error } - -// InterfaceLogs was introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceLogs and integrate its method(s) into the Interface. -type InterfaceLogs interface { - // GetPodList list all pods that match the specified listOptions - GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error) - - // OutputContainerLogsForPodList output the logs for a pod list - OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error -} - -// InterfaceDeletionPropagation is introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceDeletionPropagation and integrate its method(s) into the Interface. -type InterfaceDeletionPropagation interface { - // DeleteWithPropagationPolicy destroys one or more resources. The deletion propagation is handled as per the given deletion propagation value. - DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) -} - -// InterfaceResources is introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceResources and integrate its method(s) into the Interface. -type InterfaceResources interface { - // Get details of deployed resources. - // The first argument is a list of resources to get. The second argument - // specifies if related pods should be fetched. For example, the pods being - // managed by a deployment. - Get(resources ResourceList, related bool) (map[string][]runtime.Object, error) - - // BuildTable creates a resource list from a Reader. This differs from - // Interface.Build() in that a table kind is returned. A table is useful - // if you want to use a printer to display the information. - // - // Reader must contain a YAML stream (one or more YAML documents separated - // by "\n---\n") - // - // Validates against OpenAPI schema if validate is true. - // TODO Helm 4: Integrate into Build with an argument - BuildTable(reader io.Reader, validate bool) (ResourceList, error) -} - -var _ Interface = (*Client)(nil) -var _ InterfaceLogs = (*Client)(nil) -var _ InterfaceDeletionPropagation = (*Client)(nil) -var _ InterfaceResources = (*Client)(nil) diff --git a/pkg/kube/ready.go b/pkg/kube/ready.go index 7a06c72f9..42e327bdd 100644 --- a/pkg/kube/ready.go +++ b/pkg/kube/ready.go @@ -455,5 +455,8 @@ func getPods(ctx context.Context, client kubernetes.Interface, namespace, select list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: selector, }) - return list.Items, err + if err != nil { + return nil, fmt.Errorf("failed to list pods: %w", err) + } + return list.Items, nil } diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index 3ffad2765..57af1ad42 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -25,9 +25,9 @@ import ( "os" "strings" - "golang.org/x/crypto/openpgp" //nolint - "golang.org/x/crypto/openpgp/clearsign" //nolint - "golang.org/x/crypto/openpgp/packet" //nolint + "github.com/ProtonMail/go-crypto/openpgp" //nolint + "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint + "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint "sigs.k8s.io/yaml" ) @@ -281,8 +281,9 @@ func (s *Signatory) Verify(archiveData, provData []byte, filename string) (*Veri func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) { return openpgp.CheckDetachedSignature( s.KeyRing, - bytes.NewBuffer(block.Bytes), + bytes.NewReader(block.Bytes), block.ArmoredSignature.Body, + &defaultPGPConfig, ) } diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 4f2fc7298..1985e9eea 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -24,7 +24,10 @@ import ( "strings" "testing" - pgperrors "golang.org/x/crypto/openpgp/errors" //nolint + pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" //nolint + "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" "helm.sh/helm/v4/pkg/chart/v2/loader" @@ -59,6 +62,9 @@ const ( // testTamperedSigBlock is a tampered copy of msgblock.yaml.asc testTamperedSigBlock = "testdata/msgblock.yaml.tampered" + // testMixedKeyring points to a keyring containing RSA and ed25519 keys. + testMixedKeyring = "testdata/helm-mixed-keyring.pub" + // testSumfile points to a SHA256 sum generated by an external tool. // We always want to validate against an external tool's representation to // verify that we haven't done something stupid. This file was generated @@ -266,6 +272,56 @@ func TestClearSign(t *testing.T) { } } +func TestMixedKeyringRSASigningAndVerification(t *testing.T) { + signer, err := NewFromFiles(testKeyfile, testMixedKeyring) + require.NoError(t, err) + + require.NotEmpty(t, signer.KeyRing, "expected signer keyring to be loaded") + + hasEdDSA := false + for _, entity := range signer.KeyRing { + if entity.PrimaryKey != nil && entity.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA { + hasEdDSA = true + break + } + + for _, subkey := range entity.Subkeys { + if subkey.PublicKey != nil && subkey.PublicKey.PubKeyAlgo == packet.PubKeyAlgoEdDSA { + hasEdDSA = true + break + } + } + + if hasEdDSA { + break + } + } + + assert.True(t, hasEdDSA, "expected %s to include an Ed25519 public key", testMixedKeyring) + + require.NotNil(t, signer.Entity, "expected signer entity to be loaded") + require.NotNil(t, signer.Entity.PrivateKey, "expected signer private key to be loaded") + assert.Equal(t, packet.PubKeyAlgoRSA, signer.Entity.PrivateKey.PubKeyAlgo, "expected RSA key") + + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + archiveData, err := os.ReadFile(testChartfile) + require.NoError(t, err) + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) + require.NoError(t, err, "failed to sign chart") + + verification, err := signer.Verify(archiveData, []byte(sig), filepath.Base(testChartfile)) + require.NoError(t, err, "failed to verify chart signature") + + require.NotNil(t, verification.SignedBy, "expected verification to include signer") + require.NotNil(t, verification.SignedBy.PrimaryKey, "expected verification to include signer primary key") + assert.Equal(t, packet.PubKeyAlgoRSA, verification.SignedBy.PrimaryKey.PubKeyAlgo, "expected verification to report RSA key") + + _, ok := verification.SignedBy.Identities[testKeyName] + assert.True(t, ok, "expected verification to be signed by %q", testKeyName) +} + // failSigner always fails to sign and returns an error type failSigner struct{} diff --git a/pkg/provenance/testdata/helm-mixed-keyring.pub b/pkg/provenance/testdata/helm-mixed-keyring.pub new file mode 100644 index 000000000..7985bd20f Binary files /dev/null and b/pkg/provenance/testdata/helm-mixed-keyring.pub differ diff --git a/pkg/registry/util.go b/pkg/registry/chart.go similarity index 61% rename from pkg/registry/util.go rename to pkg/registry/chart.go index 6071c66c3..b00fc616d 100644 --- a/pkg/registry/util.go +++ b/pkg/registry/chart.go @@ -18,18 +18,12 @@ package registry // import "helm.sh/helm/v4/pkg/registry" import ( "bytes" - "fmt" - "io" - "net/http" - "slices" "strings" "time" - "helm.sh/helm/v4/internal/tlsutil" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" - "github.com/Masterminds/semver/v3" ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) @@ -38,52 +32,6 @@ var immutableOciAnnotations = []string{ ocispec.AnnotationTitle, } -// IsOCI determines whether a URL is to be treated as an OCI URL -func IsOCI(url string) bool { - return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) -} - -// ContainsTag determines whether a tag is found in a provided list of tags -func ContainsTag(tags []string, tag string) bool { - return slices.Contains(tags, tag) -} - -func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) { - var constraint *semver.Constraints - if versionString == "" { - // If string is empty, set wildcard constraint - constraint, _ = semver.NewConstraint("*") - } else { - // when customer inputs specific version, check whether there's an exact match first - for _, v := range tags { - if versionString == v { - return v, nil - } - } - - // Otherwise set constraint to the string given - var err error - constraint, err = semver.NewConstraint(versionString) - if err != nil { - return "", err - } - } - - // Otherwise try to find the first available version matching the string, - // in case it is a constraint - for _, v := range tags { - test, err := semver.NewVersion(v) - if err != nil { - continue - } - if constraint.Check(test) { - return v, nil - } - } - - return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString) -} - // extractChartMeta is used to extract a chart metadata from a byte array func extractChartMeta(chartData []byte) (*chart.Metadata, error) { ch, err := loader.LoadArchive(bytes.NewReader(chartData)) @@ -93,35 +41,6 @@ func extractChartMeta(chartData []byte) (*chart.Metadata, error) { return ch.Metadata, nil } -// NewRegistryClientWithTLS is a helper function to create a new registry client with TLS enabled. -func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSverify bool, registryConfig string, debug bool) (*Client, error) { - tlsConf, err := tlsutil.NewTLSConfig( - tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify), - tlsutil.WithCertKeyPairFiles(certFile, keyFile), - tlsutil.WithCAFile(caFile), - ) - if err != nil { - return nil, fmt.Errorf("can't create TLS config for client: %s", err) - } - // Create a new registry client - registryClient, err := NewClient( - ClientOptDebug(debug), - ClientOptEnableCache(true), - ClientOptWriter(out), - ClientOptCredentialsFile(registryConfig), - ClientOptHTTPClient(&http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConf, - Proxy: http.ProxyFromEnvironment, - }, - }), - ) - if err != nil { - return nil, err - } - return registryClient, nil -} - // generateOCIAnnotations will generate OCI annotations to include within the OCI manifest func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string { @@ -202,5 +121,4 @@ func addToMap(inputMap map[string]string, newKey string, newValue string) map[st } return inputMap - } diff --git a/pkg/registry/util_test.go b/pkg/registry/chart_test.go similarity index 100% rename from pkg/registry/util_test.go rename to pkg/registry/chart_test.go diff --git a/pkg/registry/reference.go b/pkg/registry/reference.go index b5677761d..bd0974e69 100644 --- a/pkg/registry/reference.go +++ b/pkg/registry/reference.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "fmt" "strings" "oras.land/oras-go/v2/registry" @@ -76,3 +77,8 @@ func (r *reference) String() string { } return r.orasReference.String() } + +// IsOCI determines whether a URL is to be treated as an OCI URL +func IsOCI(url string) bool { + return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) +} diff --git a/pkg/registry/tag.go b/pkg/registry/tag.go new file mode 100644 index 000000000..701701d7b --- /dev/null +++ b/pkg/registry/tag.go @@ -0,0 +1,59 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry // import "helm.sh/helm/v4/pkg/registry" + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" +) + +func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) { + var constraint *semver.Constraints + if versionString == "" { + // If string is empty, set wildcard constraint + constraint, _ = semver.NewConstraint("*") + } else { + // when customer inputs specific version, check whether there's an exact match first + for _, v := range tags { + if versionString == v { + return v, nil + } + } + + // Otherwise set constraint to the string given + var err error + constraint, err = semver.NewConstraint(versionString) + if err != nil { + return "", err + } + } + + // Otherwise try to find the first available version matching the string, + // in case it is a constraint + for _, v := range tags { + test, err := semver.NewVersion(v) + if err != nil { + continue + } + if constraint.Check(test) { + return v, nil + } + } + + return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString) +} diff --git a/pkg/registry/tag_test.go b/pkg/registry/tag_test.go new file mode 100644 index 000000000..09f0f12ea --- /dev/null +++ b/pkg/registry/tag_test.go @@ -0,0 +1,122 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "strings" + "testing" +) + +func TestGetTagMatchingVersionOrConstraint_ExactMatch(t *testing.T) { + tags := []string{"1.0.0", "1.2.3", "2.0.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Fatalf("expected exact match '1.2.3', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_EmptyVersionWildcard(t *testing.T) { + // Includes a non-semver tag which should be skipped + tags := []string{"latest", "0.9.0", "1.0.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should pick the first valid semver tag in order, which is 0.9.0 + if got != "0.9.0" { + t.Fatalf("expected '0.9.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_ConstraintRange(t *testing.T) { + tags := []string{"0.5.0", "1.0.0", "1.1.0", "2.0.0"} + + // Caret range + got, err := GetTagMatchingVersionOrConstraint(tags, "^1.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { // first match in order + t.Fatalf("expected '1.0.0', got %q", got) + } + + // Compound range + got, err = GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { + t.Fatalf("expected '1.0.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_InvalidConstraint(t *testing.T) { + tags := []string{"1.0.0"} + _, err := GetTagMatchingVersionOrConstraint(tags, ">a1") + if err == nil { + t.Fatalf("expected error for invalid constraint") + } +} + +func TestGetTagMatchingVersionOrConstraint_NoMatches(t *testing.T) { + tags := []string{"0.1.0", "0.2.0"} + _, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0") + if err == nil { + t.Fatalf("expected error when no tags match") + } + if !strings.Contains(err.Error(), ">=1.0.0") { + t.Fatalf("expected error to contain version string, got: %v", err) + } +} + +func TestGetTagMatchingVersionOrConstraint_SkipsNonSemverTags(t *testing.T) { + tags := []string{"alpha", "1.0.0", "beta", "1.1.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { + t.Fatalf("expected '1.0.0', got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_OrderMatters_FirstMatchReturned(t *testing.T) { + // Both 1.2.0 and 1.3.0 satisfy >=1.2.0 <2.0.0, but the function returns the first in input order + tags := []string{"1.3.0", "1.2.0"} + got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.2.0 <2.0.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.3.0" { + t.Fatalf("expected '1.3.0' (first satisfying tag), got %q", got) + } +} + +func TestGetTagMatchingVersionOrConstraint_ExactMatchHasPrecedence(t *testing.T) { + // Exact match should be returned even if another earlier tag would match the parsed constraint + tags := []string{"1.3.0", "1.2.3"} + got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Fatalf("expected exact match '1.2.3', got %q", got) + } +} diff --git a/pkg/release/common.go b/pkg/release/common.go new file mode 100644 index 000000000..d33c96646 --- /dev/null +++ b/pkg/release/common.go @@ -0,0 +1,116 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package release + +import ( + "errors" + "fmt" + "time" + + "helm.sh/helm/v4/pkg/chart" + v1release "helm.sh/helm/v4/pkg/release/v1" +) + +var NewAccessor func(rel Releaser) (Accessor, error) = newDefaultAccessor //nolint:revive + +var NewHookAccessor func(rel Hook) (HookAccessor, error) = newDefaultHookAccessor //nolint:revive + +func newDefaultAccessor(rel Releaser) (Accessor, error) { + switch v := rel.(type) { + case v1release.Release: + return &v1Accessor{&v}, nil + case *v1release.Release: + return &v1Accessor{v}, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} + +func newDefaultHookAccessor(hook Hook) (HookAccessor, error) { + switch h := hook.(type) { + case v1release.Hook: + return &v1HookAccessor{&h}, nil + case *v1release.Hook: + return &v1HookAccessor{h}, nil + default: + return nil, errors.New("unsupported release hook type") + } +} + +type v1Accessor struct { + rel *v1release.Release +} + +func (a *v1Accessor) Name() string { + return a.rel.Name +} + +func (a *v1Accessor) Namespace() string { + return a.rel.Namespace +} + +func (a *v1Accessor) Version() int { + return a.rel.Version +} + +func (a *v1Accessor) Hooks() []Hook { + var hooks = make([]Hook, len(a.rel.Hooks)) + for i, h := range a.rel.Hooks { + hooks[i] = h + } + return hooks +} + +func (a *v1Accessor) Manifest() string { + return a.rel.Manifest +} + +func (a *v1Accessor) Notes() string { + return a.rel.Info.Notes +} + +func (a *v1Accessor) Labels() map[string]string { + return a.rel.Labels +} + +func (a *v1Accessor) Chart() chart.Charter { + return a.rel.Chart +} + +func (a *v1Accessor) Status() string { + return a.rel.Info.Status.String() +} + +func (a *v1Accessor) ApplyMethod() string { + return a.rel.ApplyMethod +} + +func (a *v1Accessor) DeployedAt() time.Time { + return a.rel.Info.LastDeployed +} + +type v1HookAccessor struct { + hook *v1release.Hook +} + +func (a *v1HookAccessor) Path() string { + return a.hook.Path +} + +func (a *v1HookAccessor) Manifest() string { + return a.hook.Manifest +} diff --git a/pkg/release/v1/status.go b/pkg/release/common/status.go similarity index 99% rename from pkg/release/v1/status.go rename to pkg/release/common/status.go index 8d6459013..fd5010301 100644 --- a/pkg/release/v1/status.go +++ b/pkg/release/common/status.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package v1 +package common // Status is the status of a release type Status string diff --git a/pkg/release/common_test.go b/pkg/release/common_test.go new file mode 100644 index 000000000..e9f8d364a --- /dev/null +++ b/pkg/release/common_test.go @@ -0,0 +1,65 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package release + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/release/common" + rspb "helm.sh/helm/v4/pkg/release/v1" +) + +func TestNewDefaultAccessor(t *testing.T) { + // Testing the default implementation rather than NewAccessor which can be + // overridden by developers. + is := assert.New(t) + + // Create release + info := &rspb.Info{Status: common.StatusDeployed, LastDeployed: time.Now().Add(1000)} + labels := make(map[string]string) + labels["foo"] = "bar" + rel := &rspb.Release{ + Name: "happy-cats", + Version: 2, + Info: info, + Labels: labels, + Namespace: "default", + ApplyMethod: "csa", + } + + // newDefaultAccessor should not be called directly Instead, NewAccessor should be + // called and it will call NewDefaultAccessor. NewAccessor can be changed to a + // non-default accessor by a user so the test calls the default implementation. + // The accessor provides a means to access data on resources that are different types + // but have the same interface. Instead of properties, methods are used to access + // information. Structs with properties are useful in Go when it comes to marshalling + // and unmarshalling data (e.g. coming and going from JSON or YAML). But, structs + // can't be used with interfaces. The accessors enable access to the underlying data + // in a manner that works with Go interfaces. + accessor, err := newDefaultAccessor(rel) + is.NoError(err) + + // Verify information + is.Equal(rel.Name, accessor.Name()) + is.Equal(rel.Namespace, accessor.Namespace()) + is.Equal(rel.Version, accessor.Version()) + is.Equal(rel.ApplyMethod, accessor.ApplyMethod()) + is.Equal(rel.Labels, accessor.Labels()) +} diff --git a/pkg/release/interfaces.go b/pkg/release/interfaces.go new file mode 100644 index 000000000..aaa5a756f --- /dev/null +++ b/pkg/release/interfaces.go @@ -0,0 +1,46 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package release + +import ( + "time" + + "helm.sh/helm/v4/pkg/chart" +) + +type Releaser interface{} + +type Hook interface{} + +type Accessor interface { + Name() string + Namespace() string + Version() int + Hooks() []Hook + Manifest() string + Notes() string + Labels() map[string]string + Chart() chart.Charter + Status() string + ApplyMethod() string + DeployedAt() time.Time +} + +type HookAccessor interface { + Path() string + Manifest() string +} diff --git a/pkg/release/v1/responses.go b/pkg/release/responses.go similarity index 92% rename from pkg/release/v1/responses.go rename to pkg/release/responses.go index 2a5608c67..6e0a0eaec 100644 --- a/pkg/release/v1/responses.go +++ b/pkg/release/responses.go @@ -13,12 +13,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -package v1 +package release // UninstallReleaseResponse represents a successful response to an uninstall request. type UninstallReleaseResponse struct { // Release is the release that was marked deleted. - Release *Release `json:"release,omitempty"` + Release Releaser `json:"release,omitempty"` // Info is an uninstall message Info string `json:"info,omitempty"` } diff --git a/pkg/release/v1/hook.go b/pkg/release/v1/hook.go index b7d3c3992..f0a370c15 100644 --- a/pkg/release/v1/hook.go +++ b/pkg/release/v1/hook.go @@ -17,6 +17,7 @@ limitations under the License. package v1 import ( + "encoding/json" "time" ) @@ -120,3 +121,69 @@ const ( // String converts a hook phase to a printable string func (x HookPhase) String() string { return string(x) } + +// hookExecutionJSON is used for custom JSON marshaling/unmarshaling +type hookExecutionJSON struct { + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Phase HookPhase `json:"phase"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (h *HookExecution) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"started_at", "completed_at"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp hookExecutionJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to HookExecution struct + if tmp.StartedAt != nil { + h.StartedAt = *tmp.StartedAt + } + if tmp.CompletedAt != nil { + h.CompletedAt = *tmp.CompletedAt + } + h.Phase = tmp.Phase + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (h HookExecution) MarshalJSON() ([]byte, error) { + tmp := hookExecutionJSON{ + Phase: h.Phase, + } + + if !h.StartedAt.IsZero() { + tmp.StartedAt = &h.StartedAt + } + if !h.CompletedAt.IsZero() { + tmp.CompletedAt = &h.CompletedAt + } + + return json.Marshal(tmp) +} diff --git a/pkg/release/v1/hook_test.go b/pkg/release/v1/hook_test.go new file mode 100644 index 000000000..cea2568bc --- /dev/null +++ b/pkg/release/v1/hook_test.go @@ -0,0 +1,231 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHookExecutionMarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + exec HookExecution + expected string + }{ + { + name: "all fields populated", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + }, + { + name: "only phase", + exec: HookExecution{ + Phase: HookPhaseRunning, + }, + expected: `{"phase":"Running"}`, + }, + { + name: "with started time only", + exec: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + }, + { + name: "failed phase", + exec: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + expected: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + }, + { + name: "unknown phase", + exec: HookExecution{ + Phase: HookPhaseUnknown, + }, + expected: `{"phase":"Unknown"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.exec) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestHookExecutionUnmarshalJSON(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected HookExecution + wantErr bool + }{ + { + name: "all fields populated", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Succeeded"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + }, + }, + { + name: "only phase", + input: `{"phase":"Running"}`, + expected: HookExecution{ + Phase: HookPhaseRunning, + }, + }, + { + name: "empty string time fields", + input: `{"started_at":"","completed_at":"","phase":"Succeeded"}`, + expected: HookExecution{ + Phase: HookPhaseSucceeded, + }, + }, + { + name: "missing time fields", + input: `{"phase":"Failed"}`, + expected: HookExecution{ + Phase: HookPhaseFailed, + }, + }, + { + name: "null time fields", + input: `{"started_at":null,"completed_at":null,"phase":"Unknown"}`, + expected: HookExecution{ + Phase: HookPhaseUnknown, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "with started time only", + input: `{"started_at":"2025-10-08T12:00:00Z","phase":"Running"}`, + expected: HookExecution{ + StartedAt: started, + Phase: HookPhaseRunning, + }, + }, + { + name: "failed phase with times", + input: `{"started_at":"2025-10-08T12:00:00Z","completed_at":"2025-10-08T12:05:00Z","phase":"Failed"}`, + expected: HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseFailed, + }, + }, + { + name: "invalid time format", + input: `{"started_at":"invalid-time","phase":"Running"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var exec HookExecution + err := json.Unmarshal([]byte(tt.input), &exec) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.StartedAt.Unix(), exec.StartedAt.Unix()) + assert.Equal(t, tt.expected.CompletedAt.Unix(), exec.CompletedAt.Unix()) + assert.Equal(t, tt.expected.Phase, exec.Phase) + }) + } +} + +func TestHookExecutionRoundTrip(t *testing.T) { + started := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + completed := time.Date(2025, 10, 8, 12, 5, 0, 0, time.UTC) + + original := HookExecution{ + StartedAt: started, + CompletedAt: completed, + Phase: HookPhaseSucceeded, + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded HookExecution + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.StartedAt.Unix(), decoded.StartedAt.Unix()) + assert.Equal(t, original.CompletedAt.Unix(), decoded.CompletedAt.Unix()) + assert.Equal(t, original.Phase, decoded.Phase) +} + +func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"started_at":"","completed_at":"","phase":"Succeeded"}` + + var exec HookExecution + err := json.Unmarshal([]byte(input), &exec) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, exec.StartedAt.IsZero()) + assert.True(t, exec.CompletedAt.IsZero()) + assert.Equal(t, HookPhaseSucceeded, exec.Phase) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&exec) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted + assert.NotContains(t, result, "started_at") + assert.NotContains(t, result, "completed_at") + assert.Equal(t, "Succeeded", result["phase"]) +} diff --git a/pkg/release/v1/info.go b/pkg/release/v1/info.go index cef7e9960..f895fdf6c 100644 --- a/pkg/release/v1/info.go +++ b/pkg/release/v1/info.go @@ -16,8 +16,11 @@ limitations under the License. package v1 import ( + "encoding/json" "time" + "helm.sh/helm/v4/pkg/release/common" + "k8s.io/apimachinery/pkg/runtime" ) @@ -32,9 +35,91 @@ type Info struct { // Description is human-friendly "log entry" about this release. Description string `json:"description,omitempty"` // Status is the current state of the release - Status Status `json:"status,omitempty"` + Status common.Status `json:"status,omitempty"` // Contains the rendered templates/NOTES.txt if available Notes string `json:"notes,omitempty"` // Contains the deployed resources information Resources map[string][]runtime.Object `json:"resources,omitempty"` } + +// infoJSON is used for custom JSON marshaling/unmarshaling +type infoJSON struct { + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// It handles empty string time fields by treating them as zero values. +func (i *Info) UnmarshalJSON(data []byte) error { + // First try to unmarshal into a map to handle empty string time fields + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Replace empty string time fields with nil + for _, field := range []string{"first_deployed", "last_deployed", "deleted"} { + if val, ok := raw[field]; ok { + if str, ok := val.(string); ok && str == "" { + raw[field] = nil + } + } + } + + // Re-marshal with cleaned data + cleaned, err := json.Marshal(raw) + if err != nil { + return err + } + + // Unmarshal into temporary struct with pointer time fields + var tmp infoJSON + if err := json.Unmarshal(cleaned, &tmp); err != nil { + return err + } + + // Copy values to Info struct + if tmp.FirstDeployed != nil { + i.FirstDeployed = *tmp.FirstDeployed + } + if tmp.LastDeployed != nil { + i.LastDeployed = *tmp.LastDeployed + } + if tmp.Deleted != nil { + i.Deleted = *tmp.Deleted + } + i.Description = tmp.Description + i.Status = tmp.Status + i.Notes = tmp.Notes + i.Resources = tmp.Resources + + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +// It omits zero-value time fields from the JSON output. +func (i Info) MarshalJSON() ([]byte, error) { + tmp := infoJSON{ + Description: i.Description, + Status: i.Status, + Notes: i.Notes, + Resources: i.Resources, + } + + if !i.FirstDeployed.IsZero() { + tmp.FirstDeployed = &i.FirstDeployed + } + if !i.LastDeployed.IsZero() { + tmp.LastDeployed = &i.LastDeployed + } + if !i.Deleted.IsZero() { + tmp.Deleted = &i.Deleted + } + + return json.Marshal(tmp) +} diff --git a/pkg/release/v1/info_test.go b/pkg/release/v1/info_test.go new file mode 100644 index 000000000..0fff78f76 --- /dev/null +++ b/pkg/release/v1/info_test.go @@ -0,0 +1,285 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + "encoding/json" + "testing" + "time" + + "helm.sh/helm/v4/pkg/release/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfoMarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + expected string + }{ + { + name: "all fields populated", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + }, + { + name: "only required fields", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + }, + { + name: "zero time values omitted", + info: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + expected: `{"description":"Test release","status":"deployed"}`, + }, + { + name: "with pending status", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusPendingInstall, + Description: "Installing release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","description":"Installing release","status":"pending-install"}`, + }, + { + name: "uninstalled with deleted time", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + Description: "Uninstalled release", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + assert.JSONEq(t, tt.expected, string(data)) + }) + } +} + +func TestInfoUnmarshalJSON(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + deleted := time.Date(2025, 10, 8, 14, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + expected Info + wantErr bool + }{ + { + name: "all fields populated", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Test release","status":"deployed","notes":"Test notes"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Test notes", + }, + }, + { + name: "only required fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + }, + }, + { + name: "empty string time fields", + input: `{"first_deployed":"","last_deployed":"","deleted":"","description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "missing time fields", + input: `{"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "null time fields", + input: `{"first_deployed":null,"last_deployed":null,"deleted":null,"description":"Test release","status":"deployed"}`, + expected: Info{ + Description: "Test release", + Status: common.StatusDeployed, + }, + }, + { + name: "mixed empty and valid time fields", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"","deleted":"","status":"deployed"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusDeployed, + }, + }, + { + name: "pending install status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","status":"pending-install","description":"Installing"}`, + expected: Info{ + FirstDeployed: now, + Status: common.StatusPendingInstall, + Description: "Installing", + }, + }, + { + name: "uninstalled with deleted time", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","status":"uninstalled"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Deleted: deleted, + Status: common.StatusUninstalled, + }, + }, + { + name: "failed status", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"failed","description":"Deployment failed"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusFailed, + Description: "Deployment failed", + }, + }, + { + name: "invalid time format", + input: `{"first_deployed":"invalid-time","status":"deployed"}`, + wantErr: true, + }, + { + name: "empty object", + input: `{}`, + expected: Info{ + Status: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var info Info + err := json.Unmarshal([]byte(tt.input), &info) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected.FirstDeployed.Unix(), info.FirstDeployed.Unix()) + assert.Equal(t, tt.expected.LastDeployed.Unix(), info.LastDeployed.Unix()) + assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) + assert.Equal(t, tt.expected.Description, info.Description) + assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.Notes, info.Notes) + assert.Equal(t, tt.expected.Resources, info.Resources) + }) + } +} + +func TestInfoRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + original := Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Test release", + Status: common.StatusDeployed, + Notes: "Release notes", + } + + data, err := json.Marshal(&original) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, original.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, original.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, original.Deleted.Unix(), decoded.Deleted.Unix()) + assert.Equal(t, original.Description, decoded.Description) + assert.Equal(t, original.Status, decoded.Status) + assert.Equal(t, original.Notes, decoded.Notes) +} + +func TestInfoEmptyStringRoundTrip(t *testing.T) { + // This test specifically verifies that empty string time fields + // are handled correctly during parsing + input := `{"first_deployed":"","last_deployed":"","deleted":"","status":"deployed","description":"test"}` + + var info Info + err := json.Unmarshal([]byte(input), &info) + require.NoError(t, err) + + // Verify time fields are zero values + assert.True(t, info.FirstDeployed.IsZero()) + assert.True(t, info.LastDeployed.IsZero()) + assert.True(t, info.Deleted.IsZero()) + assert.Equal(t, common.StatusDeployed, info.Status) + assert.Equal(t, "test", info.Description) + + // Marshal back and verify empty time fields are omitted + data, err := json.Marshal(&info) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + // Zero time values should be omitted due to omitzero tag + assert.NotContains(t, result, "first_deployed") + assert.NotContains(t, result, "last_deployed") + assert.NotContains(t, result, "deleted") + assert.Equal(t, "deployed", result["status"]) + assert.Equal(t, "test", result["description"]) +} diff --git a/pkg/release/v1/mock.go b/pkg/release/v1/mock.go index 818cd777e..06ad90e8f 100644 --- a/pkg/release/v1/mock.go +++ b/pkg/release/v1/mock.go @@ -23,6 +23,7 @@ import ( "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" + rcommon "helm.sh/helm/v4/pkg/release/common" ) // MockHookTemplate is the hook template used for all mock release objects. @@ -45,7 +46,7 @@ type MockReleaseOptions struct { Name string Version int Chart *chart.Chart - Status Status + Status rcommon.Status Namespace string Labels map[string]string } @@ -105,7 +106,7 @@ func Mock(opts *MockReleaseOptions) *Release { } } - scode := StatusDeployed + scode := rcommon.StatusDeployed if len(opts.Status) > 0 { scode = opts.Status } diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index a7f076e04..454ee6eb7 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -17,6 +17,7 @@ package v1 import ( chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/release/common" ) type ApplyMethod string @@ -53,7 +54,7 @@ type Release struct { } // SetStatus is a helper for setting the status on a release. -func (r *Release) SetStatus(status Status, msg string) { +func (r *Release) SetStatus(status common.Status, msg string) { r.Info.Status = status r.Info.Description = msg } diff --git a/pkg/release/v1/util/filter.go b/pkg/release/v1/util/filter.go index f818a6196..dc60195cf 100644 --- a/pkg/release/v1/util/filter.go +++ b/pkg/release/v1/util/filter.go @@ -16,7 +16,10 @@ limitations under the License. package util // import "helm.sh/helm/v4/pkg/release/v1/util" -import rspb "helm.sh/helm/v4/pkg/release/v1" +import ( + "helm.sh/helm/v4/pkg/release/common" + rspb "helm.sh/helm/v4/pkg/release/v1" +) // FilterFunc returns true if the release object satisfies // the predicate of the underlying filter func. @@ -68,7 +71,7 @@ func All(filters ...FilterFunc) FilterFunc { } // StatusFilter filters a set of releases by status code. -func StatusFilter(status rspb.Status) FilterFunc { +func StatusFilter(status common.Status) FilterFunc { return FilterFunc(func(rls *rspb.Release) bool { if rls == nil { return true diff --git a/pkg/release/v1/util/filter_test.go b/pkg/release/v1/util/filter_test.go index c8b23d526..1004a4c57 100644 --- a/pkg/release/v1/util/filter_test.go +++ b/pkg/release/v1/util/filter_test.go @@ -19,20 +19,21 @@ package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "testing" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) func TestFilterAny(t *testing.T) { - ls := Any(StatusFilter(rspb.StatusUninstalled)).Filter(releases) + ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases) if len(ls) != 2 { t.Fatalf("expected 2 results, got '%d'", len(ls)) } r0, r1 := ls[0], ls[1] switch { - case r0.Info.Status != rspb.StatusUninstalled: + case r0.Info.Status != common.StatusUninstalled: t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - case r1.Info.Status != rspb.StatusUninstalled: + case r1.Info.Status != common.StatusUninstalled: t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) } } @@ -40,7 +41,7 @@ func TestFilterAny(t *testing.T) { func TestFilterAll(t *testing.T) { fn := FilterFunc(func(rls *rspb.Release) bool { // true if not uninstalled and version < 4 - v0 := !StatusFilter(rspb.StatusUninstalled).Check(rls) + v0 := !StatusFilter(common.StatusUninstalled).Check(rls) v1 := rls.Version < 4 return v0 && v1 }) @@ -53,7 +54,7 @@ func TestFilterAll(t *testing.T) { switch r0 := ls[0]; { case r0.Version == 4: t.Fatal("got release with status revision 4") - case r0.Info.Status == rspb.StatusUninstalled: + case r0.Info.Status == common.StatusUninstalled: t.Fatal("got release with status UNINSTALLED") } } diff --git a/pkg/release/v1/util/sorter.go b/pkg/release/v1/util/sorter.go index 3712a58ef..47506fbf2 100644 --- a/pkg/release/v1/util/sorter.go +++ b/pkg/release/v1/util/sorter.go @@ -44,7 +44,11 @@ func SortByDate(list []*rspb.Release) { sort.Slice(list, func(i, j int) bool { ti := list[i].Info.LastDeployed.Unix() tj := list[j].Info.LastDeployed.Unix() - return ti < tj + if ti != tj { + return ti < tj + } + // Use name as tie-breaker for stable sorting + return list[i].Name < list[j].Name }) } diff --git a/pkg/release/v1/util/sorter_test.go b/pkg/release/v1/util/sorter_test.go index 0889ddb94..f47db7db8 100644 --- a/pkg/release/v1/util/sorter_test.go +++ b/pkg/release/v1/util/sorter_test.go @@ -20,19 +20,20 @@ import ( "testing" "time" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) // note: this test data is shared with filter_test.go. var releases = []*rspb.Release{ - tsRelease("quiet-bear", 2, 2000, rspb.StatusSuperseded), - tsRelease("angry-bird", 4, 3000, rspb.StatusDeployed), - tsRelease("happy-cats", 1, 4000, rspb.StatusUninstalled), - tsRelease("vocal-dogs", 3, 6000, rspb.StatusUninstalled), + tsRelease("quiet-bear", 2, 2000, common.StatusSuperseded), + tsRelease("angry-bird", 4, 3000, common.StatusDeployed), + tsRelease("happy-cats", 1, 4000, common.StatusUninstalled), + tsRelease("vocal-dogs", 3, 6000, common.StatusUninstalled), } -func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rspb.Release { +func tsRelease(name string, vers int, dur time.Duration, status common.Status) *rspb.Release { info := &rspb.Info{Status: status, LastDeployed: time.Now().Add(dur)} return &rspb.Release{ Name: name, diff --git a/pkg/repo/v1/index.go b/pkg/repo/v1/index.go index 4de8bb463..d77d70a7f 100644 --- a/pkg/repo/v1/index.go +++ b/pkg/repo/v1/index.go @@ -215,6 +215,7 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) { } if constraint.Check(test) { + slog.Warn("unable to find exact version; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) return ver, nil } } diff --git a/pkg/storage/driver/cfgmaps.go b/pkg/storage/driver/cfgmaps.go index de097f294..ada148158 100644 --- a/pkg/storage/driver/cfgmaps.go +++ b/pkg/storage/driver/cfgmaps.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "helm.sh/helm/v4/pkg/release" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -60,7 +61,7 @@ func (cfgmaps *ConfigMaps) Name() string { // Get fetches the release named by key. The corresponding release is returned // or error if not found. -func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { +func (cfgmaps *ConfigMaps) Get(key string) (release.Releaser, error) { // fetch the configmap holding the release named by key obj, err := cfgmaps.impl.Get(context.Background(), key, metav1.GetOptions{}) if err != nil { @@ -85,7 +86,7 @@ func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) { // List fetches all releases and returns the list releases such // that filter(release) == true. An error is returned if the // configmap fails to retrieve the releases. -func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (cfgmaps *ConfigMaps) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { lsel := kblabels.Set{"owner": "helm"}.AsSelector() opts := metav1.ListOptions{LabelSelector: lsel.String()} @@ -95,7 +96,7 @@ func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Releas return nil, err } - var results []*rspb.Release + var results []release.Releaser // iterate over the configmaps object list // and decode each release @@ -117,7 +118,7 @@ func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Releas // Query fetches all releases that match the provided map of labels. // An error is returned if the configmap fails to retrieve the releases. -func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, error) { +func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]release.Releaser, error) { ls := kblabels.Set{} for k, v := range labels { if errs := validation.IsValidLabelValue(v); len(errs) != 0 { @@ -138,7 +139,7 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err return nil, ErrReleaseNotFound } - var results []*rspb.Release + var results []release.Releaser for _, item := range list.Items { rls, err := decodeRelease(item.Data["release"]) if err != nil { @@ -153,18 +154,28 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err // Create creates a new ConfigMap holding the release. If the // ConfigMap already exists, ErrReleaseExists is returned. -func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { +func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { // set labels for configmaps object meta data var lbs labels + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + lbs.init() - lbs.fromMap(rls.Labels) + lbs.fromMap(rac.Labels()) lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + rel, err := releaserToV1Release(rls) + if err != nil { + return err + } + // create a new configmap to hold the release - obj, err := newConfigMapsObject(key, rls, lbs) + obj, err := newConfigMapsObject(key, rel, lbs) if err != nil { - slog.Debug("failed to encode release", "name", rls.Name, slog.Any("error", err)) + slog.Debug("failed to encode release", "name", rac.Name(), slog.Any("error", err)) return err } // push the configmap object out into the kubiverse @@ -181,10 +192,15 @@ func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error { // Update updates the ConfigMap holding the release. If not found // the ConfigMap is created to hold the release. -func (cfgmaps *ConfigMaps) Update(key string, rls *rspb.Release) error { +func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { // set labels for configmaps object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) @@ -205,7 +221,7 @@ func (cfgmaps *ConfigMaps) Update(key string, rls *rspb.Release) error { } // Delete deletes the ConfigMap holding the release named by key. -func (cfgmaps *ConfigMaps) Delete(key string) (rls *rspb.Release, err error) { +func (cfgmaps *ConfigMaps) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence if rls, err = cfgmaps.Get(key); err != nil { return nil, err diff --git a/pkg/storage/driver/cfgmaps_test.go b/pkg/storage/driver/cfgmaps_test.go index a563eb7d9..8beb45547 100644 --- a/pkg/storage/driver/cfgmaps_test.go +++ b/pkg/storage/driver/cfgmaps_test.go @@ -22,6 +22,8 @@ import ( v1 "k8s.io/api/core/v1" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -37,7 +39,7 @@ func TestConfigMapGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) @@ -57,7 +59,7 @@ func TestUncompressedConfigMapGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // Create a test fixture which contains an uncompressed release cfgmap, err := newConfigMapsObject(key, rel, nil) @@ -84,19 +86,35 @@ func TestUncompressedConfigMapGet(t *testing.T) { } } +func convertReleaserToV1(t *testing.T, rel release.Releaser) *rspb.Release { + t.Helper() + switch r := rel.(type) { + case rspb.Release: + return &r + case *rspb.Release: + return r + case nil: + return nil + } + + t.Fatalf("Unsupported release type: %T", rel) + return nil +} + func TestConfigMapList(t *testing.T) { cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) // list all deleted releases - del, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -107,8 +125,9 @@ func TestConfigMapList(t *testing.T) { } // list all deployed releases - dpl, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -119,8 +138,9 @@ func TestConfigMapList(t *testing.T) { } // list all superseded releases - ssd, err := cfgmaps.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := cfgmaps.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -130,7 +150,7 @@ func TestConfigMapList(t *testing.T) { t.Errorf("Expected 2 superseded, got %d", len(ssd)) } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -143,12 +163,12 @@ func TestConfigMapList(t *testing.T) { func TestConfigMapQuery(t *testing.T) { cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) rls, err := cfgmaps.Query(map[string]string{"status": "deployed"}) @@ -172,7 +192,7 @@ func TestConfigMapCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // store the release in a configmap if err := cfgmaps.Create(key, rel); err != nil { @@ -196,12 +216,12 @@ func TestConfigMapUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) // modify release status code - rel.Info.Status = rspb.StatusSuperseded + rel.Info.Status = common.StatusSuperseded // perform the update if err := cfgmaps.Update(key, rel); err != nil { @@ -209,10 +229,11 @@ func TestConfigMapUpdate(t *testing.T) { } // fetch the updated release - got, err := cfgmaps.Get(key) + goti, err := cfgmaps.Get(key) if err != nil { t.Fatalf("Failed to get release with key %q: %s", key, err) } + got := convertReleaserToV1(t, goti) // check release has actually been updated by comparing modified fields if rel.Info.Status != got.Info.Status { @@ -225,7 +246,7 @@ func TestConfigMapDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) cfgmaps := newTestFixtureCfgMaps(t, []*rspb.Release{rel}...) diff --git a/pkg/storage/driver/driver.go b/pkg/storage/driver/driver.go index 4f9d63928..6efd1dbaa 100644 --- a/pkg/storage/driver/driver.go +++ b/pkg/storage/driver/driver.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" + "helm.sh/helm/v4/pkg/release" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -58,7 +59,7 @@ func NewErrNoDeployedReleases(releaseName string) error { // Create stores the release or returns ErrReleaseExists // if an identical release already exists. type Creator interface { - Create(key string, rls *rspb.Release) error + Create(key string, rls release.Releaser) error } // Updator is the interface that wraps the Update method. @@ -66,7 +67,7 @@ type Creator interface { // Update updates an existing release or returns // ErrReleaseNotFound if the release does not exist. type Updator interface { - Update(key string, rls *rspb.Release) error + Update(key string, rls release.Releaser) error } // Deletor is the interface that wraps the Delete method. @@ -74,7 +75,7 @@ type Updator interface { // Delete deletes the release named by key or returns // ErrReleaseNotFound if the release does not exist. type Deletor interface { - Delete(key string) (*rspb.Release, error) + Delete(key string) (release.Releaser, error) } // Queryor is the interface that wraps the Get and List methods. @@ -86,9 +87,9 @@ type Deletor interface { // // Query returns the set of all releases that match the provided label set. type Queryor interface { - Get(key string) (*rspb.Release, error) - List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) - Query(labels map[string]string) ([]*rspb.Release, error) + Get(key string) (release.Releaser, error) + List(filter func(release.Releaser) bool) ([]release.Releaser, error) + Query(labels map[string]string) ([]release.Releaser, error) } // Driver is the interface composed of Creator, Updator, Deletor, and Queryor @@ -102,3 +103,18 @@ type Driver interface { Queryor Name() string } + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } +} diff --git a/pkg/storage/driver/memory.go b/pkg/storage/driver/memory.go index 79e7f090e..352fe2c6a 100644 --- a/pkg/storage/driver/memory.go +++ b/pkg/storage/driver/memory.go @@ -21,7 +21,7 @@ import ( "strings" "sync" - rspb "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release" ) var _ Driver = (*Memory)(nil) @@ -61,7 +61,7 @@ func (mem *Memory) Name() string { } // Get returns the release named by key or returns ErrReleaseNotFound. -func (mem *Memory) Get(key string) (*rspb.Release, error) { +func (mem *Memory) Get(key string) (release.Releaser, error) { defer unlock(mem.rlock()) keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") @@ -83,10 +83,10 @@ func (mem *Memory) Get(key string) (*rspb.Release, error) { } // List returns the list of all releases such that filter(release) == true -func (mem *Memory) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (mem *Memory) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { defer unlock(mem.rlock()) - var ls []*rspb.Release + var ls []release.Releaser for namespace := range mem.cache { if mem.namespace != "" { // Should only list releases of this namespace @@ -109,7 +109,7 @@ func (mem *Memory) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error } // Query returns the set of releases that match the provided set of labels -func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { +func (mem *Memory) Query(keyvals map[string]string) ([]release.Releaser, error) { defer unlock(mem.rlock()) var lbs labels @@ -117,7 +117,7 @@ func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { lbs.init() lbs.fromMap(keyvals) - var ls []*rspb.Release + var ls []release.Releaser for namespace := range mem.cache { if mem.namespace != "" { // Should only query releases of this namespace @@ -150,9 +150,13 @@ func (mem *Memory) Query(keyvals map[string]string) ([]*rspb.Release, error) { } // Create creates a new release or returns ErrReleaseExists. -func (mem *Memory) Create(key string, rls *rspb.Release) error { +func (mem *Memory) Create(key string, rel release.Releaser) error { defer unlock(mem.wlock()) + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } // For backwards compatibility, we protect against an unset namespace namespace := rls.Namespace if namespace == "" { @@ -176,9 +180,14 @@ func (mem *Memory) Create(key string, rls *rspb.Release) error { } // Update updates a release or returns ErrReleaseNotFound. -func (mem *Memory) Update(key string, rls *rspb.Release) error { +func (mem *Memory) Update(key string, rel release.Releaser) error { defer unlock(mem.wlock()) + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + // For backwards compatibility, we protect against an unset namespace namespace := rls.Namespace if namespace == "" { @@ -196,7 +205,7 @@ func (mem *Memory) Update(key string, rls *rspb.Release) error { } // Delete deletes a release or returns ErrReleaseNotFound. -func (mem *Memory) Delete(key string) (*rspb.Release, error) { +func (mem *Memory) Delete(key string) (release.Releaser, error) { defer unlock(mem.wlock()) keyWithoutPrefix := strings.TrimPrefix(key, "sh.helm.release.v1.") diff --git a/pkg/storage/driver/memory_test.go b/pkg/storage/driver/memory_test.go index ee547b58b..329b82b2f 100644 --- a/pkg/storage/driver/memory_test.go +++ b/pkg/storage/driver/memory_test.go @@ -21,6 +21,10 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -38,22 +42,22 @@ func TestMemoryCreate(t *testing.T) { }{ { "create should succeed", - releaseStub("rls-c", 1, "default", rspb.StatusDeployed), + releaseStub("rls-c", 1, "default", common.StatusDeployed), false, }, { "create should fail (release already exists)", - releaseStub("rls-a", 1, "default", rspb.StatusDeployed), + releaseStub("rls-a", 1, "default", common.StatusDeployed), true, }, { "create in namespace should succeed", - releaseStub("rls-a", 1, "mynamespace", rspb.StatusDeployed), + releaseStub("rls-a", 1, "mynamespace", common.StatusDeployed), false, }, { "create in other namespace should fail (release already exists)", - releaseStub("rls-c", 1, "mynamespace", rspb.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", common.StatusDeployed), true, }, } @@ -104,8 +108,9 @@ func TestMemoryList(t *testing.T) { ts.SetNamespace("default") // list all deployed releases - dpl, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -116,8 +121,9 @@ func TestMemoryList(t *testing.T) { } // list all superseded releases - ssd, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -128,8 +134,9 @@ func TestMemoryList(t *testing.T) { } // list all deleted releases - del, err := ts.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := ts.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -185,25 +192,25 @@ func TestMemoryUpdate(t *testing.T) { { "update release status", "rls-a.v4", - releaseStub("rls-a", 4, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 4, "default", common.StatusSuperseded), false, }, { "update release does not exist", "rls-c.v1", - releaseStub("rls-c", 1, "default", rspb.StatusUninstalled), + releaseStub("rls-c", 1, "default", common.StatusUninstalled), true, }, { "update release status in namespace", "rls-c.v4", - releaseStub("rls-c", 4, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 4, "mynamespace", common.StatusSuperseded), false, }, { "update release in namespace does not exist", "rls-a.v1", - releaseStub("rls-a", 1, "mynamespace", rspb.StatusUninstalled), + releaseStub("rls-a", 1, "mynamespace", common.StatusUninstalled), true, }, } @@ -255,17 +262,23 @@ func TestMemoryDelete(t *testing.T) { startLen := len(start) for _, tt := range tests { ts.SetNamespace(tt.namespace) - if rel, err := ts.Delete(tt.key); err != nil { + + rel, err := ts.Delete(tt.key) + var rls *rspb.Release + if err == nil { + rls = convertReleaserToV1(t, rel) + } + if err != nil { if !tt.err { t.Fatalf("Failed %q to get '%s': %q\n", tt.desc, tt.key, err) } continue } else if tt.err { t.Fatalf("Did not get expected error for %q '%s'\n", tt.desc, tt.key) - } else if fmt.Sprintf("%s.v%d", rel.Name, rel.Version) != tt.key { - t.Fatalf("Asked for delete on %s, but deleted %d", tt.key, rel.Version) + } else if fmt.Sprintf("%s.v%d", rls.Name, rls.Version) != tt.key { + t.Fatalf("Asked for delete on %s, but deleted %d", tt.key, rls.Version) } - _, err := ts.Get(tt.key) + _, err = ts.Get(tt.key) if err == nil { t.Errorf("Expected an error when asking for a deleted key") } @@ -282,7 +295,9 @@ func TestMemoryDelete(t *testing.T) { if startLen-2 != endLen { t.Errorf("expected end to be %d instead of %d", startLen-2, endLen) for _, ee := range end { - t.Logf("Name: %s, Version: %d", ee.Name, ee.Version) + rac, err := release.NewAccessor(ee) + assert.NoError(t, err, "unable to get release accessor") + t.Logf("Name: %s, Version: %d", rac.Name(), rac.Version()) } } diff --git a/pkg/storage/driver/mock_test.go b/pkg/storage/driver/mock_test.go index 7dba5fea2..e62b02f43 100644 --- a/pkg/storage/driver/mock_test.go +++ b/pkg/storage/driver/mock_test.go @@ -31,10 +31,11 @@ import ( kblabels "k8s.io/apimachinery/pkg/labels" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) -func releaseStub(name string, vers int, namespace string, status rspb.Status) *rspb.Release { +func releaseStub(name string, vers int, namespace string, status common.Status) *rspb.Release { return &rspb.Release{ Name: name, Version: vers, @@ -55,20 +56,20 @@ func tsFixtureMemory(t *testing.T) *Memory { t.Helper() hs := []*rspb.Release{ // rls-a - releaseStub("rls-a", 4, "default", rspb.StatusDeployed), - releaseStub("rls-a", 1, "default", rspb.StatusSuperseded), - releaseStub("rls-a", 3, "default", rspb.StatusSuperseded), - releaseStub("rls-a", 2, "default", rspb.StatusSuperseded), + releaseStub("rls-a", 4, "default", common.StatusDeployed), + releaseStub("rls-a", 1, "default", common.StatusSuperseded), + releaseStub("rls-a", 3, "default", common.StatusSuperseded), + releaseStub("rls-a", 2, "default", common.StatusSuperseded), // rls-b - releaseStub("rls-b", 4, "default", rspb.StatusDeployed), - releaseStub("rls-b", 1, "default", rspb.StatusSuperseded), - releaseStub("rls-b", 3, "default", rspb.StatusSuperseded), - releaseStub("rls-b", 2, "default", rspb.StatusSuperseded), + releaseStub("rls-b", 4, "default", common.StatusDeployed), + releaseStub("rls-b", 1, "default", common.StatusSuperseded), + releaseStub("rls-b", 3, "default", common.StatusSuperseded), + releaseStub("rls-b", 2, "default", common.StatusSuperseded), // rls-c in other namespace - releaseStub("rls-c", 4, "mynamespace", rspb.StatusDeployed), - releaseStub("rls-c", 1, "mynamespace", rspb.StatusSuperseded), - releaseStub("rls-c", 3, "mynamespace", rspb.StatusSuperseded), - releaseStub("rls-c", 2, "mynamespace", rspb.StatusSuperseded), + releaseStub("rls-c", 4, "mynamespace", common.StatusDeployed), + releaseStub("rls-c", 1, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 3, "mynamespace", common.StatusSuperseded), + releaseStub("rls-c", 2, "mynamespace", common.StatusSuperseded), } mem := NewMemory() diff --git a/pkg/storage/driver/records_test.go b/pkg/storage/driver/records_test.go index 34b2fb80c..24e4ccb4e 100644 --- a/pkg/storage/driver/records_test.go +++ b/pkg/storage/driver/records_test.go @@ -20,13 +20,13 @@ import ( "reflect" "testing" - rspb "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/release/common" ) func TestRecordsAdd(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -39,13 +39,13 @@ func TestRecordsAdd(t *testing.T) { "add valid key", "rls-a.v3", false, - newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), }, { "add already existing key", "rls-a.v1", true, - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusDeployed)), }, } @@ -70,8 +70,8 @@ func TestRecordsRemove(t *testing.T) { } rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) startLen := rs.Len() @@ -98,8 +98,8 @@ func TestRecordsRemove(t *testing.T) { func TestRecordsRemoveAt(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) if len(rs) != 2 { @@ -114,8 +114,8 @@ func TestRecordsRemoveAt(t *testing.T) { func TestRecordsGet(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -126,7 +126,7 @@ func TestRecordsGet(t *testing.T) { { "get valid key", "rls-a.v1", - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), }, { "get invalid key", @@ -145,8 +145,8 @@ func TestRecordsGet(t *testing.T) { func TestRecordsIndex(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -176,8 +176,8 @@ func TestRecordsIndex(t *testing.T) { func TestRecordsExists(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -207,8 +207,8 @@ func TestRecordsExists(t *testing.T) { func TestRecordsReplace(t *testing.T) { rs := records([]*record{ - newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v1", releaseStub("rls-a", 1, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }) var tests = []struct { @@ -220,13 +220,13 @@ func TestRecordsReplace(t *testing.T) { { "replace with existing key", "rls-a.v2", - newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", rspb.StatusSuperseded)), - newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", rspb.StatusDeployed)), + newRecord("rls-a.v3", releaseStub("rls-a", 3, "default", common.StatusSuperseded)), + newRecord("rls-a.v2", releaseStub("rls-a", 2, "default", common.StatusDeployed)), }, { "replace with non existing key", "rls-a.v4", - newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", rspb.StatusDeployed)), + newRecord("rls-a.v4", releaseStub("rls-a", 4, "default", common.StatusDeployed)), nil, }, } diff --git a/pkg/storage/driver/secrets.go b/pkg/storage/driver/secrets.go index 23a8f5cab..1f5ce75ac 100644 --- a/pkg/storage/driver/secrets.go +++ b/pkg/storage/driver/secrets.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "helm.sh/helm/v4/pkg/release" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -60,7 +61,7 @@ func (secrets *Secrets) Name() string { // Get fetches the release named by key. The corresponding release is returned // or error if not found. -func (secrets *Secrets) Get(key string) (*rspb.Release, error) { +func (secrets *Secrets) Get(key string) (release.Releaser, error) { // fetch the secret holding the release named by key obj, err := secrets.impl.Get(context.Background(), key, metav1.GetOptions{}) if err != nil { @@ -81,7 +82,7 @@ func (secrets *Secrets) Get(key string) (*rspb.Release, error) { // List fetches all releases and returns the list releases such // that filter(release) == true. An error is returned if the // secret fails to retrieve the releases. -func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (secrets *Secrets) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { lsel := kblabels.Set{"owner": "helm"}.AsSelector() opts := metav1.ListOptions{LabelSelector: lsel.String()} @@ -90,7 +91,7 @@ func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, return nil, fmt.Errorf("list: failed to list: %w", err) } - var results []*rspb.Release + var results []release.Releaser // iterate over the secrets object list // and decode each release @@ -112,7 +113,7 @@ func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release, // Query fetches all releases that match the provided map of labels. // An error is returned if the secret fails to retrieve the releases. -func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) { +func (secrets *Secrets) Query(labels map[string]string) ([]release.Releaser, error) { ls := kblabels.Set{} for k, v := range labels { if errs := validation.IsValidLabelValue(v); len(errs) != 0 { @@ -132,7 +133,7 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) return nil, ErrReleaseNotFound } - var results []*rspb.Release + var results []release.Releaser for _, item := range list.Items { rls, err := decodeRelease(string(item.Data["release"])) if err != nil { @@ -147,10 +148,15 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error) // Create creates a new Secret holding the release. If the // Secret already exists, ErrReleaseExists is returned. -func (secrets *Secrets) Create(key string, rls *rspb.Release) error { +func (secrets *Secrets) Create(key string, rel release.Releaser) error { // set labels for secrets object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) @@ -173,10 +179,15 @@ func (secrets *Secrets) Create(key string, rls *rspb.Release) error { // Update updates the Secret holding the release. If not found // the Secret is created to hold the release. -func (secrets *Secrets) Update(key string, rls *rspb.Release) error { +func (secrets *Secrets) Update(key string, rel release.Releaser) error { // set labels for secrets object meta data var lbs labels + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + lbs.init() lbs.fromMap(rls.Labels) lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) @@ -195,7 +206,7 @@ func (secrets *Secrets) Update(key string, rls *rspb.Release) error { } // Delete deletes the Secret holding the release named by key. -func (secrets *Secrets) Delete(key string) (rls *rspb.Release, err error) { +func (secrets *Secrets) Delete(key string) (rls release.Releaser, err error) { // fetch the release to check existence if rls, err = secrets.Get(key); err != nil { return nil, err diff --git a/pkg/storage/driver/secrets_test.go b/pkg/storage/driver/secrets_test.go index 9e45bae67..f4aa1176c 100644 --- a/pkg/storage/driver/secrets_test.go +++ b/pkg/storage/driver/secrets_test.go @@ -22,6 +22,8 @@ import ( v1 "k8s.io/api/core/v1" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -37,7 +39,7 @@ func TestSecretGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) @@ -57,7 +59,7 @@ func TestUNcompressedSecretGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // Create a test fixture which contains an uncompressed release secret, err := newSecretsObject(key, rel, nil) @@ -86,17 +88,18 @@ func TestUNcompressedSecretGet(t *testing.T) { func TestSecretList(t *testing.T) { secrets := newTestFixtureSecrets(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) // list all deleted releases - del, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -107,8 +110,9 @@ func TestSecretList(t *testing.T) { } // list all deployed releases - dpl, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -119,8 +123,9 @@ func TestSecretList(t *testing.T) { } // list all superseded releases - ssd, err := secrets.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := secrets.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -130,7 +135,7 @@ func TestSecretList(t *testing.T) { t.Errorf("Expected 2 superseded, got %d", len(ssd)) } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -143,12 +148,12 @@ func TestSecretList(t *testing.T) { func TestSecretQuery(t *testing.T) { secrets := newTestFixtureSecrets(t, []*rspb.Release{ - releaseStub("key-1", 1, "default", rspb.StatusUninstalled), - releaseStub("key-2", 1, "default", rspb.StatusUninstalled), - releaseStub("key-3", 1, "default", rspb.StatusDeployed), - releaseStub("key-4", 1, "default", rspb.StatusDeployed), - releaseStub("key-5", 1, "default", rspb.StatusSuperseded), - releaseStub("key-6", 1, "default", rspb.StatusSuperseded), + releaseStub("key-1", 1, "default", common.StatusUninstalled), + releaseStub("key-2", 1, "default", common.StatusUninstalled), + releaseStub("key-3", 1, "default", common.StatusDeployed), + releaseStub("key-4", 1, "default", common.StatusDeployed), + releaseStub("key-5", 1, "default", common.StatusSuperseded), + releaseStub("key-6", 1, "default", common.StatusSuperseded), }...) rls, err := secrets.Query(map[string]string{"status": "deployed"}) @@ -172,7 +177,7 @@ func TestSecretCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) // store the release in a secret if err := secrets.Create(key, rel); err != nil { @@ -196,12 +201,12 @@ func TestSecretUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) // modify release status code - rel.Info.Status = rspb.StatusSuperseded + rel.Info.Status = common.StatusSuperseded // perform the update if err := secrets.Update(key, rel); err != nil { @@ -209,10 +214,11 @@ func TestSecretUpdate(t *testing.T) { } // fetch the updated release - got, err := secrets.Get(key) + goti, err := secrets.Get(key) if err != nil { t.Fatalf("Failed to get release with key %q: %s", key, err) } + got := convertReleaserToV1(t, goti) // check release has actually been updated by comparing modified fields if rel.Info.Status != got.Info.Status { @@ -225,7 +231,7 @@ func TestSecretDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) secrets := newTestFixtureSecrets(t, []*rspb.Release{rel}...) diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go index 46f6c6b2e..0020d2436 100644 --- a/pkg/storage/driver/sql.go +++ b/pkg/storage/driver/sql.go @@ -32,6 +32,7 @@ import ( // Import pq for postgres dialect _ "github.com/lib/pq" + "helm.sh/helm/v4/pkg/release" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -297,7 +298,7 @@ func NewSQL(connectionString string, namespace string) (*SQL, error) { } // Get returns the release named by key. -func (s *SQL) Get(key string) (*rspb.Release, error) { +func (s *SQL) Get(key string) (release.Releaser, error) { var record SQLReleaseWrapper qb := s.statementBuilder. @@ -333,7 +334,7 @@ func (s *SQL) Get(key string) (*rspb.Release, error) { } // List returns the list of all releases such that filter(release) == true -func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (s *SQL) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { sb := s.statementBuilder. Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). From(sqlReleaseTableName). @@ -356,7 +357,7 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { return nil, err } - var releases []*rspb.Release + var releases []release.Releaser for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { @@ -379,7 +380,7 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { } // Query returns the set of releases that match the provided set of labels. -func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { +func (s *SQL) Query(labels map[string]string) ([]release.Releaser, error) { sb := s.statementBuilder. Select(sqlReleaseTableKeyColumn, sqlReleaseTableNamespaceColumn, sqlReleaseTableBodyColumn). From(sqlReleaseTableName) @@ -420,7 +421,7 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { return nil, ErrReleaseNotFound } - var releases []*rspb.Release + var releases []release.Releaser for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { @@ -444,7 +445,12 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) { } // Create creates a new release. -func (s *SQL) Create(key string, rls *rspb.Release) error { +func (s *SQL) Create(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } + namespace := rls.Namespace if namespace == "" { namespace = defaultNamespace @@ -551,7 +557,11 @@ func (s *SQL) Create(key string, rls *rspb.Release) error { } // Update updates a release. -func (s *SQL) Update(key string, rls *rspb.Release) error { +func (s *SQL) Update(key string, rel release.Releaser) error { + rls, err := releaserToV1Release(rel) + if err != nil { + return err + } namespace := rls.Namespace if namespace == "" { namespace = defaultNamespace @@ -590,7 +600,7 @@ func (s *SQL) Update(key string, rls *rspb.Release) error { } // Delete deletes a release or returns ErrReleaseNotFound. -func (s *SQL) Delete(key string) (*rspb.Release, error) { +func (s *SQL) Delete(key string) (release.Releaser, error) { transaction, err := s.db.Beginx() if err != nil { slog.Debug("failed to start SQL transaction", slog.Any("error", err)) diff --git a/pkg/storage/driver/sql_test.go b/pkg/storage/driver/sql_test.go index bd2918aad..d85691a6f 100644 --- a/pkg/storage/driver/sql_test.go +++ b/pkg/storage/driver/sql_test.go @@ -14,6 +14,7 @@ limitations under the License. package driver import ( + "database/sql/driver" "fmt" "reflect" "regexp" @@ -23,9 +24,38 @@ import ( sqlmock "github.com/DATA-DOG/go-sqlmock" migrate "github.com/rubenv/sql-migrate" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) +const recentTimestampTolerance = time.Second + +func recentUnixTimestamp() sqlmock.Argument { + return recentUnixTimestampArgument{} +} + +type recentUnixTimestampArgument struct{} + +func (recentUnixTimestampArgument) Match(value driver.Value) bool { + var ts int64 + switch v := value.(type) { + case int: + ts = int64(v) + case int64: + ts = v + default: + return false + } + + diff := time.Since(time.Unix(ts, 0)) + if diff < 0 { + diff = -diff + } + + return diff <= recentTimestampTolerance +} + func TestSQLName(t *testing.T) { sqlDriver, _ := newTestFixtureSQL(t) if sqlDriver.Name() != SQLDriverName { @@ -38,7 +68,7 @@ func TestSQLGet(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) body, _ := encodeRelease(rel) @@ -81,12 +111,12 @@ func TestSQLGet(t *testing.T) { func TestSQLList(t *testing.T) { releases := []*rspb.Release{} - releases = append(releases, releaseStub("key-1", 1, "default", rspb.StatusUninstalled)) - releases = append(releases, releaseStub("key-2", 1, "default", rspb.StatusUninstalled)) - releases = append(releases, releaseStub("key-3", 1, "default", rspb.StatusDeployed)) - releases = append(releases, releaseStub("key-4", 1, "default", rspb.StatusDeployed)) - releases = append(releases, releaseStub("key-5", 1, "default", rspb.StatusSuperseded)) - releases = append(releases, releaseStub("key-6", 1, "default", rspb.StatusSuperseded)) + releases = append(releases, releaseStub("key-1", 1, "default", common.StatusUninstalled)) + releases = append(releases, releaseStub("key-2", 1, "default", common.StatusUninstalled)) + releases = append(releases, releaseStub("key-3", 1, "default", common.StatusDeployed)) + releases = append(releases, releaseStub("key-4", 1, "default", common.StatusDeployed)) + releases = append(releases, releaseStub("key-5", 1, "default", common.StatusSuperseded)) + releases = append(releases, releaseStub("key-6", 1, "default", common.StatusSuperseded)) sqlDriver, mock := newTestFixtureSQL(t) @@ -119,8 +149,9 @@ func TestSQLList(t *testing.T) { } // list all deleted releases - del, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusUninstalled + del, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusUninstalled }) // check if err != nil { @@ -131,8 +162,9 @@ func TestSQLList(t *testing.T) { } // list all deployed releases - dpl, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusDeployed + dpl, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusDeployed }) // check if err != nil { @@ -143,8 +175,9 @@ func TestSQLList(t *testing.T) { } // list all superseded releases - ssd, err := sqlDriver.List(func(rel *rspb.Release) bool { - return rel.Info.Status == rspb.StatusSuperseded + ssd, err := sqlDriver.List(func(rel release.Releaser) bool { + rls := convertReleaserToV1(t, rel) + return rls.Info.Status == common.StatusSuperseded }) // check if err != nil { @@ -159,7 +192,7 @@ func TestSQLList(t *testing.T) { } // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work. - rls := ssd[0] + rls := convertReleaserToV1(t, ssd[0]) _, ok := rls.Labels["name"] if !ok { t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels) @@ -175,7 +208,7 @@ func TestSqlCreate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -197,7 +230,7 @@ func TestSqlCreate(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(regexp.QuoteMeta(query)). - WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). WillReturnResult(sqlmock.NewResult(1, 1)) labelsQuery := fmt.Sprintf( @@ -232,7 +265,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -255,7 +288,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { mock.ExpectBegin() mock. ExpectExec(regexp.QuoteMeta(insertQuery)). - WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix())). + WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). WillReturnError(fmt.Errorf("dialect dependent SQL error")) selectQuery := fmt.Sprintf( @@ -293,7 +326,7 @@ func TestSqlUpdate(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) sqlDriver, mock := newTestFixtureSQL(t) body, _ := encodeRelease(rel) @@ -313,7 +346,7 @@ func TestSqlUpdate(t *testing.T) { mock. ExpectExec(regexp.QuoteMeta(query)). - WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, int(time.Now().Unix()), key, namespace). + WithArgs(body, rel.Name, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp(), key, namespace). WillReturnResult(sqlmock.NewResult(0, 1)) if err := sqlDriver.Update(key, rel); err != nil { @@ -342,9 +375,9 @@ func TestSqlQuery(t *testing.T) { "owner": sqlReleaseDefaultOwner, } - supersededRelease := releaseStub("smug-pigeon", 1, "default", rspb.StatusSuperseded) + supersededRelease := releaseStub("smug-pigeon", 1, "default", common.StatusSuperseded) supersededReleaseBody, _ := encodeRelease(supersededRelease) - deployedRelease := releaseStub("smug-pigeon", 2, "default", rspb.StatusDeployed) + deployedRelease := releaseStub("smug-pigeon", 2, "default", common.StatusDeployed) deployedReleaseBody, _ := encodeRelease(deployedRelease) // Let's actually start our test @@ -454,7 +487,7 @@ func TestSqlDelete(t *testing.T) { name := "smug-pigeon" namespace := "default" key := testKey(name, vers) - rel := releaseStub(name, vers, namespace, rspb.StatusDeployed) + rel := releaseStub(name, vers, namespace, common.StatusDeployed) body, _ := encodeRelease(rel) diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index f086309bb..4603a1de6 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -22,6 +22,8 @@ import ( "log/slog" "strings" + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" relutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" @@ -47,7 +49,7 @@ type Storage struct { // Get retrieves the release from storage. An error is returned // if the storage driver failed to fetch the release, or the // release identified by the key, version pair does not exist. -func (s *Storage) Get(name string, version int) (*rspb.Release, error) { +func (s *Storage) Get(name string, version int) (release.Releaser, error) { slog.Debug("getting release", "key", makeKey(name, version)) return s.Driver.Get(makeKey(name, version)) } @@ -55,62 +57,99 @@ func (s *Storage) Get(name string, version int) (*rspb.Release, error) { // Create creates a new storage entry holding the release. An // error is returned if the storage driver fails to store the // release, or a release with an identical key already exists. -func (s *Storage) Create(rls *rspb.Release) error { - slog.Debug("creating release", "key", makeKey(rls.Name, rls.Version)) +func (s *Storage) Create(rls release.Releaser) error { + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + slog.Debug("creating release", "key", makeKey(rac.Name(), rac.Version())) if s.MaxHistory > 0 { // Want to make space for one more release. - if err := s.removeLeastRecent(rls.Name, s.MaxHistory-1); err != nil && + if err := s.removeLeastRecent(rac.Name(), s.MaxHistory-1); err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return err } } - return s.Driver.Create(makeKey(rls.Name, rls.Version), rls) + return s.Driver.Create(makeKey(rac.Name(), rac.Version()), rls) } // Update updates the release in storage. An error is returned if the // storage backend fails to update the release or if the release // does not exist. -func (s *Storage) Update(rls *rspb.Release) error { - slog.Debug("updating release", "key", makeKey(rls.Name, rls.Version)) - return s.Driver.Update(makeKey(rls.Name, rls.Version), rls) +func (s *Storage) Update(rls release.Releaser) error { + rac, err := release.NewAccessor(rls) + if err != nil { + return err + } + slog.Debug("updating release", "key", makeKey(rac.Name(), rac.Version())) + return s.Driver.Update(makeKey(rac.Name(), rac.Version()), rls) } // Delete deletes the release from storage. An error is returned if // the storage backend fails to delete the release or if the release // does not exist. -func (s *Storage) Delete(name string, version int) (*rspb.Release, error) { +func (s *Storage) Delete(name string, version int) (release.Releaser, error) { slog.Debug("deleting release", "key", makeKey(name, version)) return s.Driver.Delete(makeKey(name, version)) } // ListReleases returns all releases from storage. An error is returned if the // storage backend fails to retrieve the releases. -func (s *Storage) ListReleases() ([]*rspb.Release, error) { +func (s *Storage) ListReleases() ([]release.Releaser, error) { slog.Debug("listing all releases in storage") - return s.List(func(_ *rspb.Release) bool { return true }) + return s.List(func(_ release.Releaser) bool { return true }) +} + +// releaserToV1Release is a helper function to convert a v1 release passed by interface +// into the type object. +func releaserToV1Release(rel release.Releaser) (*rspb.Release, error) { + switch r := rel.(type) { + case rspb.Release: + return &r, nil + case *rspb.Release: + return r, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("unsupported release type: %T", rel) + } } // ListUninstalled returns all releases with Status == UNINSTALLED. An error is returned // if the storage backend fails to retrieve the releases. -func (s *Storage) ListUninstalled() ([]*rspb.Release, error) { +func (s *Storage) ListUninstalled() ([]release.Releaser, error) { slog.Debug("listing uninstalled releases in storage") - return s.List(func(rls *rspb.Release) bool { - return relutil.StatusFilter(rspb.StatusUninstalled).Check(rls) + return s.List(func(rls release.Releaser) bool { + rel, err := releaserToV1Release(rls) + if err != nil { + // This will only happen if calling code does not pass the proper types. This is + // a problem with the application and not user data. + slog.Error("unable to convert release to typed release", slog.Any("error", err)) + panic(fmt.Sprintf("unable to convert release to typed release: %s", err)) + } + return relutil.StatusFilter(common.StatusUninstalled).Check(rel) }) } // ListDeployed returns all releases with Status == DEPLOYED. An error is returned // if the storage backend fails to retrieve the releases. -func (s *Storage) ListDeployed() ([]*rspb.Release, error) { +func (s *Storage) ListDeployed() ([]release.Releaser, error) { slog.Debug("listing all deployed releases in storage") - return s.List(func(rls *rspb.Release) bool { - return relutil.StatusFilter(rspb.StatusDeployed).Check(rls) + return s.List(func(rls release.Releaser) bool { + rel, err := releaserToV1Release(rls) + if err != nil { + // This will only happen if calling code does not pass the proper types. This is + // a problem with the application and not user data. + slog.Error("unable to convert release to typed release", slog.Any("error", err)) + panic(fmt.Sprintf("unable to convert release to typed release: %s", err)) + } + return relutil.StatusFilter(common.StatusDeployed).Check(rel) }) } // Deployed returns the last deployed release with the provided release name, or // returns driver.NewErrNoDeployedReleases if not found. -func (s *Storage) Deployed(name string) (*rspb.Release, error) { +func (s *Storage) Deployed(name string) (release.Releaser, error) { ls, err := s.DeployedAll(name) if err != nil { return nil, err @@ -120,16 +159,34 @@ func (s *Storage) Deployed(name string) (*rspb.Release, error) { return nil, driver.NewErrNoDeployedReleases(name) } + rls, err := releaseListToV1List(ls) + if err != nil { + return nil, err + } + // If executed concurrently, Helm's database gets corrupted // and multiple releases are DEPLOYED. Take the latest. - relutil.Reverse(ls, relutil.SortByRevision) + relutil.Reverse(rls, relutil.SortByRevision) - return ls[0], nil + return rls[0], nil +} + +func releaseListToV1List(ls []release.Releaser) ([]*rspb.Release, error) { + rls := make([]*rspb.Release, 0, len(ls)) + for _, val := range ls { + rel, err := releaserToV1Release(val) + if err != nil { + return nil, err + } + rls = append(rls, rel) + } + + return rls, nil } // DeployedAll returns all deployed releases with the provided name, or // returns driver.NewErrNoDeployedReleases if not found. -func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) { +func (s *Storage) DeployedAll(name string) ([]release.Releaser, error) { slog.Debug("getting deployed releases", "name", name) ls, err := s.Query(map[string]string{ @@ -148,7 +205,7 @@ func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) { // History returns the revision history for the release with the provided name, or // returns driver.ErrReleaseNotFound if no such release name exists. -func (s *Storage) History(name string) ([]*rspb.Release, error) { +func (s *Storage) History(name string) ([]release.Releaser, error) { slog.Debug("getting release history", "name", name) return s.Query(map[string]string{"name": name, "owner": "helm"}) @@ -170,23 +227,31 @@ func (s *Storage) removeLeastRecent(name string, maximum int) error { if len(h) <= maximum { return nil } + rls, err := releaseListToV1List(h) + if err != nil { + return err + } // We want oldest to newest - relutil.SortByRevision(h) + relutil.SortByRevision(rls) lastDeployed, err := s.Deployed(name) if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { return err } - var toDelete []*rspb.Release - for _, rel := range h { + var toDelete []release.Releaser + for _, rel := range rls { // once we have enough releases to delete to reach the maximum, stop - if len(h)-len(toDelete) == maximum { + if len(rls)-len(toDelete) == maximum { break } if lastDeployed != nil { - if rel.Version != lastDeployed.Version { + ldac, err := release.NewAccessor(lastDeployed) + if err != nil { + return err + } + if rel.Version != ldac.Version() { toDelete = append(toDelete, rel) } } else { @@ -198,7 +263,12 @@ func (s *Storage) removeLeastRecent(name string, maximum int) error { // multiple invocations of this function will eventually delete them all. errs := []error{} for _, rel := range toDelete { - err = s.deleteReleaseVersion(name, rel.Version) + rac, err := release.NewAccessor(rel) + if err != nil { + errs = append(errs, err) + continue + } + err = s.deleteReleaseVersion(name, rac.Version()) if err != nil { errs = append(errs, err) } @@ -226,7 +296,7 @@ func (s *Storage) deleteReleaseVersion(name string, version int) error { } // Last fetches the last revision of the named release. -func (s *Storage) Last(name string) (*rspb.Release, error) { +func (s *Storage) Last(name string) (release.Releaser, error) { slog.Debug("getting last revision", "name", name) h, err := s.History(name) if err != nil { @@ -235,9 +305,13 @@ func (s *Storage) Last(name string) (*rspb.Release, error) { if len(h) == 0 { return nil, fmt.Errorf("no revision for release %q", name) } + rls, err := releaseListToV1List(h) + if err != nil { + return nil, err + } - relutil.Reverse(h, relutil.SortByRevision) - return h[0], nil + relutil.Reverse(rls, relutil.SortByRevision) + return rls[0], nil } // makeKey concatenates the Kubernetes storage object type, a release name and version diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go index d3025eca3..5b2a3bba5 100644 --- a/pkg/storage/storage_test.go +++ b/pkg/storage/storage_test.go @@ -22,6 +22,10 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage/driver" ) @@ -56,13 +60,13 @@ func TestStorageUpdate(t *testing.T) { rls := ReleaseTestData{ Name: "angry-beaver", Version: 1, - Status: rspb.StatusDeployed, + Status: common.StatusDeployed, }.ToRelease() assertErrNil(t.Fatal, storage.Create(rls), "StoreRelease") // modify the release - rls.Info.Status = rspb.StatusUninstalled + rls.Info.Status = common.StatusUninstalled assertErrNil(t.Fatal, storage.Update(rls), "UpdateRelease") // retrieve the updated release @@ -106,13 +110,16 @@ func TestStorageDelete(t *testing.T) { t.Errorf("unexpected error: %s", err) } + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + // We have now deleted one of the two records. - if len(hist) != 1 { + if len(rhist) != 1 { t.Errorf("expected 1 record for deleted release version, got %d", len(hist)) } - if hist[0].Version != 2 { - t.Errorf("Expected version to be 2, got %d", hist[0].Version) + if rhist[0].Version != 2 { + t.Errorf("Expected version to be 2, got %d", rhist[0].Version) } } @@ -123,13 +130,13 @@ func TestStorageList(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: "happy-catdog", Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: "livid-human", Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: "relaxed-cat", Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: "hungry-hippo", Status: rspb.StatusDeployed}.ToRelease() - rls4 := ReleaseTestData{Name: "angry-beaver", Status: rspb.StatusDeployed}.ToRelease() - rls5 := ReleaseTestData{Name: "opulent-frog", Status: rspb.StatusUninstalled}.ToRelease() - rls6 := ReleaseTestData{Name: "happy-liger", Status: rspb.StatusUninstalled}.ToRelease() + rls0 := ReleaseTestData{Name: "happy-catdog", Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: "livid-human", Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: "relaxed-cat", Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: "hungry-hippo", Status: common.StatusDeployed}.ToRelease() + rls4 := ReleaseTestData{Name: "angry-beaver", Status: common.StatusDeployed}.ToRelease() + rls5 := ReleaseTestData{Name: "opulent-frog", Status: common.StatusUninstalled}.ToRelease() + rls6 := ReleaseTestData{Name: "happy-liger", Status: common.StatusUninstalled}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'rls0'") @@ -144,7 +151,7 @@ func TestStorageList(t *testing.T) { var listTests = []struct { Description string NumExpected int - ListFunc func() ([]*rspb.Release, error) + ListFunc func() ([]release.Releaser, error) }{ {"ListDeployed", 2, storage.ListDeployed}, {"ListReleases", 7, storage.ListReleases}, @@ -175,10 +182,10 @@ func TestStorageDeployed(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -194,15 +201,18 @@ func TestStorageDeployed(t *testing.T) { t.Fatalf("Failed to query for deployed release: %s\n", err) } + rel, err := releaserToV1Release(rls) + assert.NoError(t, err) + switch { case rls == nil: t.Fatalf("Release is nil") - case rls.Name != name: - t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) - case rls.Version != vers: - t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) - case rls.Info.Status != rspb.StatusDeployed: - t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + case rel.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) + case rel.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rel.Version) + case rel.Info.Status != common.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rel.Info.Status.String()) } } @@ -215,10 +225,10 @@ func TestStorageDeployedWithCorruption(t *testing.T) { // setup storage with test releases setup := func() { // release records (notice odd order and corruption) - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -234,15 +244,18 @@ func TestStorageDeployedWithCorruption(t *testing.T) { t.Fatalf("Failed to query for deployed release: %s\n", err) } + rel, err := releaserToV1Release(rls) + assert.NoError(t, err) + switch { case rls == nil: t.Fatalf("Release is nil") - case rls.Name != name: - t.Fatalf("Expected release name %q, actual %q\n", name, rls.Name) - case rls.Version != vers: - t.Fatalf("Expected release version %d, actual %d\n", vers, rls.Version) - case rls.Info.Status != rspb.StatusDeployed: - t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rls.Info.Status.String()) + case rel.Name != name: + t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) + case rel.Version != vers: + t.Fatalf("Expected release version %d, actual %d\n", vers, rel.Version) + case rel.Info.Status != common.StatusDeployed: + t.Fatalf("Expected release status 'DEPLOYED', actual %s\n", rel.Info.Status.String()) } } @@ -254,10 +267,10 @@ func TestStorageHistory(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -286,22 +299,22 @@ type MaxHistoryMockDriver struct { func NewMaxHistoryMockDriver(d driver.Driver) *MaxHistoryMockDriver { return &MaxHistoryMockDriver{Driver: d} } -func (d *MaxHistoryMockDriver) Create(key string, rls *rspb.Release) error { +func (d *MaxHistoryMockDriver) Create(key string, rls release.Releaser) error { return d.Driver.Create(key, rls) } -func (d *MaxHistoryMockDriver) Update(key string, rls *rspb.Release) error { +func (d *MaxHistoryMockDriver) Update(key string, rls release.Releaser) error { return d.Driver.Update(key, rls) } -func (d *MaxHistoryMockDriver) Delete(_ string) (*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Delete(_ string) (release.Releaser, error) { return nil, errMaxHistoryMockDriverSomethingHappened } -func (d *MaxHistoryMockDriver) Get(key string) (*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Get(key string) (release.Releaser, error) { return d.Driver.Get(key) } -func (d *MaxHistoryMockDriver) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) { +func (d *MaxHistoryMockDriver) List(filter func(release.Releaser) bool) ([]release.Releaser, error) { return d.Driver.List(filter) } -func (d *MaxHistoryMockDriver) Query(labels map[string]string) ([]*rspb.Release, error) { +func (d *MaxHistoryMockDriver) Query(labels map[string]string) ([]release.Releaser, error) { return d.Driver.Query(labels) } func (d *MaxHistoryMockDriver) Name() string { @@ -319,14 +332,14 @@ func TestMaxHistoryErrorHandling(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls1 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Driver.Create(makeKey(rls1.Name, rls1.Version), rls1), "Storing release 'angry-bird' (v1)") } setup() - rls2 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() wantErr := errMaxHistoryMockDriverSomethingHappened gotErr := storage.Create(rls2) if !errors.Is(gotErr, wantErr) { @@ -345,10 +358,10 @@ func TestStorageRemoveLeastRecent(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusDeployed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusDeployed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -367,22 +380,25 @@ func TestStorageRemoveLeastRecent(t *testing.T) { } storage.MaxHistory = 3 - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusDeployed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusDeployed}.ToRelease() assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") // On inserting the 5th record, we expect two records to be pruned from history. hist, err := storage.History(name) + assert.NoError(t, err) + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) if err != nil { t.Fatal(err) - } else if len(hist) != storage.MaxHistory { - for _, item := range hist { + } else if len(rhist) != storage.MaxHistory { + for _, item := range rhist { t.Logf("%s %v", item.Name, item.Version) } - t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(rhist)) } // We expect the existing records to be 3, 4, and 5. - for i, item := range hist { + for i, item := range rhist { v := item.Version if expect := i + 3; v != expect { t.Errorf("Expected release %d, got %d", expect, v) @@ -399,10 +415,10 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusDeployed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusDeployed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -412,7 +428,7 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { } setup() - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusFailed}.ToRelease() assertErrNil(t.Fatal, storage.Create(rls5), "Storing release 'angry-bird' (v5)") // On inserting the 5th record, we expect a total of 3 releases, but we expect version 2 @@ -421,10 +437,12 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { if err != nil { t.Fatal(err) } else if len(hist) != storage.MaxHistory { - for _, item := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for _, item := range rhist { t.Logf("%s %v", item.Name, item.Version) } - t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(hist)) + t.Fatalf("expected %d items in history, got %d", storage.MaxHistory, len(rhist)) } expectedVersions := map[int]bool{ @@ -433,7 +451,9 @@ func TestStorageDoNotDeleteDeployed(t *testing.T) { 5: true, } - for _, item := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for _, item := range rhist { if !expectedVersions[item.Version] { t.Errorf("Release version %d, found when not expected", item.Version) } @@ -448,10 +468,10 @@ func TestStorageLast(t *testing.T) { // Set up storage with test releases. setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusSuperseded}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusSuperseded}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusSuperseded}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusSuperseded}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusSuperseded}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusSuperseded}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -467,8 +487,11 @@ func TestStorageLast(t *testing.T) { t.Fatalf("Failed to query for release history (%q): %s\n", name, err) } - if h.Version != 4 { - t.Errorf("Expected revision 4, got %d", h.Version) + rel, err := releaserToV1Release(h) + assert.NoError(t, err) + + if rel.Version != 4 { + t.Errorf("Expected revision 4, got %d", rel.Version) } } @@ -483,10 +506,10 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { // setup storage with test releases setup := func() { // release records - rls0 := ReleaseTestData{Name: name, Version: 1, Status: rspb.StatusFailed}.ToRelease() - rls1 := ReleaseTestData{Name: name, Version: 2, Status: rspb.StatusFailed}.ToRelease() - rls2 := ReleaseTestData{Name: name, Version: 3, Status: rspb.StatusFailed}.ToRelease() - rls3 := ReleaseTestData{Name: name, Version: 4, Status: rspb.StatusFailed}.ToRelease() + rls0 := ReleaseTestData{Name: name, Version: 1, Status: common.StatusFailed}.ToRelease() + rls1 := ReleaseTestData{Name: name, Version: 2, Status: common.StatusFailed}.ToRelease() + rls2 := ReleaseTestData{Name: name, Version: 3, Status: common.StatusFailed}.ToRelease() + rls3 := ReleaseTestData{Name: name, Version: 4, Status: common.StatusFailed}.ToRelease() // create the release records in the storage assertErrNil(t.Fatal, storage.Create(rls0), "Storing release 'angry-bird' (v1)") @@ -507,7 +530,7 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { setup() - rls5 := ReleaseTestData{Name: name, Version: 5, Status: rspb.StatusFailed}.ToRelease() + rls5 := ReleaseTestData{Name: name, Version: 5, Status: common.StatusFailed}.ToRelease() err := storage.Create(rls5) if err != nil { t.Fatalf("Failed to create a new release version: %s", err) @@ -518,13 +541,15 @@ func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) { t.Fatalf("unexpected error: %s", err) } - for i, rel := range hist { + rhist, err := releaseListToV1List(hist) + assert.NoError(t, err) + for i, rel := range rhist { wantVersion := i + 2 if rel.Version != wantVersion { t.Fatalf("Expected history release %d version to equal %d, got %d", i+1, wantVersion, rel.Version) } - wantStatus := rspb.StatusFailed + wantStatus := common.StatusFailed if rel.Info.Status != wantStatus { t.Fatalf("Expected history release %d status to equal %q, got %q", i+1, wantStatus, rel.Info.Status) } @@ -536,7 +561,7 @@ type ReleaseTestData struct { Version int Manifest string Namespace string - Status rspb.Status + Status common.Status } func (test ReleaseTestData) ToRelease() *rspb.Release { diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 487d4eeee..4a29a68ad 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -18,8 +18,20 @@ set -euo pipefail covermode=${COVERMODE:-atomic} coverdir=$(mktemp -d /tmp/coverage.XXXXXXXXXX) +trap 'rm -rf "${coverdir}"' EXIT profile="${coverdir}/cover.out" -target="${1:-./...}" # by default the whole repository is tested +html=false +target="./..." # by default the whole repository is tested +for arg in "$@"; do + case "${arg}" in + --html) + html=true + ;; + *) + target="${arg}" + ;; + esac +done generate_cover_data() { for d in $(go list "$target"); do @@ -36,9 +48,7 @@ generate_cover_data() { generate_cover_data go tool cover -func "${profile}" -case "${1-}" in - --html) +if [ "${html}" = "true" ] ; then go tool cover -html "${profile}" - ;; -esac +fi