Merge branch 'main' into fix-31170-URN

Signed-off-by: Benoit Tigeot <benoittgt@users.noreply.github.com>
Signed-off-by: Benoit Tigeot <benoit.tigeot@lifen.fr>
pull/31240/head
Benoit Tigeot 11 months ago committed by Benoit Tigeot
commit 97b7a1c9ae
No known key found for this signature in database
GPG Key ID: 8E6D4FC8AEBDA62C

@ -44,6 +44,7 @@ body:
label: Helm version label: Helm version
value: | value: |
<details> <details>
```console ```console
$ helm version $ helm version
# paste output here # paste output here

@ -0,0 +1,48 @@
# Copilot Instructions for Helm
## Overview
Helm is a package manager for Kubernetes written in Go, supporting v3 (stable) and v4 (unstable) APIs.
## Build & Test
```bash
make build # Build binary
make test # Run all tests (style + unit)
make test-unit # Unit tests only
make test-coverage # With coverage
make test-style # Linting
golangci-lint run # Direct linting
go test -run TestName # Specific test
```
## Code Structure
- `/cmd/helm/` - CLI entry point (Cobra-based)
- `/pkg/` - Public API
- `action/` - Core operations (install, upgrade, rollback)
- `chart/v2/` - Stable chart format
- `engine/` - Template rendering (Go templates + Sprig)
- `registry/` - OCI support
- `storage/` - Release backends (Secrets/ConfigMaps/SQL)
- `/internal/` - Private implementation
- `chart/v3/` - Next-gen chart format
## Development Guidelines
### Code Standards
- Use table-driven tests with testify
- Golden files in `testdata/` for complex output
- Mock Kubernetes clients for action tests
- All commits must include DCO sign-off: `git commit -s`
### Branching
- `main` - Helm v4 development
- `dev-v3` - Helm v3 stable (backport from main)
### Dependencies
- `k8s.io/client-go` - Kubernetes interaction
- `github.com/spf13/cobra` - CLI framework
- `github.com/Masterminds/sprig` - Template functions
### Key Patterns
- **Actions**: Operations in `/pkg/action/` use shared Configuration
- **Dual Chart Support**: v2 (stable) in `/pkg/`, v3 (dev) in `/internal/`
- **Storage Abstraction**: Pluggable release storage backends

4
.github/env vendored

@ -1,2 +1,2 @@
GOLANG_VERSION=1.24 GOLANG_VERSION=1.25
GOLANGCI_LINT_VERSION=v2.1.0 GOLANGCI_LINT_VERSION=v2.5.0

@ -33,7 +33,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: "Run analysis" - name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with: with:
results_file: results.sarif results_file: results.sarif
results_format: sarif results_format: sarif

@ -7,7 +7,7 @@ jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} 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.' 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.'

2
.gitignore vendored

@ -2,7 +2,7 @@
*.swp *.swp
.DS_Store .DS_Store
.coverage/ .coverage/
.idea/ .idea
.vimrc .vimrc
.vscode/ .vscode/
.devcontainer/ .devcontainer/

@ -43,6 +43,9 @@ linters:
- std-error-handling - std-error-handling
rules: rules:
# 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: - linters:
- revive - revive
text: 'var-naming: avoid meaningless package names' text: 'var-naming: avoid meaningless package names'

@ -1,8 +1,8 @@
BINDIR := $(CURDIR)/bin BINDIR := $(CURDIR)/bin
INSTALL_PATH ?= /usr/local/bin INSTALL_PATH ?= /usr/local/bin
DIST_DIRS := find * -type d -exec DIST_DIRS := find * -type d -exec
TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64 TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/loong64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64
TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-loong64.tar.gz linux-loong64.tar.gz.sha256 linux-loong64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum
BINNAME ?= helm BINNAME ?= helm
GOBIN = $(shell go env GOBIN) GOBIN = $(shell go env GOBIN)
@ -69,6 +69,8 @@ LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMajor=$(K8S_M
LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER)
LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
LDFLAGS += -X helm.sh/helm/v4/internal/version.kubeClientVersionMajor=$(K8S_MODULES_MAJOR_VER)
LDFLAGS += -X helm.sh/helm/v4/internal/version.kubeClientVersionMinor=$(K8S_MODULES_MINOR_VER)
.PHONY: all .PHONY: all
all: build all: build
@ -118,11 +120,12 @@ test-unit:
go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./internal/chart/v3/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)' go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./internal/chart/v3/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)'
# To run the coverage for a specific package use: make test-coverage PKG=./pkg/action
.PHONY: test-coverage .PHONY: test-coverage
test-coverage: test-coverage:
@echo @echo
@echo "==> Running unit tests with coverage <==" @echo "==> Running unit tests with coverage: $(PKG) <=="
@ ./scripts/coverage.sh @ ./scripts/coverage.sh $(PKG)
.PHONY: test-style .PHONY: test-style
test-style: test-style:
@ -148,10 +151,6 @@ test-acceptance: build build-cross
test-acceptance-completion: ACCEPTANCE_RUN_TESTS = shells.robot test-acceptance-completion: ACCEPTANCE_RUN_TESTS = shells.robot
test-acceptance-completion: test-acceptance test-acceptance-completion: test-acceptance
.PHONY: coverage
coverage:
@scripts/coverage.sh
.PHONY: format .PHONY: format
format: $(GOIMPORTS) format: $(GOIMPORTS)
go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm

@ -10,8 +10,9 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0 github.com/Masterminds/sprig/v3 v3.3.0
github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/squirrel v1.5.4
github.com/Masterminds/vcs v1.13.3 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/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/distribution/distribution/v3 v3.0.0
github.com/evanphx/json-patch/v5 v5.9.11 github.com/evanphx/json-patch/v5 v5.9.11
github.com/extism/go-sdk v1.7.1 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/fluxcd/cli-utils v0.36.0-flux.14
github.com/foxcpp/go-mockdns v1.1.0 github.com/foxcpp/go-mockdns v1.1.0
github.com/gobwas/glob v0.2.3 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/gosuri/uitable v0.0.4
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
@ -31,24 +32,24 @@ require (
github.com/rubenv/sql-migrate v1.8.0 github.com/rubenv/sql-migrate v1.8.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0 github.com/tetratelabs/wazero v1.9.0
go.yaml.in/yaml/v3 v3.0.4 go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.43.0
golang.org/x/term v0.34.0 golang.org/x/term v0.36.0
golang.org/x/text v0.28.0 golang.org/x/text v0.30.0
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.34.0 k8s.io/api v0.34.1
k8s.io/apiextensions-apiserver v0.34.0 k8s.io/apiextensions-apiserver v0.34.1
k8s.io/apimachinery v0.34.0 k8s.io/apimachinery v0.34.1
k8s.io/apiserver v0.34.0 k8s.io/apiserver v0.34.1
k8s.io/cli-runtime v0.34.0 k8s.io/cli-runtime v0.34.1
k8s.io/client-go v0.34.0 k8s.io/client-go v0.34.1
k8s.io/klog/v2 v2.130.1 k8s.io/klog/v2 v2.130.1
k8s.io/kubectl v0.34.0 k8s.io/kubectl v0.34.1
oras.land/oras-go/v2 v2.6.0 oras.land/oras-go/v2 v2.6.0
sigs.k8s.io/controller-runtime v0.22.0 sigs.k8s.io/controller-runtime v0.22.3
sigs.k8s.io/kustomize/kyaml v0.20.1 sigs.k8s.io/kustomize/kyaml v0.20.1
sigs.k8s.io/yaml v1.6.0 sigs.k8s.io/yaml v1.6.0
) )
@ -64,6 +65,7 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // 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/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/otel/trace v1.37.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/mod v0.26.0 // indirect golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.45.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/time v0.12.0 // indirect golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.35.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/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc 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 google.golang.org/grpc v1.72.1 // indirect
@ -174,7 +176,7 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/component-base v0.34.0 // indirect k8s.io/component-base v0.34.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect

@ -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/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 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE=
github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= 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/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/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= 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/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 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk=
github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= 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 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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.5.0 h1:hIAhkRBMQ8nIeuVwcAoymp7MY4oherZdAxD+m0u9zaw=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 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/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.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= 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.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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@ -302,8 +306,9 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@ -391,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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 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.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 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-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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -414,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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 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.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -428,8 +433,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -449,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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.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.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -458,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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 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.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -467,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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -479,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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -507,32 +512,32 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA=
k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0=
k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M=
k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE=
k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A=
k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs= k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI=
k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4= k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 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 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
sigs.k8s.io/controller-runtime v0.22.0 h1:mTOfibb8Hxwpx3xEkR56i7xSjB+nH4hZG37SrlCY5e0= sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y=
sigs.k8s.io/controller-runtime v0.22.0/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= 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 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 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= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I=

@ -19,6 +19,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
) )
@ -47,9 +48,13 @@ type Chart struct {
Values map[string]interface{} `json:"values"` Values map[string]interface{} `json:"values"`
// Schema is an optional JSON schema for imposing structure on Values // Schema is an optional JSON schema for imposing structure on Values
Schema []byte `json:"schema"` Schema []byte `json:"schema"`
// SchemaModTime the schema was last modified
SchemaModTime time.Time `json:"schemamodtime,omitempty"`
// Files are miscellaneous files in a chart archive, // Files are miscellaneous files in a chart archive,
// e.g. README, LICENSE, etc. // e.g. README, LICENSE, etc.
Files []*common.File `json:"files"` Files []*common.File `json:"files"`
// ModTime the chart metadata was last modified
ModTime time.Time `json:"modtime,omitzero"`
parent *Chart parent *Chart
dependencies []*Chart dependencies []*Chart

@ -18,6 +18,7 @@ package v3
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -25,26 +26,32 @@ import (
) )
func TestCRDs(t *testing.T) { func TestCRDs(t *testing.T) {
modTime := time.Now()
chrt := Chart{ chrt := Chart{
Files: []*common.File{ Files: []*common.File{
{ {
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "bar.yaml", Name: "bar.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crdsfoo/bar/baz.yaml", Name: "crdsfoo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/README.md", Name: "crds/README.md",
ModTime: modTime,
Data: []byte("# hello"), Data: []byte("# hello"),
}, },
}, },
@ -62,6 +69,7 @@ func TestSaveChartNoRawData(t *testing.T) {
Raw: []*common.File{ Raw: []*common.File{
{ {
Name: "fhqwhgads.yaml", Name: "fhqwhgads.yaml",
ModTime: time.Now(),
Data: []byte("Everybody to the Limit"), Data: []byte("Everybody to the Limit"),
}, },
}, },
@ -163,26 +171,32 @@ func TestChartFullPath(t *testing.T) {
} }
func TestCRDObjects(t *testing.T) { func TestCRDObjects(t *testing.T) {
modTime := time.Now()
chrt := Chart{ chrt := Chart{
Files: []*common.File{ Files: []*common.File{
{ {
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "bar.yaml", Name: "bar.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crdsfoo/bar/baz.yaml", Name: "crdsfoo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/README.md", Name: "crds/README.md",
ModTime: modTime,
Data: []byte("# hello"), Data: []byte("# hello"),
}, },
}, },
@ -194,6 +208,7 @@ func TestCRDObjects(t *testing.T) {
Filename: "crds/foo.yaml", Filename: "crds/foo.yaml",
File: &common.File{ File: &common.File{
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
}, },
@ -202,6 +217,7 @@ func TestCRDObjects(t *testing.T) {
Filename: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml",
File: &common.File{ File: &common.File{
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
}, },

@ -57,7 +57,7 @@ func RunAll(baseDir string, values map[string]interface{}, namespace string, opt
} }
rules.Chartfile(&result) rules.Chartfile(&result)
rules.ValuesWithOverrides(&result, values) rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation)
rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation)
rules.Dependencies(&result) rules.Dependencies(&result)
rules.Crds(&result) rules.Crds(&result)

@ -27,8 +27,6 @@ import (
chartutil "helm.sh/helm/v4/internal/chart/v3/util" chartutil "helm.sh/helm/v4/internal/chart/v3/util"
) )
var values map[string]interface{}
const namespace = "testNamespace" const namespace = "testNamespace"
const badChartDir = "rules/testdata/badchartfile" const badChartDir = "rules/testdata/badchartfile"
@ -41,6 +39,7 @@ const malformedTemplate = "rules/testdata/malformed-template"
const invalidChartFileDir = "rules/testdata/invalidchartfile" const invalidChartFileDir = "rules/testdata/invalidchartfile"
func TestBadChartV3(t *testing.T) { func TestBadChartV3(t *testing.T) {
var values map[string]any
m := RunAll(badChartDir, values, namespace).Messages m := RunAll(badChartDir, values, namespace).Messages
if len(m) != 8 { if len(m) != 8 {
t.Errorf("Number of errors %v", len(m)) t.Errorf("Number of errors %v", len(m))
@ -60,7 +59,7 @@ func TestBadChartV3(t *testing.T) {
} }
} }
if msg.Severity == support.ErrorSev { if msg.Severity == support.ErrorSev {
if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") { if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") {
e = true e = true
} }
if strings.Contains(msg.Err.Error(), "name is required") { if strings.Contains(msg.Err.Error(), "name is required") {
@ -90,6 +89,7 @@ func TestBadChartV3(t *testing.T) {
} }
func TestInvalidYaml(t *testing.T) { func TestInvalidYaml(t *testing.T) {
var values map[string]any
m := RunAll(badYamlFileDir, values, namespace).Messages m := RunAll(badYamlFileDir, values, namespace).Messages
if len(m) != 1 { if len(m) != 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m) 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) { func TestInvalidChartYamlV3(t *testing.T) {
var values map[string]any
m := RunAll(invalidChartFileDir, values, namespace).Messages m := RunAll(invalidChartFileDir, values, namespace).Messages
t.Log(m) t.Log(m)
if len(m) != 3 { if len(m) != 3 {
@ -111,6 +112,7 @@ func TestInvalidChartYamlV3(t *testing.T) {
} }
func TestBadValuesV3(t *testing.T) { func TestBadValuesV3(t *testing.T) {
var values map[string]any
m := RunAll(badValuesFileDir, values, namespace).Messages m := RunAll(badValuesFileDir, values, namespace).Messages
if len(m) < 1 { if len(m) < 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m) 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) { func TestBadCrdFileV3(t *testing.T) {
var values map[string]any
m := RunAll(badCrdFileDir, values, namespace).Messages m := RunAll(badCrdFileDir, values, namespace).Messages
assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) 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'") 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) { func TestGoodChart(t *testing.T) {
var values map[string]any
m := RunAll(goodChartDir, values, namespace).Messages m := RunAll(goodChartDir, values, namespace).Messages
if len(m) != 0 { if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have") 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 // See https://github.com/helm/helm/issues/7923
func TestHelmCreateChart(t *testing.T) { func TestHelmCreateChart(t *testing.T) {
var values map[string]any
dir := t.TempDir() dir := t.TempDir()
createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) 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. // Add values to enable hpa, and ingress which are disabled by default.
// This is the equivalent of: // This is the equivalent of:
// helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true'
updatedValues := map[string]interface{}{ updatedValues := map[string]any{
"autoscaling": map[string]interface{}{ "autoscaling": map[string]any{
"enabled": true, "enabled": true,
}, },
"ingress": map[string]interface{}{ "ingress": map[string]any{
"enabled": true, "enabled": true,
}, },
} }
@ -213,6 +218,7 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) {
// lint ignores import-values // lint ignores import-values
// See https://github.com/helm/helm/issues/9658 // See https://github.com/helm/helm/issues/9658
func TestSubChartValuesChart(t *testing.T) { func TestSubChartValuesChart(t *testing.T) {
var values map[string]any
m := RunAll(subChartValuesDir, values, namespace).Messages m := RunAll(subChartValuesDir, values, namespace).Messages
if len(m) != 0 { if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have") 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 // lint stuck with malformed template object
// See https://github.com/helm/helm/issues/11391 // See https://github.com/helm/helm/issues/11391
func TestMalformedTemplate(t *testing.T) { func TestMalformedTemplate(t *testing.T) {
var values map[string]any
c := time.After(3 * time.Second) c := time.After(3 * time.Second)
ch := make(chan int, 1) ch := make(chan int, 1)
var m []support.Message var m []support.Message

@ -140,9 +140,9 @@ func validateChartVersion(cf *chart.Metadata) error {
return errors.New("version is required") return errors.New("version is required")
} }
version, err := semver.NewVersion(cf.Version) version, err := semver.StrictNewVersion(cf.Version)
if err != nil { if err != nil {
return fmt.Errorf("version '%s' is not a valid SemVer", cf.Version) return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version)
} }
c, err := semver.NewConstraint(">0.0.0-0") c, err := semver.NewConstraint(">0.0.0-0")

@ -84,9 +84,11 @@ func TestValidateChartVersion(t *testing.T) {
ErrorMsg string ErrorMsg string
}{ }{
{"", "version is required"}, {"", "version is required"},
{"1.2.3.4", "version '1.2.3.4' is not a valid SemVer"}, {"1.2.3.4", "version '1.2.3.4' is not a valid SemVerV2"},
{"waps", "'waps' is not a valid SemVer"}, {"waps", "'waps' is not a valid SemVerV2"},
{"-3", "'-3' is not a valid SemVer"}, {"-3", "'-3' is not a valid SemVerV2"},
{"1.1", "'1.1' is not a valid SemVerV2"},
{"1", "'1' is not a valid SemVerV2"},
} }
var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"}

@ -126,7 +126,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string
linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName))
// We only apply the following lint rules to yaml files // We only apply the following lint rules to yaml files
if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { if !isYamlFileExtension(fileName) {
continue continue
} }
@ -335,6 +335,11 @@ func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error {
return nil return nil
} }
func isYamlFileExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
return ext == ".yaml" || ext == ".yml"
}
// k8sYamlStruct stubs a Kubernetes YAML file. // k8sYamlStruct stubs a Kubernetes YAML file.
type k8sYamlStruct struct { type k8sYamlStruct struct {
APIVersion string `json:"apiVersion"` APIVersion string `json:"apiVersion"`

@ -22,6 +22,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/internal/chart/v3/lint/support" "helm.sh/helm/v4/internal/chart/v3/lint/support"
@ -183,6 +184,7 @@ func TestValidateMetadataName(t *testing.T) {
} }
func TestDeprecatedAPIFails(t *testing.T) { func TestDeprecatedAPIFails(t *testing.T) {
modTime := time.Now()
mychart := chart.Chart{ mychart := chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
APIVersion: "v2", APIVersion: "v2",
@ -193,10 +195,12 @@ func TestDeprecatedAPIFails(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/baddeployment.yaml", Name: "templates/baddeployment.yaml",
ModTime: modTime,
Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"),
}, },
{ {
Name: "templates/goodsecret.yaml", Name: "templates/goodsecret.yaml",
ModTime: modTime,
Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"),
}, },
}, },
@ -253,6 +257,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/configmap.yaml", Name: "templates/configmap.yaml",
ModTime: time.Now(),
Data: []byte(manifest), Data: []byte(manifest),
}, },
}, },
@ -382,6 +387,7 @@ func TestEmptyWithCommentsManifests(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/empty-with-comments.yaml", Name: "templates/empty-with-comments.yaml",
ModTime: time.Now(),
Data: []byte("#@formatter:off\n"), Data: []byte("#@formatter:off\n"),
}, },
}, },
@ -439,3 +445,23 @@ items:
t.Fatalf("List objects keep annotations should pass. got: %s", err) t.Fatalf("List objects keep annotations should pass. got: %s", err)
} }
} }
func TestIsYamlFileExtension(t *testing.T) {
tests := []struct {
filename string
expected bool
}{
{"test.yaml", true},
{"test.yml", true},
{"test.txt", false},
{"test", false},
}
for _, test := range tests {
result := isYamlFileExtension(test.filename)
if result != test.expected {
t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected)
}
}
}

@ -32,7 +32,7 @@ import (
// they are only tested for well-formedness. // they are only tested for well-formedness.
// //
// If additional values are supplied, they are coalesced into the values in values.yaml. // If additional values are supplied, they are coalesced into the values in values.yaml.
func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) { func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) {
file := "values.yaml" file := "values.yaml"
vf := filepath.Join(linter.ChartDir, file) vf := filepath.Join(linter.ChartDir, file)
fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf))
@ -41,7 +41,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter
return return
} }
linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides)) linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation))
} }
func validateValuesFileExistence(valuesPath string) error { func validateValuesFileExistence(valuesPath string) error {
@ -52,7 +52,7 @@ func validateValuesFileExistence(valuesPath string) error {
return nil return nil
} }
func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error {
values, err := common.ReadValuesFile(valuesPath) values, err := common.ReadValuesFile(valuesPath)
if err != nil { if err != nil {
return fmt.Errorf("unable to parse YAML: %w", err) return fmt.Errorf("unable to parse YAML: %w", err)
@ -75,5 +75,10 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err
if err != nil { if err != nil {
return err return err
} }
if !skipSchemaValidation {
return util.ValidateAgainstSingleSchema(coalescedValues, schema) return util.ValidateAgainstSingleSchema(coalescedValues, schema)
} }
return nil
}

@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) {
` `
tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml))
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil {
t.Fatal("expected values file to fail parsing") t.Fatal("expected values file to fail parsing")
} }
} }
@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) {
createTestingSchema(t, tmpdir) createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil {
t.Fatalf("Failed validation with %s", err) t.Fatalf("Failed validation with %s", err)
} }
} }
@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) {
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, map[string]interface{}{}) err := validateValuesFile(valfile, map[string]interface{}{}, false)
if err == nil { if err == nil {
t.Fatal("expected values file to fail parsing") t.Fatal("expected values file to fail parsing")
} }
@ -99,6 +99,20 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) {
assert.Contains(t, err.Error(), "- at '/username': got number, want string") assert.Contains(t, err.Error(), "- at '/username': got number, want string")
} }
func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) {
// 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation.
yaml := "username: 1234\npassword: swordfish"
tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml))
createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, map[string]interface{}{}, true)
if err != nil {
t.Fatal("expected values file to pass parsing because of skipSchemaValidation")
}
}
func TestValidateValuesFileSchemaOverrides(t *testing.T) { func TestValidateValuesFileSchemaOverrides(t *testing.T) {
yaml := "username: admin" yaml := "username: admin"
overrides := map[string]interface{}{ overrides := map[string]interface{}{
@ -108,7 +122,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) {
createTestingSchema(t, tmpdir) createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, overrides); err != nil { if err := validateValuesFile(valfile, overrides, false); err != nil {
t.Fatalf("Failed validation with %s", err) t.Fatalf("Failed validation with %s", err)
} }
} }
@ -145,7 +159,7 @@ func TestValidateValuesFile(t *testing.T) {
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, tt.overrides) err := validateValuesFile(valfile, tt.overrides, false)
switch { switch {
case err != nil && tt.errorMessage == "": case err != nil && tt.errorMessage == "":

@ -17,32 +17,16 @@ limitations under the License.
package loader package loader
import ( import (
"archive/tar"
"bytes"
"compress/gzip" "compress/gzip"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"os" "os"
"path"
"regexp"
"strings"
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/pkg/chart/loader/archive"
) )
// MaxDecompressedChartSize is the maximum size of a chart archive that will be
// decompressed. This is the decompressed size of all the files.
// The default value is 100 MiB.
var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
// The size of the file is the decompressed version of it when it is stored in an archive.
var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
// FileLoader loads a chart from a file // FileLoader loads a chart from a file
type FileLoader string type FileLoader string
@ -65,7 +49,7 @@ func LoadFile(name string) (*chart.Chart, error) {
} }
defer raw.Close() defer raw.Close()
err = ensureArchive(name, raw) err = archive.EnsureArchive(name, raw)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -79,153 +63,9 @@ func LoadFile(name string) (*chart.Chart, error) {
return c, err return c, err
} }
// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive.
//
// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence
// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error
// if we didn't check for this.
func ensureArchive(name string, raw *os.File) error {
defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed.
// Check the file format to give us a chance to provide the user with more actionable feedback.
buffer := make([]byte, 512)
_, err := raw.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("file '%s' cannot be read: %s", name, err)
}
// Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject.
// Fix for: https://github.com/helm/helm/issues/12261
if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) {
// TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide
// variety of content (Makefile, .zshrc) as valid YAML without errors.
// Wrong content type. Let's check if it's yaml and give an extra hint?
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name)
}
return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType)
}
return nil
}
// isGZipApplication checks whether the archive is of the application/x-gzip type.
func isGZipApplication(data []byte) bool {
sig := []byte("\x1F\x8B\x08")
return bytes.HasPrefix(data, sig)
}
// LoadArchiveFiles reads in files out of an archive into memory. This function
// performs important path security checks and should always be used before
// expanding a tarball
func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
unzipped, err := gzip.NewReader(in)
if err != nil {
return nil, err
}
defer unzipped.Close()
files := []*BufferedFile{}
tr := tar.NewReader(unzipped)
remainingSize := MaxDecompressedChartSize
for {
b := bytes.NewBuffer(nil)
hd, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if hd.FileInfo().IsDir() {
// Use this instead of hd.Typeflag because we don't have to do any
// inference chasing.
continue
}
switch hd.Typeflag {
// We don't want to process these extension header files.
case tar.TypeXGlobalHeader, tar.TypeXHeader:
continue
}
// Archive could contain \ if generated on Windows
delimiter := "/"
if strings.ContainsRune(hd.Name, '\\') {
delimiter = "\\"
}
parts := strings.Split(hd.Name, delimiter)
n := strings.Join(parts[1:], delimiter)
// Normalize the path to the / delimiter
n = strings.ReplaceAll(n, delimiter, "/")
if path.IsAbs(n) {
return nil, errors.New("chart illegally contains absolute paths")
}
n = path.Clean(n)
if n == "." {
// In this case, the original path was relative when it should have been absolute.
return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
}
if strings.HasPrefix(n, "..") {
return nil, errors.New("chart illegally references parent directory")
}
// In some particularly arcane acts of path creativity, it is possible to intermix
// UNIX and Windows style paths in such a way that you produce a result of the form
// c:/foo even after all the built-in absolute path checks. So we explicitly check
// for this condition.
if drivePathPattern.MatchString(n) {
return nil, errors.New("chart contains illegally named files")
}
if parts[0] == "Chart.yaml" {
return nil, errors.New("chart yaml not in base directory")
}
if hd.Size > remainingSize {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
if hd.Size > MaxDecompressedFileSize {
return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
}
limitedReader := io.LimitReader(tr, remainingSize)
bytesWritten, err := io.Copy(b, limitedReader)
if err != nil {
return nil, err
}
remainingSize -= bytesWritten
// When the bytesWritten are less than the file size it means the limit reader ended
// copying early. Here we report that error. This is important if the last file extracted
// is the one that goes over the limit. It assumes the Size stored in the tar header
// is correct, something many applications do.
if bytesWritten < hd.Size || remainingSize <= 0 {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
data := bytes.TrimPrefix(b.Bytes(), utf8bom)
files = append(files, &BufferedFile{Name: n, Data: data})
b.Reset()
}
if len(files) == 0 {
return nil, errors.New("no files in chart archive")
}
return files, nil
}
// LoadArchive loads from a reader containing a compressed tar archive. // LoadArchive loads from a reader containing a compressed tar archive.
func LoadArchive(in io.Reader) (*chart.Chart, error) { func LoadArchive(in io.Reader) (*chart.Chart, error) {
files, err := LoadArchiveFiles(in) files, err := archive.LoadArchiveFiles(in)
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -25,6 +25,7 @@ import (
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/internal/sympath" "helm.sh/helm/v4/internal/sympath"
"helm.sh/helm/v4/pkg/chart/loader/archive"
"helm.sh/helm/v4/pkg/ignore" "helm.sh/helm/v4/pkg/ignore"
) )
@ -61,7 +62,7 @@ func LoadDir(dir string) (*chart.Chart, error) {
} }
rules.AddDefaults() rules.AddDefaults()
files := []*BufferedFile{} files := []*archive.BufferedFile{}
topdir += string(filepath.Separator) topdir += string(filepath.Separator)
walk := func(name string, fi os.FileInfo, err error) error { walk := func(name string, fi os.FileInfo, err error) error {
@ -99,8 +100,8 @@ func LoadDir(dir string) (*chart.Chart, error) {
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
} }
if fi.Size() > MaxDecompressedFileSize { if fi.Size() > archive.MaxDecompressedFileSize {
return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize) return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize)
} }
data, err := os.ReadFile(name) data, err := os.ReadFile(name)
@ -110,7 +111,7 @@ func LoadDir(dir string) (*chart.Chart, error) {
data = bytes.TrimPrefix(data, utf8bom) data = bytes.TrimPrefix(data, utf8bom)
files = append(files, &BufferedFile{Name: n, Data: data}) files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data})
return nil return nil
} }
if err = sympath.Walk(topdir, walk); err != nil { if err = sympath.Walk(topdir, walk); err != nil {

@ -25,6 +25,7 @@ import (
"maps" "maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
utilyaml "k8s.io/apimachinery/pkg/util/yaml" utilyaml "k8s.io/apimachinery/pkg/util/yaml"
@ -32,6 +33,7 @@ import (
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/loader/archive"
) )
// ChartLoader loads a chart. // ChartLoader loads a chart.
@ -66,21 +68,16 @@ func Load(name string) (*chart.Chart, error) {
return l.Load() return l.Load()
} }
// BufferedFile represents an archive file buffered for later processing.
type BufferedFile struct {
Name string
Data []byte
}
// LoadFiles loads from in-memory files. // LoadFiles loads from in-memory files.
func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) {
c := new(chart.Chart) c := new(chart.Chart)
subcharts := make(map[string][]*BufferedFile) subcharts := make(map[string][]*archive.BufferedFile)
var subChartsKeys []string
// do not rely on assumed ordering of files in the chart and crash // do not rely on assumed ordering of files in the chart and crash
// if Chart.yaml was not coming early enough to initialize metadata // if Chart.yaml was not coming early enough to initialize metadata
for _, f := range files { for _, f := range files {
c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) c.Raw = append(c.Raw, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
if f.Name == "Chart.yaml" { if f.Name == "Chart.yaml" {
if c.Metadata == nil { if c.Metadata == nil {
c.Metadata = new(chart.Metadata) c.Metadata = new(chart.Metadata)
@ -94,6 +91,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
if c.Metadata.APIVersion == "" { if c.Metadata.APIVersion == "" {
c.Metadata.APIVersion = chart.APIVersionV3 c.Metadata.APIVersion = chart.APIVersionV3
} }
c.ModTime = f.ModTime
} }
} }
for _, f := range files { for _, f := range files {
@ -114,20 +112,24 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
c.Values = values c.Values = values
case f.Name == "values.schema.json": case f.Name == "values.schema.json":
c.Schema = f.Data c.Schema = f.Data
c.SchemaModTime = f.ModTime
case strings.HasPrefix(f.Name, "templates/"): case strings.HasPrefix(f.Name, "templates/"):
c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime})
case strings.HasPrefix(f.Name, "charts/"): case strings.HasPrefix(f.Name, "charts/"):
if filepath.Ext(f.Name) == ".prov" { if filepath.Ext(f.Name) == ".prov" {
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data, ModTime: f.ModTime})
continue continue
} }
fname := strings.TrimPrefix(f.Name, "charts/") fname := strings.TrimPrefix(f.Name, "charts/")
cname := strings.SplitN(fname, "/", 2)[0] cname := strings.SplitN(fname, "/", 2)[0]
subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) if slices.Index(subChartsKeys, cname) == -1 {
subChartsKeys = append(subChartsKeys, cname)
}
subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data})
default: default:
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
} }
} }
@ -155,7 +157,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
default: default:
// We have to trim the prefix off of every file, and ignore any file // We have to trim the prefix off of every file, and ignore any file
// that is in charts/, but isn't actually a chart. // that is in charts/, but isn't actually a chart.
buff := make([]*BufferedFile, 0, len(files)) buff := make([]*archive.BufferedFile, 0, len(files))
for _, f := range files { for _, f := range files {
parts := strings.SplitN(f.Name, "/", 2) parts := strings.SplitN(f.Name, "/", 2)
if len(parts) < 2 { if len(parts) < 2 {

@ -32,6 +32,7 @@ import (
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/loader/archive"
) )
func TestLoadDir(t *testing.T) { func TestLoadDir(t *testing.T) {
@ -183,9 +184,11 @@ func TestLoadFile(t *testing.T) {
} }
func TestLoadFiles(t *testing.T) { func TestLoadFiles(t *testing.T) {
goodFiles := []*BufferedFile{ modTime := time.Now()
goodFiles := []*archive.BufferedFile{
{ {
Name: "Chart.yaml", Name: "Chart.yaml",
ModTime: modTime,
Data: []byte(`apiVersion: v3 Data: []byte(`apiVersion: v3
name: frobnitz name: frobnitz
description: This is a frobnitz. description: This is a frobnitz.
@ -207,18 +210,22 @@ icon: https://example.com/64x64.png
}, },
{ {
Name: "values.yaml", Name: "values.yaml",
ModTime: modTime,
Data: []byte("var: some values"), Data: []byte("var: some values"),
}, },
{ {
Name: "values.schema.json", Name: "values.schema.json",
ModTime: modTime,
Data: []byte("type: Values"), Data: []byte("type: Values"),
}, },
{ {
Name: "templates/deployment.yaml", Name: "templates/deployment.yaml",
ModTime: modTime,
Data: []byte("some deployment"), Data: []byte("some deployment"),
}, },
{ {
Name: "templates/service.yaml", Name: "templates/service.yaml",
ModTime: modTime,
Data: []byte("some service"), Data: []byte("some service"),
}, },
} }
@ -248,7 +255,7 @@ icon: https://example.com/64x64.png
t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) t.Errorf("Expected number of templates == 2, got %d", len(c.Templates))
} }
if _, err = LoadFiles([]*BufferedFile{}); err == nil { if _, err = LoadFiles([]*archive.BufferedFile{}); err == nil {
t.Fatal("Expected err to be non-nil") t.Fatal("Expected err to be non-nil")
} }
if err.Error() != "Chart.yaml file is missing" { if err.Error() != "Chart.yaml file is missing" {
@ -259,26 +266,32 @@ icon: https://example.com/64x64.png
// Test the order of file loading. The Chart.yaml file needs to come first for // Test the order of file loading. The Chart.yaml file needs to come first for
// later comparison checks. See https://github.com/helm/helm/pull/8948 // later comparison checks. See https://github.com/helm/helm/pull/8948
func TestLoadFilesOrder(t *testing.T) { func TestLoadFilesOrder(t *testing.T) {
goodFiles := []*BufferedFile{ modTime := time.Now()
goodFiles := []*archive.BufferedFile{
{ {
Name: "requirements.yaml", Name: "requirements.yaml",
ModTime: modTime,
Data: []byte("dependencies:"), Data: []byte("dependencies:"),
}, },
{ {
Name: "values.yaml", Name: "values.yaml",
ModTime: modTime,
Data: []byte("var: some values"), Data: []byte("var: some values"),
}, },
{ {
Name: "templates/deployment.yaml", Name: "templates/deployment.yaml",
ModTime: modTime,
Data: []byte("some deployment"), Data: []byte("some deployment"),
}, },
{ {
Name: "templates/service.yaml", Name: "templates/service.yaml",
ModTime: modTime,
Data: []byte("some service"), Data: []byte("some service"),
}, },
{ {
Name: "Chart.yaml", Name: "Chart.yaml",
ModTime: modTime,
Data: []byte(`apiVersion: v3 Data: []byte(`apiVersion: v3
name: frobnitz name: frobnitz
description: This is a frobnitz. description: This is a frobnitz.

@ -218,9 +218,10 @@ httpRoute:
# value: v2 # value: v2
resources: {} resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious # For publicly distributed charts, we recommend leaving 'resources' commented out.
# choice for the user. This also increases chances charts run on environments with little # This makes resource allocation a conscious choice for the user and increases the chances
# resources, such as Minikube. If you do want to specify resources, uncomment the following # charts run on a wide range of environments from low-resource clusters like Minikube to those
# with strict resource policies. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'. # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits: # limits:
# cpu: 100m # cpu: 100m
@ -660,7 +661,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error {
for _, template := range schart.Templates { for _, template := range schart.Templates {
newData := transform(string(template.Data), schart.Name()) newData := transform(string(template.Data), schart.Name())
updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData})
} }
schart.Templates = updatedTemplates schart.Templates = updatedTemplates

@ -27,12 +27,12 @@ import (
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
chart "helm.sh/helm/v4/internal/chart/v3" chart "helm.sh/helm/v4/internal/chart/v3"
"helm.sh/helm/v4/internal/chart/v3/loader" "helm.sh/helm/v4/pkg/chart/loader/archive"
) )
// Expand uncompresses and extracts a chart into the specified directory. // Expand uncompresses and extracts a chart into the specified directory.
func Expand(dir string, r io.Reader) error { func Expand(dir string, r io.Reader) error {
files, err := loader.LoadArchiveFiles(r) files, err := archive.LoadArchiveFiles(r)
if err != nil { if err != nil {
return err return err
} }

@ -166,7 +166,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil {
return err return err
} }
@ -176,7 +176,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil {
return err return err
} }
} }
@ -184,7 +184,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save values.yaml // Save values.yaml
for _, f := range c.Raw { for _, f := range c.Raw {
if f.Name == ValuesfileName { if f.Name == ValuesfileName {
if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -195,7 +195,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if !json.Valid(c.Schema) { if !json.Valid(c.Schema) {
return errors.New("invalid JSON in " + SchemafileName) return errors.New("invalid JSON in " + SchemafileName)
} }
if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil {
return err return err
} }
} }
@ -203,7 +203,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save templates // Save templates
for _, f := range c.Templates { for _, f := range c.Templates {
n := filepath.Join(base, f.Name) n := filepath.Join(base, f.Name)
if err := writeToTar(out, n, f.Data); err != nil { if err := writeToTar(out, n, f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -211,7 +211,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save files // Save files
for _, f := range c.Files { for _, f := range c.Files {
n := filepath.Join(base, f.Name) n := filepath.Join(base, f.Name)
if err := writeToTar(out, n, f.Data); err != nil { if err := writeToTar(out, n, f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -226,13 +226,16 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
} }
// writeToTar writes a single file to a tar archive. // writeToTar writes a single file to a tar archive.
func writeToTar(out *tar.Writer, name string, body []byte) error { func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error {
// TODO: Do we need to create dummy parent directory names if none exist? // TODO: Do we need to create dummy parent directory names if none exist?
h := &tar.Header{ h := &tar.Header{
Name: filepath.ToSlash(name), Name: filepath.ToSlash(name),
Mode: 0644, Mode: 0644,
Size: int64(len(body)), Size: int64(len(body)),
ModTime: time.Now(), ModTime: modTime,
}
if h.ModTime.IsZero() {
h.ModTime = time.Now()
} }
if err := out.WriteHeader(h); err != nil { if err := out.WriteHeader(h); err != nil {
return err return err

@ -20,6 +20,8 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/sha256"
"fmt"
"io" "io"
"os" "os"
"path" "path"
@ -49,7 +51,7 @@ func TestSave(t *testing.T) {
Digest: "testdigest", Digest: "testdigest",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")},
}, },
Schema: []byte("{\n \"title\": \"Values\"\n}"), Schema: []byte("{\n \"title\": \"Values\"\n}"),
} }
@ -115,7 +117,7 @@ func TestSave(t *testing.T) {
Digest: "testdigest", Digest: "testdigest",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")},
}, },
} }
_, err := Save(c, tmp) _, err := Save(c, tmp)
@ -141,7 +143,6 @@ func TestSavePreservesTimestamps(t *testing.T) {
// check will fail because `initialCreateTime` will be identical to the // check will fail because `initialCreateTime` will be identical to the
// written timestamp for the files. // written timestamp for the files.
initialCreateTime := time.Now().Add(-1 * time.Second) initialCreateTime := time.Now().Add(-1 * time.Second)
tmp := t.TempDir() tmp := t.TempDir()
c := &chart.Chart{ c := &chart.Chart{
@ -150,14 +151,16 @@ func TestSavePreservesTimestamps(t *testing.T) {
Name: "ahab", Name: "ahab",
Version: "1.2.3", Version: "1.2.3",
}, },
ModTime: initialCreateTime,
Values: map[string]interface{}{ Values: map[string]interface{}{
"imageName": "testimage", "imageName": "testimage",
"imageId": 42, "imageId": 42,
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")},
}, },
Schema: []byte("{\n \"title\": \"Values\"\n}"), Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: initialCreateTime,
} }
where, err := Save(c, tmp) where, err := Save(c, tmp)
@ -170,8 +173,9 @@ func TestSavePreservesTimestamps(t *testing.T) {
t.Fatalf("Failed to parse tar: %v", err) t.Fatalf("Failed to parse tar: %v", err)
} }
roundedTime := initialCreateTime.Round(time.Second)
for _, header := range allHeaders { for _, header := range allHeaders {
if header.ModTime.Before(initialCreateTime) { if !header.ModTime.Equal(roundedTime) {
t.Fatalf("File timestamp not preserved: %v", header.ModTime) t.Fatalf("File timestamp not preserved: %v", header.ModTime)
} }
} }
@ -213,6 +217,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) {
func TestSaveDir(t *testing.T) { func TestSaveDir(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
modTime := time.Now()
c := &chart.Chart{ c := &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
@ -221,10 +226,10 @@ func TestSaveDir(t *testing.T) {
Version: "1.2.3", Version: "1.2.3",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
}, },
Templates: []*common.File{ Templates: []*common.File{
{Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")},
}, },
} }
@ -260,3 +265,92 @@ func TestSaveDir(t *testing.T) {
t.Fatalf("Did not get expected error for chart named %q", c.Name()) t.Fatalf("Did not get expected error for chart named %q", c.Name())
} }
} }
func TestRepeatableSave(t *testing.T) {
tmp := t.TempDir()
defer os.RemoveAll(tmp)
modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC)
tests := []struct {
name string
chart *chart.Chart
want string
}{
{
name: "Package 1 file",
chart: &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV3,
Name: "ahab",
Version: "1.2.3",
},
ModTime: modTime,
Lock: &chart.Lock{
Digest: "testdigest",
Generated: modTime,
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: modTime,
},
want: "5bfea18cc3c8cbc265744bc32bffa9489a4dbe87d6b51b90f4255e4839d35e03",
},
{
name: "Package 2 files",
chart: &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV3,
Name: "ahab",
Version: "1.2.3",
},
ModTime: modTime,
Lock: &chart.Lock{
Digest: "testdigest",
Generated: modTime,
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
{Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: modTime,
},
want: "a240365c21e0a2f4a57873132a9b686566a612d08bcb3f20c9446bfff005ccce",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// create package
dest := path.Join(tmp, "newdir")
where, err := Save(test.chart, dest)
if err != nil {
t.Fatalf("Failed to save: %s", err)
}
// get shasum for package
result, err := sha256Sum(where)
if err != nil {
t.Fatalf("Failed to check shasum: %s", err)
}
// assert that the package SHA is what we wanted.
if result != test.want {
t.Errorf("FormatName() result = %v, want %v", result, test.want)
}
})
}
}
func sha256Sum(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

@ -19,24 +19,24 @@ package output
import ( import (
"github.com/fatih/color" "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 // 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 // Disable color if requested
if noColor { if noColor {
return status.String() return status.String()
} }
switch status { switch status {
case release.StatusDeployed: case common.StatusDeployed:
return color.GreenString(status.String()) return color.GreenString(status.String())
case release.StatusFailed: case common.StatusFailed:
return color.RedString(status.String()) 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()) return color.YellowString(status.String())
case release.StatusUnknown: case common.StatusUnknown:
return color.RedString(status.String()) return color.RedString(status.String())
default: default:
// For uninstalled, superseded, and any other status // For uninstalled, superseded, and any other status

@ -20,63 +20,63 @@ import (
"strings" "strings"
"testing" "testing"
release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/release/common"
) )
func TestColorizeStatus(t *testing.T) { func TestColorizeStatus(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
status release.Status status common.Status
noColor bool noColor bool
envNoColor string envNoColor string
wantColor bool // whether we expect color codes in output wantColor bool // whether we expect color codes in output
}{ }{
{ {
name: "deployed status with color", name: "deployed status with color",
status: release.StatusDeployed, status: common.StatusDeployed,
noColor: false, noColor: false,
envNoColor: "", envNoColor: "",
wantColor: true, wantColor: true,
}, },
{ {
name: "deployed status without color flag", name: "deployed status without color flag",
status: release.StatusDeployed, status: common.StatusDeployed,
noColor: true, noColor: true,
envNoColor: "", envNoColor: "",
wantColor: false, wantColor: false,
}, },
{ {
name: "deployed status with NO_COLOR env", name: "deployed status with NO_COLOR env",
status: release.StatusDeployed, status: common.StatusDeployed,
noColor: false, noColor: false,
envNoColor: "1", envNoColor: "1",
wantColor: false, wantColor: false,
}, },
{ {
name: "failed status with color", name: "failed status with color",
status: release.StatusFailed, status: common.StatusFailed,
noColor: false, noColor: false,
envNoColor: "", envNoColor: "",
wantColor: true, wantColor: true,
}, },
{ {
name: "pending install status with color", name: "pending install status with color",
status: release.StatusPendingInstall, status: common.StatusPendingInstall,
noColor: false, noColor: false,
envNoColor: "", envNoColor: "",
wantColor: true, wantColor: true,
}, },
{ {
name: "unknown status with color", name: "unknown status with color",
status: release.StatusUnknown, status: common.StatusUnknown,
noColor: false, noColor: false,
envNoColor: "", envNoColor: "",
wantColor: true, wantColor: true,
}, },
{ {
name: "superseded status with color", name: "superseded status with color",
status: release.StatusSuperseded, status: common.StatusSuperseded,
noColor: false, noColor: false,
envNoColor: "", envNoColor: "",
wantColor: false, // superseded doesn't get colored wantColor: false, // superseded doesn't get colored

@ -20,9 +20,12 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
// TestAtomicWriteFile tests the happy path of AtomicWriteFile function.
// It verifies that the function correctly writes content to a file with the specified mode.
func TestAtomicWriteFile(t *testing.T) { func TestAtomicWriteFile(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
@ -55,3 +58,64 @@ func TestAtomicWriteFile(t *testing.T) {
mode, gotinfo.Mode()) mode, gotinfo.Mode())
} }
} }
// TestAtomicWriteFile_CreateTempError tests the error path when os.CreateTemp fails
func TestAtomicWriteFile_CreateTempError(t *testing.T) {
invalidPath := "/invalid/path/that/does/not/exist/testfile"
reader := bytes.NewReader([]byte("test content"))
mode := os.FileMode(0644)
err := AtomicWriteFile(invalidPath, reader, mode)
if err == nil {
t.Error("Expected error when CreateTemp fails, but got nil")
}
}
// TestAtomicWriteFile_EmptyContent tests with empty content
func TestAtomicWriteFile_EmptyContent(t *testing.T) {
dir := t.TempDir()
testpath := filepath.Join(dir, "empty_helm")
reader := bytes.NewReader([]byte(""))
mode := os.FileMode(0644)
err := AtomicWriteFile(testpath, reader, mode)
if err != nil {
t.Errorf("AtomicWriteFile error with empty content: %s", err)
}
got, err := os.ReadFile(testpath)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("expected empty content, got: %s", string(got))
}
}
// TestAtomicWriteFile_LargeContent tests with large content
func TestAtomicWriteFile_LargeContent(t *testing.T) {
dir := t.TempDir()
testpath := filepath.Join(dir, "large_test")
// Create a large content string
largeContent := strings.Repeat("HELM", 1024*1024)
reader := bytes.NewReader([]byte(largeContent))
mode := os.FileMode(0644)
err := AtomicWriteFile(testpath, reader, mode)
if err != nil {
t.Errorf("AtomicWriteFile error with large content: %s", err)
}
got, err := os.ReadFile(testpath)
if err != nil {
t.Fatal(err)
}
if largeContent != string(got) {
t.Fatalf("expected large content to match, got different length: %d vs %d", len(largeContent), len(got))
}
}

@ -20,6 +20,7 @@ import (
"context" "context"
"log/slog" "log/slog"
"os" "os"
"sync/atomic"
) )
// DebugEnabledFunc is a function type that determines if debug logging is enabled // DebugEnabledFunc is a function type that determines if debug logging is enabled
@ -85,3 +86,37 @@ func NewLogger(debugEnabled DebugEnabledFunc) *slog.Logger {
return slog.New(dynamicHandler) return slog.New(dynamicHandler)
} }
// LoggerSetterGetter is an interface that can set and get a logger
type LoggerSetterGetter interface {
// SetLogger sets a new slog.Handler
SetLogger(newHandler slog.Handler)
// Logger returns the slog.Logger created from the slog.Handler
Logger() *slog.Logger
}
type LogHolder struct {
// logger is an atomic.Pointer[slog.Logger] to store the slog.Logger
// We use atomic.Pointer for thread safety
logger atomic.Pointer[slog.Logger]
}
// Logger returns the logger for the LogHolder. If nil, returns slog.Default().
func (l *LogHolder) Logger() *slog.Logger {
if lg := l.logger.Load(); lg != nil {
return lg
}
return slog.New(slog.DiscardHandler) // Should never be reached
}
// SetLogger sets the logger for the LogHolder. If nil, sets the default logger.
func (l *LogHolder) SetLogger(newHandler slog.Handler) {
if newHandler == nil {
l.logger.Store(slog.New(slog.DiscardHandler)) // Assume nil as discarding logs
return
}
l.logger.Store(slog.New(newHandler))
}
// Ensure LogHolder implements LoggerSetterGetter
var _ LoggerSetterGetter = &LogHolder{}

@ -0,0 +1,115 @@
/*
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 logging
import (
"bytes"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLogHolder_Logger(t *testing.T) {
t.Run("should return new logger with a then set handler", func(t *testing.T) {
holder := &LogHolder{}
buf := &bytes.Buffer{}
handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})
holder.SetLogger(handler)
logger := holder.Logger()
assert.NotNil(t, logger)
// Test that the logger works
logger.Info("test message")
assert.Contains(t, buf.String(), "test message")
})
t.Run("should return discard - defaultlogger when no handler is set", func(t *testing.T) {
holder := &LogHolder{}
logger := holder.Logger()
assert.Equal(t, slog.Handler(slog.DiscardHandler), logger.Handler())
})
}
func TestLogHolder_SetLogger(t *testing.T) {
t.Run("sets logger with valid handler", func(t *testing.T) {
holder := &LogHolder{}
buf := &bytes.Buffer{}
handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})
holder.SetLogger(handler)
logger := holder.Logger()
assert.NotNil(t, logger)
// Compare the handler directly
assert.Equal(t, handler, logger.Handler())
})
t.Run("sets discard logger with nil handler", func(t *testing.T) {
holder := &LogHolder{}
holder.SetLogger(nil)
logger := holder.Logger()
assert.NotNil(t, logger)
assert.Equal(t, slog.Handler(slog.DiscardHandler), logger.Handler())
})
t.Run("can replace existing logger", func(t *testing.T) {
holder := &LogHolder{}
// Set first logger
buf1 := &bytes.Buffer{}
handler1 := slog.NewTextHandler(buf1, &slog.HandlerOptions{Level: slog.LevelDebug})
holder.SetLogger(handler1)
logger1 := holder.Logger()
assert.Equal(t, handler1, logger1.Handler())
// Replace with second logger
buf2 := &bytes.Buffer{}
handler2 := slog.NewTextHandler(buf2, &slog.HandlerOptions{Level: slog.LevelDebug})
holder.SetLogger(handler2)
logger2 := holder.Logger()
assert.Equal(t, handler2, logger2.Handler())
})
}
func TestLogHolder_InterfaceCompliance(t *testing.T) {
t.Run("implements LoggerSetterGetter interface", func(_ *testing.T) {
var _ LoggerSetterGetter = &LogHolder{}
})
t.Run("interface methods work correctly", func(t *testing.T) {
var holder LoggerSetterGetter = &LogHolder{}
buf := &bytes.Buffer{}
handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})
holder.SetLogger(handler)
logger := holder.Logger()
assert.NotNil(t, logger)
assert.Equal(t, handler, logger.Handler())
})
}

@ -85,10 +85,10 @@ func NewExtractor(source string) (Extractor, error) {
// //
// - The character `:` is considered illegal because it is a separator on UNIX and a // - The character `:` is considered illegal because it is a separator on UNIX and a
// drive designator on Windows. // drive designator on Windows.
// - The path component `..` is considered suspicions, and therefore illegal // - The path component `..` is considered suspicious, and therefore illegal
// - The character \ (backslash) is treated as a path separator and is converted to /. // - The character \ (backslash) is treated as a path separator and is converted to /.
// - Beginning a path with a path separator is illegal // - Beginning a path with a path separator is illegal
// - Rudimentary symlink protects are offered by SecureJoin. // - Rudimentary symlink protections are offered by SecureJoin.
func cleanJoin(root, dest string) (string, error) { func cleanJoin(root, dest string) (string, error) {
// On Windows, this is a drive separator. On UNIX-like, this is the path list separator. // On Windows, this is a drive separator. On UNIX-like, this is the path list separator.
@ -185,8 +185,8 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
func stripPluginName(name string) string { func stripPluginName(name string) string {
var strippedName string var strippedName string
for suffix := range Extractors { for suffix := range Extractors {
if strings.HasSuffix(name, suffix) { if before, ok := strings.CutSuffix(name, suffix); ok {
strippedName = strings.TrimSuffix(name, suffix) strippedName = before
break break
} }
} }

@ -18,6 +18,7 @@ package installer
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -79,6 +80,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error)
return nil, err return nil, err
} }
if _, pathErr := os.Stat(i.Path()); !os.IsNotExist(pathErr) { 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") return nil, errors.New("plugin already exists")
} }
@ -130,30 +132,38 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error)
// Update updates a plugin. // Update updates a plugin.
func Update(i Installer) error { func Update(i Installer) error {
if _, pathErr := os.Stat(i.Path()); os.IsNotExist(pathErr) { 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 errors.New("plugin does not exist")
} }
return i.Update() return i.Update()
} }
// NewForSource determines the correct Installer for the given source. // NewForSource determines the correct Installer for the given source.
func NewForSource(source, version string) (Installer, error) { func NewForSource(source, version string) (installer Installer, err error) {
// Check if source is an OCI registry reference
if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) { if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) {
return NewOCIInstaller(source) // Source is an OCI registry reference
} installer, err = NewOCIInstaller(source)
// Check if source is a local directory } else if isLocalReference(source) {
if isLocalReference(source) { // Source is a local directory
return NewLocalInstaller(source) installer, err = NewLocalInstaller(source)
} else if isRemoteHTTPArchive(source) { } else if isRemoteHTTPArchive(source) {
return NewHTTPInstaller(source) installer, err = NewHTTPInstaller(source)
} else {
installer, err = NewVCSInstaller(source, version)
}
if err != nil {
return installer, fmt.Errorf("cannot get information about plugin source %q (if it's a local directory, does it exist?), last error was: %w", source, err)
} }
return NewVCSInstaller(source, version)
return
} }
// FindSource determines the correct Installer for the given source. // FindSource determines the correct Installer for the given source.
func FindSource(location string) (Installer, error) { func FindSource(location string) (Installer, error) {
installer, err := existingVCSRepo(location) installer, err := existingVCSRepo(location)
if err != nil && err.Error() == "Cannot detect VCS" { 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, errors.New("cannot get information about plugin source")
} }
return installer, err return installer, err

@ -29,8 +29,8 @@ import (
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
) )
// ErrPluginNotAFolder indicates that the plugin path is not a folder. // ErrPluginNotADirectory indicates that the plugin path is not a directory.
var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") var ErrPluginNotADirectory = errors.New("expected plugin to be a directory (containing a file 'plugin.yaml')")
// LocalInstaller installs plugins from the filesystem. // LocalInstaller installs plugins from the filesystem.
type LocalInstaller struct { type LocalInstaller struct {
@ -91,7 +91,7 @@ func (i *LocalInstaller) installFromDirectory() error {
return err return err
} }
if !stat.IsDir() { if !stat.IsDir() {
return ErrPluginNotAFolder return ErrPluginNotADirectory
} }
if !isPlugin(i.Source) { if !isPlugin(i.Source) {

@ -64,7 +64,7 @@ func TestLocalInstallerNotAFolder(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if err != ErrPluginNotAFolder { if err != ErrPluginNotADirectory {
t.Fatalf("expected error to equal: %q", err) t.Fatalf("expected error to equal: %q", err)
} }
} }

@ -73,27 +73,27 @@ type pluginTypeMeta struct {
var pluginTypes = []pluginTypeMeta{ var pluginTypes = []pluginTypeMeta{
{ {
pluginType: "test/v1", pluginType: "test/v1",
inputType: reflect.TypeOf(schema.InputMessageTestV1{}), inputType: reflect.TypeFor[schema.InputMessageTestV1](),
outputType: reflect.TypeOf(schema.OutputMessageTestV1{}), outputType: reflect.TypeFor[schema.OutputMessageTestV1](),
configType: reflect.TypeOf(schema.ConfigTestV1{}), configType: reflect.TypeFor[schema.ConfigTestV1](),
}, },
{ {
pluginType: "cli/v1", pluginType: "cli/v1",
inputType: reflect.TypeOf(schema.InputMessageCLIV1{}), inputType: reflect.TypeFor[schema.InputMessageCLIV1](),
outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}), outputType: reflect.TypeFor[schema.OutputMessageCLIV1](),
configType: reflect.TypeOf(schema.ConfigCLIV1{}), configType: reflect.TypeFor[schema.ConfigCLIV1](),
}, },
{ {
pluginType: "getter/v1", pluginType: "getter/v1",
inputType: reflect.TypeOf(schema.InputMessageGetterV1{}), inputType: reflect.TypeFor[schema.InputMessageGetterV1](),
outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}), outputType: reflect.TypeFor[schema.OutputMessageGetterV1](),
configType: reflect.TypeOf(schema.ConfigGetterV1{}), configType: reflect.TypeFor[schema.ConfigGetterV1](),
}, },
{ {
pluginType: "postrenderer/v1", pluginType: "postrenderer/v1",
inputType: reflect.TypeOf(schema.InputMessagePostRendererV1{}), inputType: reflect.TypeFor[schema.InputMessagePostRendererV1](),
outputType: reflect.TypeOf(schema.OutputMessagePostRendererV1{}), outputType: reflect.TypeFor[schema.OutputMessagePostRendererV1](),
configType: reflect.TypeOf(schema.ConfigPostRendererV1{}), configType: reflect.TypeFor[schema.ConfigPostRendererV1](),
}, },
} }

@ -29,7 +29,7 @@ import (
"helm.sh/helm/v4/internal/plugin/schema" "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 { type SubprocessProtocolCommand struct {
// Protocols are the list of schemes from the charts URL. // Protocols are the list of schemes from the charts URL.
Protocols []string `yaml:"protocols"` Protocols []string `yaml:"protocols"`

@ -15,13 +15,10 @@ package schema
import ( import (
"bytes" "bytes"
"helm.sh/helm/v4/pkg/cli"
) )
type InputMessageCLIV1 struct { type InputMessageCLIV1 struct {
ExtraArgs []string `json:"extraArgs"` ExtraArgs []string `json:"extraArgs"`
Settings *cli.EnvSettings `json:"settings"`
} }
type OutputMessageCLIV1 struct { type OutputMessageCLIV1 struct {

@ -23,7 +23,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"golang.org/x/crypto/openpgp/clearsign" //nolint "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint
"helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath"
) )

@ -18,6 +18,7 @@ package version // import "helm.sh/helm/v4/internal/version"
import ( import (
"flag" "flag"
"fmt"
"runtime" "runtime"
"strings" "strings"
) )
@ -37,6 +38,11 @@ var (
gitCommit = "" gitCommit = ""
// gitTreeState is the state of the git tree // gitTreeState is the state of the git tree
gitTreeState = "" gitTreeState = ""
// The Kubernetes version can be set by LDFLAGS. In order to do that the value
// must be a string.
kubeClientVersionMajor = ""
kubeClientVersionMinor = ""
) )
// BuildInfo describes the compile time information. // BuildInfo describes the compile time information.
@ -49,6 +55,8 @@ type BuildInfo struct {
GitTreeState string `json:"git_tree_state,omitempty"` GitTreeState string `json:"git_tree_state,omitempty"`
// GoVersion is the version of the Go compiler used. // GoVersion is the version of the Go compiler used.
GoVersion string `json:"go_version,omitempty"` GoVersion string `json:"go_version,omitempty"`
// KubeClientVersion is the version of client-go Helm was build with
KubeClientVersion string `json:"kube_client_version"`
} }
// GetVersion returns the semver string of the version // GetVersion returns the semver string of the version
@ -71,6 +79,7 @@ func Get() BuildInfo {
GitCommit: gitCommit, GitCommit: gitCommit,
GitTreeState: gitTreeState, GitTreeState: gitTreeState,
GoVersion: runtime.Version(), GoVersion: runtime.Version(),
KubeClientVersion: fmt.Sprintf("v%s.%s", kubeClientVersionMajor, kubeClientVersionMinor),
} }
// HACK(bacongobbler): strip out GoVersion during a test run for consistent test output // HACK(bacongobbler): strip out GoVersion during a test run for consistent test output

@ -30,6 +30,7 @@ import (
"strings" "strings"
"sync" "sync"
"text/template" "text/template"
"time"
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
@ -39,6 +40,7 @@ import (
"sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/kio"
kyaml "sigs.k8s.io/kustomize/kyaml/yaml" kyaml "sigs.k8s.io/kustomize/kyaml/yaml"
"helm.sh/helm/v4/internal/logging"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
@ -46,11 +48,11 @@ import (
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
ri "helm.sh/helm/v4/pkg/release"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util" releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
"helm.sh/helm/v4/pkg/time"
) )
// Timestamper is a function capable of producing a timestamp.Timestamper. // Timestamper is a function capable of producing a timestamp.Timestamper.
@ -70,6 +72,21 @@ var (
errPending = errors.New("another operation (install/upgrade/rollback) is in progress") 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. // Configuration injects the dependencies that all actions share.
type Configuration struct { type Configuration struct {
// RESTClientGetter is an interface that loads Kubernetes clients. // RESTClientGetter is an interface that loads Kubernetes clients.
@ -93,7 +110,32 @@ type Configuration struct {
// HookOutputFunc called with container name and returns and expects writer that will receive the log output. // HookOutputFunc called with container name and returns and expects writer that will receive the log output.
HookOutputFunc func(namespace, pod, container string) io.Writer HookOutputFunc func(namespace, pod, container string) io.Writer
// Mutex is an exclusive lock for concurrent access to the action
mutex sync.Mutex mutex sync.Mutex
// Embed a LogHolder to provide logger functionality
logging.LogHolder
}
type ConfigurationOption func(c *Configuration)
// Override the default logging handler
// If unspecified, the default logger will be used
func ConfigurationSetLogger(h slog.Handler) ConfigurationOption {
return func(c *Configuration) {
c.SetLogger(h)
}
}
func NewConfiguration(options ...ConfigurationOption) *Configuration {
c := &Configuration{}
c.SetLogger(slog.Default().Handler())
for _, o := range options {
o(c)
}
return c
} }
const ( const (
@ -360,8 +402,8 @@ func (cfg *Configuration) getCapabilities() (*common.Capabilities, error) {
apiVersions, err := GetVersionSet(dc) apiVersions, err := GetVersionSet(dc)
if err != nil { if err != nil {
if discovery.IsGroupDiscoveryFailedError(err) { if discovery.IsGroupDiscoveryFailedError(err) {
slog.Warn("the kubernetes server has an orphaned API service", slog.Any("error", err)) cfg.Logger().Warn("the kubernetes server has an orphaned API service", slog.Any("error", err))
slog.Warn("to fix this, kubectl delete apiservice <service-name>") cfg.Logger().Warn("to fix this, kubectl delete apiservice <service-name>")
} else { } else {
return nil, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err) return nil, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err)
} }
@ -397,7 +439,7 @@ func (cfg *Configuration) Now() time.Time {
return Timestamper() 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 { if err := chartutil.ValidateReleaseName(name); err != nil {
return nil, fmt.Errorf("releaseContent: Release name is invalid: %s", name) return nil, fmt.Errorf("releaseContent: Release name is invalid: %s", name)
} }
@ -460,13 +502,14 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet
// recordRelease with an update operation in case reuse has been set. // recordRelease with an update operation in case reuse has been set.
func (cfg *Configuration) recordRelease(r *release.Release) { func (cfg *Configuration) recordRelease(r *release.Release) {
if err := cfg.Releases.Update(r); err != nil { if err := cfg.Releases.Update(r); err != nil {
slog.Warn("failed to update release", "name", r.Name, "revision", r.Version, slog.Any("error", err)) cfg.Logger().Warn("failed to update release", "name", r.Name, "revision", r.Version, slog.Any("error", err))
} }
} }
// Init initializes the action configuration // Init initializes the action configuration
func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string) error { func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string) error {
kc := kube.New(getter) kc := kube.New(getter)
kc.SetLogger(cfg.Logger().Handler())
lazyClient := &lazyClient{ lazyClient := &lazyClient{
namespace: namespace, namespace: namespace,
@ -477,9 +520,11 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
switch helmDriver { switch helmDriver {
case "secret", "secrets", "": case "secret", "secrets", "":
d := driver.NewSecrets(newSecretClient(lazyClient)) d := driver.NewSecrets(newSecretClient(lazyClient))
d.SetLogger(cfg.Logger().Handler())
store = storage.Init(d) store = storage.Init(d)
case "configmap", "configmaps": case "configmap", "configmaps":
d := driver.NewConfigMaps(newConfigMapClient(lazyClient)) d := driver.NewConfigMaps(newConfigMapClient(lazyClient))
d.SetLogger(cfg.Logger().Handler())
store = storage.Init(d) store = storage.Init(d)
case "memory": case "memory":
var d *driver.Memory var d *driver.Memory
@ -494,6 +539,7 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
if d == nil { if d == nil {
d = driver.NewMemory() d = driver.NewMemory()
} }
d.SetLogger(cfg.Logger().Handler())
d.SetNamespace(namespace) d.SetNamespace(namespace)
store = storage.Init(d) store = storage.Init(d)
case "sql": case "sql":
@ -504,6 +550,7 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
if err != nil { if err != nil {
return fmt.Errorf("unable to instantiate SQL driver: %w", err) return fmt.Errorf("unable to instantiate SQL driver: %w", err)
} }
d.SetLogger(cfg.Logger().Handler())
store = storage.Init(d) store = storage.Init(d)
default: default:
return fmt.Errorf("unknown driver %q", helmDriver) return fmt.Errorf("unknown driver %q", helmDriver)
@ -528,3 +575,13 @@ func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod {
} }
return release.ApplyMethodClientSideApply 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
}

@ -24,6 +24,7 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -35,10 +36,10 @@ import (
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
"helm.sh/helm/v4/pkg/time"
) )
var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)") var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)")
@ -122,9 +123,10 @@ type chartOptions struct {
type chartOption func(*chartOptions) type chartOption func(*chartOptions)
func buildChart(opts ...chartOption) *chart.Chart { func buildChart(opts ...chartOption) *chart.Chart {
modTime := time.Now()
defaultTemplates := []*common.File{ defaultTemplates := []*common.File{
{Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")},
{Name: "templates/hooks", Data: []byte(manifestWithHook)}, {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifestWithHook)},
} }
return buildChartWithTemplates(defaultTemplates, opts...) return buildChartWithTemplates(defaultTemplates, opts...)
} }
@ -181,6 +183,7 @@ func withNotes(notes string) chartOption {
return func(opts *chartOptions) { return func(opts *chartOptions) {
opts.Templates = append(opts.Templates, &common.File{ opts.Templates = append(opts.Templates, &common.File{
Name: "templates/NOTES.txt", Name: "templates/NOTES.txt",
ModTime: time.Now(),
Data: []byte(notes), Data: []byte(notes),
}) })
} }
@ -200,12 +203,13 @@ func withMetadataDependency(dependency chart.Dependency) chartOption {
func withSampleTemplates() chartOption { func withSampleTemplates() chartOption {
return func(opts *chartOptions) { return func(opts *chartOptions) {
modTime := time.Now()
sampleTemplates := []*common.File{ sampleTemplates := []*common.File{
// This adds basic templates and partials. // This adds basic templates and partials.
{Name: "templates/goodbye", Data: []byte("goodbye: world")}, {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")},
{Name: "templates/empty", Data: []byte("")}, {Name: "templates/empty", ModTime: modTime, Data: []byte("")},
{Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)},
{Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)},
} }
opts.Templates = append(opts.Templates, sampleTemplates...) opts.Templates = append(opts.Templates, sampleTemplates...)
} }
@ -213,20 +217,21 @@ func withSampleTemplates() chartOption {
func withSampleSecret() chartOption { func withSampleSecret() chartOption {
return func(opts *chartOptions) { return func(opts *chartOptions) {
sampleSecret := &common.File{Name: "templates/secret.yaml", Data: []byte("apiVersion: v1\nkind: Secret\n")} sampleSecret := &common.File{Name: "templates/secret.yaml", ModTime: time.Now(), Data: []byte("apiVersion: v1\nkind: Secret\n")}
opts.Templates = append(opts.Templates, sampleSecret) opts.Templates = append(opts.Templates, sampleSecret)
} }
} }
func withSampleIncludingIncorrectTemplates() chartOption { func withSampleIncludingIncorrectTemplates() chartOption {
return func(opts *chartOptions) { return func(opts *chartOptions) {
modTime := time.Now()
sampleTemplates := []*common.File{ sampleTemplates := []*common.File{
// This adds basic templates and partials. // This adds basic templates and partials.
{Name: "templates/goodbye", Data: []byte("goodbye: world")}, {Name: "templates/goodbye", ModTime: modTime, Data: []byte("goodbye: world")},
{Name: "templates/empty", Data: []byte("")}, {Name: "templates/empty", ModTime: modTime, Data: []byte("")},
{Name: "templates/incorrect", Data: []byte("{{ .Values.bad.doh }}")}, {Name: "templates/incorrect", ModTime: modTime, Data: []byte("{{ .Values.bad.doh }}")},
{Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, {Name: "templates/with-partials", ModTime: modTime, Data: []byte(`hello: {{ template "_planet" . }}`)},
{Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, {Name: "templates/partials/_planet", ModTime: modTime, Data: []byte(`{{define "_planet"}}Earth{{end}}`)},
} }
opts.Templates = append(opts.Templates, sampleTemplates...) opts.Templates = append(opts.Templates, sampleTemplates...)
} }
@ -235,7 +240,7 @@ func withSampleIncludingIncorrectTemplates() chartOption {
func withMultipleManifestTemplate() chartOption { func withMultipleManifestTemplate() chartOption {
return func(opts *chartOptions) { return func(opts *chartOptions) {
sampleTemplates := []*common.File{ sampleTemplates := []*common.File{
{Name: "templates/rbac", Data: []byte(rbacManifests)}, {Name: "templates/rbac", ModTime: time.Now(), Data: []byte(rbacManifests)},
} }
opts.Templates = append(opts.Templates, sampleTemplates...) opts.Templates = append(opts.Templates, sampleTemplates...)
} }
@ -249,10 +254,10 @@ func withKube(version string) chartOption {
// releaseStub creates a release stub, complete with the chartStub as its chart. // releaseStub creates a release stub, complete with the chartStub as its chart.
func releaseStub() *release.Release { 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() now := time.Now()
return &release.Release{ return &release.Release{
Name: name, Name: name,
@ -343,7 +348,7 @@ func TestConfiguration_Init(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := &Configuration{} cfg := NewConfiguration()
actualErr := cfg.Init(nil, "default", tt.helmDriver) actualErr := cfg.Init(nil, "default", tt.helmDriver)
if tt.expectErr { if tt.expectErr {
@ -852,7 +857,7 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) {
Version: "0.1.0", Version: "0.1.0",
}, },
Templates: []*common.File{ Templates: []*common.File{
{Name: "templates/invalid", Data: []byte("invalid: yaml: content:")}, {Name: "templates/invalid", ModTime: time.Now(), Data: []byte("invalid: yaml: content:")},
}, },
} }
values := map[string]interface{}{} values := map[string]interface{}{}
@ -951,3 +956,15 @@ func TestDetermineReleaseSSAApplyMethod(t *testing.T) {
assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false)) assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false))
assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true)) 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))
}

@ -17,7 +17,7 @@ limitations under the License.
package action package action
import ( 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. // 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. // 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 { if err := g.cfg.KubeClient.IsReachable(); err != nil {
return nil, err return nil, err
} }

@ -17,11 +17,15 @@ limitations under the License.
package action package action
import ( import (
"errors"
"log/slog"
"sort" "sort"
"strings" "strings"
"time" "time"
ci "helm.sh/helm/v4/pkg/chart"
chart "helm.sh/helm/v4/pkg/chart/v2" 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. // GetMetadata is the action for checking a given release's metadata.
@ -42,7 +46,7 @@ type Metadata struct {
Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
// Labels of the release which are stored in driver metadata fields storage // Labels of the release which are stored in driver metadata fields storage
Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
Dependencies []*chart.Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"` Dependencies []ci.Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
Namespace string `json:"namespace" yaml:"namespace"` Namespace string `json:"namespace" yaml:"namespace"`
Revision int `json:"revision" yaml:"revision"` Revision int `json:"revision" yaml:"revision"`
Status string `json:"status" yaml:"status"` Status string `json:"status" yaml:"status"`
@ -68,19 +72,40 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) {
return nil, err 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{ return &Metadata{
Name: rel.Name, Name: rac.Name(),
Chart: rel.Chart.Metadata.Name, Chart: chrt.Metadata.Name,
Version: rel.Chart.Metadata.Version, Version: chrt.Metadata.Version,
AppVersion: rel.Chart.Metadata.AppVersion, AppVersion: chrt.Metadata.AppVersion,
Dependencies: rel.Chart.Metadata.Dependencies, Dependencies: ac.MetaDependencies(),
Annotations: rel.Chart.Metadata.Annotations, Annotations: chrt.Metadata.Annotations,
Labels: rel.Labels, Labels: rac.Labels(),
Namespace: rel.Namespace, Namespace: rac.Namespace(),
Revision: rel.Version, Revision: rac.Version(),
Status: rel.Info.Status.String(), Status: rac.Status(),
DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339), DeployedAt: rac.DeployedAt().Format(time.RFC3339),
ApplyMethod: rel.ApplyMethod, ApplyMethod: rac.ApplyMethod(),
}, nil }, nil
} }
@ -88,7 +113,13 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) {
func (m *Metadata) FormattedDepNames() string { func (m *Metadata) FormattedDepNames() string {
depsNames := make([]string, 0, len(m.Dependencies)) depsNames := make([]string, 0, len(m.Dependencies))
for _, dep := range 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() sort.StringSlice(depsNames).Sort()

@ -25,21 +25,13 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
ci "helm.sh/helm/v4/pkg/chart"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time"
) )
// unreachableKubeClient is a test client that always returns an error for IsReachable
type unreachableKubeClient struct {
kubefake.PrintingKubeClient
}
func (u *unreachableKubeClient) IsReachable() error {
return errors.New("connection refused")
}
func TestNewGetMetadata(t *testing.T) { func TestNewGetMetadata(t *testing.T) {
cfg := actionConfigFixture(t) cfg := actionConfigFixture(t)
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
@ -54,12 +46,12 @@ func TestGetMetadata_Run_BasicMetadata(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -73,7 +65,8 @@ func TestGetMetadata_Run_BasicMetadata(t *testing.T) {
Namespace: "default", Namespace: "default",
} }
cfg.Releases.Create(rel) err := cfg.Releases.Create(rel)
require.NoError(t, err)
result, err := client.Run(releaseName) result, err := client.Run(releaseName)
require.NoError(t, err) require.NoError(t, err)
@ -95,7 +88,7 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
dependencies := []*chart.Dependency{ dependencies := []*chart.Dependency{
{ {
@ -113,7 +106,7 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -133,13 +126,18 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) {
result, err := client.Run(releaseName) result, err := client.Run(releaseName)
require.NoError(t, err) 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, releaseName, result.Name)
assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "test-chart", result.Chart)
assert.Equal(t, "1.0.0", result.Version) 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.Len(t, result.Dependencies, 2)
assert.Equal(t, "mysql", result.Dependencies[0].Name) assert.Equal(t, "mysql", dep0.Name())
assert.Equal(t, "redis", result.Dependencies[1].Name) assert.Equal(t, "redis", dep1.Name())
} }
func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) {
@ -147,7 +145,7 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
dependencies := []*chart.Dependency{ dependencies := []*chart.Dependency{
{ {
@ -167,7 +165,7 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -187,15 +185,20 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) {
result, err := client.Run(releaseName) result, err := client.Run(releaseName)
require.NoError(t, err) 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, releaseName, result.Name)
assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "test-chart", result.Chart)
assert.Equal(t, "1.0.0", result.Version) 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.Len(t, result.Dependencies, 2)
assert.Equal(t, "mysql", result.Dependencies[0].Name) assert.Equal(t, "mysql", dep0.Name())
assert.Equal(t, "database", result.Dependencies[0].Alias) assert.Equal(t, "database", dep0.Alias())
assert.Equal(t, "redis", result.Dependencies[1].Name) assert.Equal(t, "redis", dep1.Name())
assert.Equal(t, "cache", result.Dependencies[1].Alias) assert.Equal(t, "cache", dep1.Alias())
} }
func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) {
@ -203,7 +206,7 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
dependencies := []*chart.Dependency{ dependencies := []*chart.Dependency{
{ {
@ -233,7 +236,7 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -253,23 +256,32 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) {
result, err := client.Run(releaseName) result, err := client.Run(releaseName)
require.NoError(t, err) 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, releaseName, result.Name)
assert.Equal(t, "test-chart", result.Chart) assert.Equal(t, "test-chart", result.Chart)
assert.Equal(t, "1.0.0", result.Version) 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) assert.Len(t, result.Dependencies, 4)
// Verify dependencies with aliases // Verify dependencies with aliases
assert.Equal(t, "mysql", result.Dependencies[0].Name) assert.Equal(t, "mysql", dep0.Name())
assert.Equal(t, "database", result.Dependencies[0].Alias) assert.Equal(t, "database", dep0.Alias())
assert.Equal(t, "redis", result.Dependencies[2].Name) assert.Equal(t, "redis", dep2.Name())
assert.Equal(t, "cache", result.Dependencies[2].Alias) assert.Equal(t, "cache", dep2.Alias())
// Verify dependencies without aliases // Verify dependencies without aliases
assert.Equal(t, "nginx", result.Dependencies[1].Name) assert.Equal(t, "nginx", dep1.Name())
assert.Equal(t, "", result.Dependencies[1].Alias) assert.Equal(t, "", dep1.Alias())
assert.Equal(t, "postgresql", result.Dependencies[3].Name) assert.Equal(t, "postgresql", dep3.Name())
assert.Equal(t, "", result.Dependencies[3].Alias) assert.Equal(t, "", dep3.Alias())
} }
func TestGetMetadata_Run_WithAnnotations(t *testing.T) { func TestGetMetadata_Run_WithAnnotations(t *testing.T) {
@ -277,7 +289,7 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
annotations := map[string]string{ annotations := map[string]string{
"helm.sh/hook": "pre-install", "helm.sh/hook": "pre-install",
@ -288,7 +300,7 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -322,13 +334,13 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) {
client.Version = 2 client.Version = 2
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
rel1 := &release.Release{ rel1 := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusSuperseded, Status: common.StatusSuperseded,
LastDeployed: helmtime.Time{Time: deployedTime.Time.Add(-time.Hour)}, LastDeployed: deployedTime.Add(-time.Hour),
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
@ -344,7 +356,7 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) {
rel2 := &release.Release{ rel2 := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -378,22 +390,22 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
status release.Status status common.Status
expected string expected string
}{ }{
{"deployed", release.StatusDeployed, "deployed"}, {"deployed", common.StatusDeployed, "deployed"},
{"failed", release.StatusFailed, "failed"}, {"failed", common.StatusFailed, "failed"},
{"uninstalled", release.StatusUninstalled, "uninstalled"}, {"uninstalled", common.StatusUninstalled, "uninstalled"},
{"pending-install", release.StatusPendingInstall, "pending-install"}, {"pending-install", common.StatusPendingInstall, "pending-install"},
{"pending-upgrade", release.StatusPendingUpgrade, "pending-upgrade"}, {"pending-upgrade", common.StatusPendingUpgrade, "pending-upgrade"},
{"pending-rollback", release.StatusPendingRollback, "pending-rollback"}, {"pending-rollback", common.StatusPendingRollback, "pending-rollback"},
{"superseded", release.StatusSuperseded, "superseded"}, {"superseded", common.StatusSuperseded, "superseded"},
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
releaseName := "test-release-" + tc.name releaseName := "test-release-" + tc.name
deployedTime := helmtime.Now() deployedTime := time.Now()
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
@ -424,9 +436,9 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) {
func TestGetMetadata_Run_UnreachableKubeClient(t *testing.T) { func TestGetMetadata_Run_UnreachableKubeClient(t *testing.T) {
cfg := actionConfigFixture(t) cfg := actionConfigFixture(t)
cfg.KubeClient = &unreachableKubeClient{ failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, failingKubeClient.ConnectionError = errors.New("connection refused")
} cfg.KubeClient = &failingKubeClient
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
@ -449,12 +461,12 @@ func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) {
client := NewGetMetadata(cfg) client := NewGetMetadata(cfg)
releaseName := "test-release" releaseName := "test-release"
deployedTime := helmtime.Now() deployedTime := time.Now()
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
LastDeployed: deployedTime, LastDeployed: deployedTime,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
@ -525,8 +537,9 @@ func TestMetadata_FormattedDepNames(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
deps := convertDeps(tc.dependencies)
metadata := &Metadata{ metadata := &Metadata{
Dependencies: tc.dependencies, Dependencies: deps,
} }
result := metadata.FormattedDepNames() result := metadata.FormattedDepNames()
@ -535,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) { func TestMetadata_FormattedDepNames_WithComplexDependencies(t *testing.T) {
dependencies := []*chart.Dependency{ dependencies := []*chart.Dependency{
{ {
@ -556,8 +577,9 @@ func TestMetadata_FormattedDepNames_WithComplexDependencies(t *testing.T) {
}, },
} }
deps := convertDeps(dependencies)
metadata := &Metadata{ metadata := &Metadata{
Dependencies: dependencies, Dependencies: deps,
} }
result := metadata.FormattedDepNames() result := metadata.FormattedDepNames()
@ -607,8 +629,9 @@ func TestMetadata_FormattedDepNames_WithAliases(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
deps := convertDeps(tc.dependencies)
metadata := &Metadata{ metadata := &Metadata{
Dependencies: tc.dependencies, Dependencies: deps,
} }
result := metadata.FormattedDepNames() result := metadata.FormattedDepNames()
@ -619,7 +642,7 @@ func TestMetadata_FormattedDepNames_WithAliases(t *testing.T) {
func TestGetMetadata_Labels(t *testing.T) { func TestGetMetadata_Labels(t *testing.T) {
rel := releaseStub() rel := releaseStub()
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
customLabels := map[string]string{"key1": "value1", "key2": "value2"} customLabels := map[string]string{"key1": "value1", "key2": "value2"}
rel.Labels = customLabels rel.Labels = customLabels

@ -16,7 +16,13 @@ limitations under the License.
package action 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. // 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 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 { if err != nil {
return nil, err return nil, err
} }
@ -56,3 +67,18 @@ func (g *GetValues) Run(name string) (map[string]interface{}, error) {
} }
return rel.Config, nil 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)
}
}

@ -17,6 +17,7 @@ limitations under the License.
package action package action
import ( import (
"errors"
"io" "io"
"testing" "testing"
@ -25,6 +26,7 @@ import (
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
) )
@ -57,7 +59,7 @@ func TestGetValues_Run_UserConfigOnly(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
@ -111,7 +113,7 @@ func TestGetValues_Run_AllValues(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
@ -146,7 +148,7 @@ func TestGetValues_Run_EmptyValues(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
@ -168,9 +170,9 @@ func TestGetValues_Run_EmptyValues(t *testing.T) {
func TestGetValues_Run_UnreachableKubeClient(t *testing.T) { func TestGetValues_Run_UnreachableKubeClient(t *testing.T) {
cfg := actionConfigFixture(t) cfg := actionConfigFixture(t)
cfg.KubeClient = &unreachableKubeClient{ failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, failingKubeClient.ConnectionError = errors.New("connection refused")
} cfg.KubeClient = &failingKubeClient
client := NewGetValues(cfg) client := NewGetValues(cfg)
@ -197,7 +199,7 @@ func TestGetValues_Run_NilConfig(t *testing.T) {
rel := &release.Release{ rel := &release.Release{
Name: releaseName, Name: releaseName,
Info: &release.Info{ Info: &release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}, },
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{

@ -17,12 +17,10 @@ limitations under the License.
package action package action
import ( import (
"log/slog"
"fmt" "fmt"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" 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. // History is the action for checking the release's ledger.
@ -46,7 +44,7 @@ func NewHistory(cfg *Configuration) *History {
} }
// Run executes 'helm history' against the given release. // 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 { if err := h.cfg.KubeClient.IsReachable(); err != nil {
return nil, err return nil, err
} }
@ -55,6 +53,6 @@ func (h *History) Run(name string) ([]*release.Release, error) {
return nil, fmt.Errorf("release name is invalid: %s", name) return nil, fmt.Errorf("release name is invalid: %s", name)
} }
slog.Debug("getting history for release", "release", name) h.cfg.Logger().Debug("getting history for release", "release", name)
return h.cfg.Releases.History(name) return h.cfg.Releases.History(name)
} }

@ -29,7 +29,6 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time"
) )
// execHook executes all of the hooks for the given hook event. // execHook executes all of the hooks for the given hook event.
@ -62,7 +61,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent,
// Record the time at which the hook was applied to the cluster // Record the time at which the hook was applied to the cluster
h.LastRun = release.HookExecution{ h.LastRun = release.HookExecution{
StartedAt: helmtime.Now(), StartedAt: time.Now(),
Phase: release.HookPhaseRunning, Phase: release.HookPhaseRunning,
} }
cfg.recordRelease(rl) cfg.recordRelease(rl)
@ -76,7 +75,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent,
if _, err := cfg.KubeClient.Create( if _, err := cfg.KubeClient.Create(
resources, resources,
kube.ClientCreateOptionServerSideApply(serverSideApply, false)); err != nil { kube.ClientCreateOptionServerSideApply(serverSideApply, false)); err != nil {
h.LastRun.CompletedAt = helmtime.Now() h.LastRun.CompletedAt = time.Now()
h.LastRun.Phase = release.HookPhaseFailed h.LastRun.Phase = release.HookPhaseFailed
return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err) return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err)
} }
@ -88,7 +87,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent,
// Watch hook resources until they have completed // Watch hook resources until they have completed
err = waiter.WatchUntilReady(resources, timeout) err = waiter.WatchUntilReady(resources, timeout)
// Note the time of success/failure // Note the time of success/failure
h.LastRun.CompletedAt = helmtime.Now() h.LastRun.CompletedAt = time.Now()
// Mark hook as succeeded or failed // Mark hook as succeeded or failed
if err != nil { if err != nil {
h.LastRun.Phase = release.HookPhaseFailed h.LastRun.Phase = release.HookPhaseFailed
@ -155,7 +154,7 @@ func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.Hoo
if err != nil { if err != nil {
return fmt.Errorf("unable to build kubernetes object for deleting hook %s: %w", h.Path, err) 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 { if len(errs) > 0 {
return joinErrors(errs, "; ") return joinErrors(errs, "; ")
} }
@ -224,16 +223,12 @@ func (cfg *Configuration) outputLogsByPolicy(h *release.Hook, releaseNamespace s
} }
func (cfg *Configuration) outputContainerLogsForListOptions(namespace string, listOptions metav1.ListOptions) error { 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 podList, err := cfg.KubeClient.GetPodList(namespace, listOptions)
if kubeClient, ok := cfg.KubeClient.(kube.InterfaceLogs); ok {
podList, err := kubeClient.GetPodList(namespace, listOptions)
if err != nil { if err != nil {
return err return err
} }
err = kubeClient.OutputContainerLogsForPodList(podList, namespace, cfg.HookOutputFunc)
return err return cfg.KubeClient.OutputContainerLogsForPodList(podList, namespace, cfg.HookOutputFunc)
}
return nil
} }
func (cfg *Configuration) deriveNamespace(h *release.Hook, namespace string) (string, error) { func (cfg *Configuration) deriveNamespace(h *release.Hook, namespace string) (string, error) {

@ -21,17 +21,20 @@ import (
"fmt" "fmt"
"io" "io"
"reflect" "reflect"
"strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/yaml" "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/cli-runtime/pkg/resource" "k8s.io/cli-runtime/pkg/resource"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
@ -112,15 +115,15 @@ spec:
} }
func convertHooksToCommaSeparated(hookDefinitions []release.HookOutputLogPolicy) string { func convertHooksToCommaSeparated(hookDefinitions []release.HookOutputLogPolicy) string {
var commaSeparated string var commaSeparated strings.Builder
for i, policy := range hookDefinitions { for i, policy := range hookDefinitions {
if i+1 == len(hookDefinitions) { if i+1 == len(hookDefinitions) {
commaSeparated += policy.String() commaSeparated.WriteString(policy.String())
} else { } else {
commaSeparated += policy.String() + "," commaSeparated.WriteString(policy.String() + ",")
} }
} }
return commaSeparated return commaSeparated.String()
} }
func TestInstallRelease_HookOutputLogsOnFailure(t *testing.T) { func TestInstallRelease_HookOutputLogsOnFailure(t *testing.T) {
@ -177,16 +180,19 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str
outBuffer := &bytes.Buffer{} outBuffer := &bytes.Buffer{}
instAction.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} instAction.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
modTime := time.Now()
templates := []*common.File{ templates := []*common.File{
{Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")},
{Name: "templates/hooks", Data: []byte(manifest)}, {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)},
} }
vals := map[string]interface{}{} 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.NoError(err)
is.Equal(expectedOutput, outBuffer.String()) 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) { func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace string, shouldOutput bool) {
@ -204,17 +210,20 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str
outBuffer := &bytes.Buffer{} outBuffer := &bytes.Buffer{}
failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
modTime := time.Now()
templates := []*common.File{ templates := []*common.File{
{Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")},
{Name: "templates/hooks", Data: []byte(manifest)}, {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)},
} }
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChartWithTemplates(templates), vals) resi, err := instAction.Run(buildChartWithTemplates(templates), vals)
is.Error(err) is.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "failed pre-install") is.Contains(res.Info.Description, "failed pre-install")
is.Equal(expectedOutput, outBuffer.String()) is.Equal(expectedOutput, outBuffer.String())
is.Equal(release.StatusFailed, res.Info.Status) is.Equal(rcommon.StatusFailed, res.Info.Status)
} }
type HookFailedError struct{} type HookFailedError struct{}
@ -258,7 +267,7 @@ func (h *HookFailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ t
return nil 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 { for _, res := range resources {
h.deleteRecord = append(h.deleteRecord, resource.Info{ h.deleteRecord = append(h.deleteRecord, resource.Info{
Name: res.Name, Name: res.Name,
@ -266,7 +275,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) { func (h *HookFailingKubeClient) GetWaiter(strategy kube.WaitStrategy) (kube.Waiter, error) {

@ -26,10 +26,10 @@ import (
"log/slog" "log/slog"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"text/template" "text/template"
"time" "time"
@ -41,6 +41,7 @@ import (
"k8s.io/cli-runtime/pkg/resource" "k8s.io/cli-runtime/pkg/resource"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
ci "helm.sh/helm/v4/pkg/chart"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/common/util" "helm.sh/helm/v4/pkg/chart/common/util"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
@ -52,6 +53,8 @@ import (
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "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" release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util" releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/repo/v1" "helm.sh/helm/v4/pkg/repo/v1"
@ -73,7 +76,6 @@ type Install struct {
ChartPathOptions ChartPathOptions
ClientOnly bool
// ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway. // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway.
// //
// This should be used with caution. // This should be used with caution.
@ -85,8 +87,8 @@ type Install struct {
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/
ServerSideApply bool ServerSideApply bool
CreateNamespace bool CreateNamespace bool
DryRun bool // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster
DryRunOption string DryRunStrategy DryRunStrategy
// HideSecret can be set to true when DryRun is enabled in order to hide // 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. // Kubernetes Secrets in the output. It cannot be used outside of DryRun.
HideSecret bool HideSecret bool
@ -129,6 +131,7 @@ type Install struct {
PostRenderer postrenderer.PostRenderer PostRenderer postrenderer.PostRenderer
// Lock to control raceconditions when the process receives a SIGTERM // Lock to control raceconditions when the process receives a SIGTERM
Lock sync.Mutex Lock sync.Mutex
goroutineCount atomic.Int32
} }
// ChartPathOptions captures common options used for controlling chart paths // ChartPathOptions captures common options used for controlling chart paths
@ -156,6 +159,7 @@ func NewInstall(cfg *Configuration) *Install {
in := &Install{ in := &Install{
cfg: cfg, cfg: cfg,
ServerSideApply: true, ServerSideApply: true,
DryRunStrategy: DryRunNone,
} }
in.registryClient = cfg.RegistryClient in.registryClient = cfg.RegistryClient
@ -189,7 +193,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
// If the error is CRD already exists, continue. // If the error is CRD already exists, continue.
if apierrors.IsAlreadyExists(err) { if apierrors.IsAlreadyExists(err) {
crdName := res[0].Name crdName := res[0].Name
slog.Debug("CRD is already present. Skipping", "crd", crdName) i.cfg.Logger().Debug("CRD is already present. Skipping", "crd", crdName)
continue continue
} }
return fmt.Errorf("failed to install CRD %s: %w", obj.Name, err) return fmt.Errorf("failed to install CRD %s: %w", obj.Name, err)
@ -217,7 +221,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
return err return err
} }
slog.Debug("clearing discovery cache") i.cfg.Logger().Debug("clearing discovery cache")
discoveryClient.Invalidate() discoveryClient.Invalidate()
_, _ = discoveryClient.ServerGroups() _, _ = discoveryClient.ServerGroups()
@ -230,7 +234,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
return err return err
} }
if resettable, ok := restMapper.(meta.ResettableRESTMapper); ok { if resettable, ok := restMapper.(meta.ResettableRESTMapper); ok {
slog.Debug("clearing REST mapper cache") i.cfg.Logger().Debug("clearing REST mapper cache")
resettable.Reset() resettable.Reset()
} }
} }
@ -241,7 +245,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
// //
// If DryRun is set to true, this will prepare the release, but not install it // If DryRun is set to true, this will prepare the release, but not install it
func (i *Install) Run(chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { func (i *Install) Run(chrt ci.Charter, vals map[string]interface{}) (ri.Releaser, error) {
ctx := context.Background() ctx := context.Background()
return i.RunWithContext(ctx, chrt, vals) return i.RunWithContext(ctx, chrt, vals)
} }
@ -250,48 +254,52 @@ func (i *Install) Run(chrt *chart.Chart, vals map[string]interface{}) (*release.
// //
// When the task is cancelled through ctx, the function returns and the install // When the task is cancelled through ctx, the function returns and the install
// proceeds in the background. // proceeds in the background.
func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals map[string]interface{}) (*release.Release, error) { func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[string]interface{}) (ri.Releaser, error) {
// Check reachability of cluster unless in client-only mode (e.g. `helm template` without `--validate`) var chrt *chart.Chart
if !i.ClientOnly { switch c := ch.(type) {
case *chart.Chart:
chrt = c
case chart.Chart:
chrt = &c
default:
return nil, errors.New("invalid chart apiVersion")
}
if interactWithServer(i.DryRunStrategy) {
if err := i.cfg.KubeClient.IsReachable(); err != nil { if err := i.cfg.KubeClient.IsReachable(); err != nil {
slog.Error(fmt.Sprintf("cluster reachability check failed: %v", err)) i.cfg.Logger().Error(fmt.Sprintf("cluster reachability check failed: %v", err))
return nil, fmt.Errorf("cluster reachability check failed: %w", err) return nil, fmt.Errorf("cluster reachability check failed: %w", err)
} }
} }
// HideSecret must be used with dry run. Otherwise, return an error. // 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") i.cfg.Logger().Error("hiding Kubernetes secrets requires a dry-run mode")
return nil, errors.New("hiding Kubernetes secrets requires a dry-run mode") return nil, errors.New("hiding Kubernetes secrets requires a dry-run mode")
} }
if err := i.availableName(); err != nil { if err := i.availableName(); err != nil {
slog.Error("release name check failed", slog.Any("error", err)) i.cfg.Logger().Error("release name check failed", slog.Any("error", err))
return nil, fmt.Errorf("release name check failed: %w", err) return nil, fmt.Errorf("release name check failed: %w", err)
} }
if err := chartutil.ProcessDependencies(chrt, vals); err != nil { if err := chartutil.ProcessDependencies(chrt, vals); err != nil {
slog.Error("chart dependencies processing failed", slog.Any("error", err)) i.cfg.Logger().Error("chart dependencies processing failed", slog.Any("error", err))
return nil, fmt.Errorf("chart dependencies processing failed: %w", err) 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 // Pre-install anything in the crd/ directory. We do this before Helm
// contacts the upstream server and builds the capabilities object. // 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 // 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.") i.cfg.Logger().Warn("This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.")
} else if err := i.installCRDs(crds); err != nil { } else if err := i.installCRDs(crds); err != nil {
return nil, err return nil, err
} }
} }
if i.ClientOnly { if !interactWithServer(i.DryRunStrategy) {
// Add mock objects in here so it doesn't use Kube API server // Add mock objects in here so it doesn't use Kube API server
// NOTE(bacongobbler): used for `helm template` // NOTE(bacongobbler): used for `helm template`
i.cfg.Capabilities = common.DefaultCapabilities.Copy() i.cfg.Capabilities = common.DefaultCapabilities.Copy()
@ -304,8 +312,8 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
mem := driver.NewMemory() mem := driver.NewMemory()
mem.SetNamespace(i.Namespace) mem.SetNamespace(i.Namespace)
i.cfg.Releases = storage.Init(mem) 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") i.cfg.Logger().Debug("API Version list given outside of client only mode, this list will be ignored")
} }
// Make sure if RollbackOnFailure is set, that wait is set as well. This makes it so // Make sure if RollbackOnFailure is set, that wait is set as well. This makes it so
@ -320,7 +328,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
} }
// special case for helm template --is-upgrade // special case for helm template --is-upgrade
isUpgrade := i.IsUpgrade && i.isDryRun() isUpgrade := i.IsUpgrade && isDryRun(i.DryRunStrategy)
options := common.ReleaseOptions{ options := common.ReleaseOptions{
Name: i.ReleaseName, Name: i.ReleaseName,
Namespace: i.Namespace, Namespace: i.Namespace,
@ -340,20 +348,20 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
rel := i.createRelease(chrt, vals, i.Labels) rel := i.createRelease(chrt, vals, i.Labels)
var manifestDoc *bytes.Buffer 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 // Even for errors, attach this if available
if manifestDoc != nil { if manifestDoc != nil {
rel.Manifest = manifestDoc.String() rel.Manifest = manifestDoc.String()
} }
// Check error from render // Check error from render
if err != nil { 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 a release with partial data so that the client can show debugging information.
return rel, err return rel, err
} }
// Mark this release as in-progress // Mark this release as in-progress
rel.SetStatus(release.StatusPendingInstall, "Initial install underway") rel.SetStatus(rcommon.StatusPendingInstall, "Initial install underway")
var toBeAdopted kube.ResourceList var toBeAdopted kube.ResourceList
resources, err := i.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), !i.DisableOpenAPIValidation) resources, err := i.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), !i.DisableOpenAPIValidation)
@ -373,7 +381,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
// we'll end up in a state where we will delete those resources upon // 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 // deleting the release because the manifest will be pointing at that
// resource // resource
if !i.ClientOnly && !isUpgrade && len(resources) > 0 { if interactWithServer(i.DryRunStrategy) && !isUpgrade && len(resources) > 0 {
if i.TakeOwnership { if i.TakeOwnership {
toBeAdopted, err = requireAdoption(resources) toBeAdopted, err = requireAdoption(resources)
} else { } else {
@ -385,7 +393,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
} }
// Bail out here if it is a dry run // Bail out here if it is a dry run
if i.isDryRun() { if isDryRun(i.DryRunStrategy) {
rel.Info.Description = "Dry run complete" rel.Info.Description = "Dry run complete"
return rel, nil return rel, nil
} }
@ -448,8 +456,10 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t
resultChan := make(chan Msg, 1) resultChan := make(chan Msg, 1)
go func() { go func() {
i.goroutineCount.Add(1)
rel, err := i.performInstall(rel, toBeAdopted, resources) rel, err := i.performInstall(rel, toBeAdopted, resources)
resultChan <- Msg{rel, err} resultChan <- Msg{rel, err}
i.goroutineCount.Add(-1)
}() }()
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -460,12 +470,9 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t
} }
} }
// isDryRun returns true if Upgrade is set to run as a DryRun // getGoroutineCount return the number of running routines
func (i *Install) isDryRun() bool { func (i *Install) getGoroutineCount() int32 {
if i.DryRun || i.DryRunOption == "client" || i.DryRunOption == "server" || i.DryRunOption == "true" { return i.goroutineCount.Load()
return true
}
return false
} }
func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) { func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.ResourceList, resources kube.ResourceList) (*release.Release, error) {
@ -519,9 +526,9 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
} }
if len(i.Description) > 0 { if len(i.Description) > 0 {
rel.SetStatus(release.StatusDeployed, i.Description) rel.SetStatus(rcommon.StatusDeployed, i.Description)
} else { } 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 // This is a tricky case. The release has been created, but the result
@ -532,20 +539,21 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
// One possible strategy would be to do a timed retry to see if we can get // One possible strategy would be to do a timed retry to see if we can get
// this stored in the future. // this stored in the future.
if err := i.recordRelease(rel); err != nil { if err := i.recordRelease(rel); err != nil {
slog.Error("failed to record the release", slog.Any("error", err)) i.cfg.Logger().Error("failed to record the release", slog.Any("error", err))
} }
return rel, nil return rel, nil
} }
func (i *Install) failRelease(rel *release.Release, err error) (*release.Release, error) { 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 { if i.RollbackOnFailure {
slog.Debug("install failed and rollback-on-failure is set, uninstalling release", "release", i.ReleaseName) i.cfg.Logger().Debug("install failed and rollback-on-failure is set, uninstalling release", "release", i.ReleaseName)
uninstall := NewUninstall(i.cfg) uninstall := NewUninstall(i.cfg)
uninstall.DisableHooks = i.DisableHooks uninstall.DisableHooks = i.DisableHooks
uninstall.KeepHistory = false uninstall.KeepHistory = false
uninstall.Timeout = i.Timeout uninstall.Timeout = i.Timeout
uninstall.WaitStrategy = i.WaitStrategy
if _, uninstallErr := uninstall.Run(i.ReleaseName); uninstallErr != nil { if _, uninstallErr := uninstall.Run(i.ReleaseName); uninstallErr != nil {
return rel, fmt.Errorf("an error occurred while uninstalling the release. original install error: %w: %w", err, uninstallErr) return rel, fmt.Errorf("an error occurred while uninstalling the release. original install error: %w: %w", err, uninstallErr)
} }
@ -570,7 +578,7 @@ func (i *Install) availableName() error {
return fmt.Errorf("release name %q: %w", start, err) return fmt.Errorf("release name %q: %w", start, err)
} }
// On dry run, bail here // On dry run, bail here
if i.isDryRun() { if isDryRun(i.DryRunStrategy) {
return nil return nil
} }
@ -578,15 +586,43 @@ func (i *Install) availableName() error {
if err != nil || len(h) < 1 { if err != nil || len(h) < 1 {
return nil 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 nil
} }
return errors.New("cannot reuse a name that is still in use") 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 // createRelease creates a new release object
func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release { func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release {
ts := i.cfg.Now() ts := i.cfg.Now()
@ -599,7 +635,7 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{
Info: &release.Info{ Info: &release.Info{
FirstDeployed: ts, FirstDeployed: ts,
LastDeployed: ts, LastDeployed: ts,
Status: release.StatusUnknown, Status: rcommon.StatusUnknown,
}, },
Version: 1, Version: 1,
Labels: labels, Labels: labels,
@ -625,20 +661,24 @@ func (i *Install) replaceRelease(rel *release.Release) error {
// No releases exist for this name, so we can return early // No releases exist for this name, so we can return early
return nil return nil
} }
hl, err := releaseListToV1List(hist)
if err != nil {
return err
}
releaseutil.Reverse(hist, releaseutil.SortByRevision) releaseutil.Reverse(hl, releaseutil.SortByRevision)
last := hist[0] last := hl[0]
// Update version to the next available // Update version to the next available
rel.Version = last.Version + 1 rel.Version = last.Version + 1
// Do not change the status of a failed release. // Do not change the status of a failed release.
if last.Info.Status == release.StatusFailed { if last.Info.Status == rcommon.StatusFailed {
return nil return nil
} }
// For any other status, mark it as superseded and store the old record // 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) return i.recordRelease(last)
} }
@ -677,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 // check if the directory exists to create file. creates if doesn't exist
func ensureDirectoryForFile(file string) error { func ensureDirectoryForFile(file string) error {
baseDir := path.Dir(file) baseDir := filepath.Dir(file)
_, err := os.Stat(baseDir) _, err := os.Stat(baseDir)
if err != nil && !errors.Is(err, fs.ErrNotExist) { if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err return err
@ -752,17 +792,30 @@ func TemplateName(nameTemplate string) (string, error) {
} }
// CheckDependencies checks the dependencies for a chart. // CheckDependencies checks the dependencies for a chart.
func CheckDependencies(ch *chart.Chart, reqs []*chart.Dependency) error { func CheckDependencies(ch ci.Charter, reqs []ci.Dependency) error {
ac, err := ci.NewAccessor(ch)
if err != nil {
return err
}
var missing []string var missing []string
OUTER: OUTER:
for _, r := range reqs { for _, r := range reqs {
for _, d := range ch.Dependencies() { rac, err := ci.NewDependencyAccessor(r)
if d.Name() == r.Name { if err != nil {
return err
}
for _, d := range ac.Dependencies() {
dac, err := ci.NewAccessor(d)
if err != nil {
return err
}
if dac.Name() == rac.Name() {
continue OUTER continue OUTER
} }
} }
missing = append(missing, r.Name) missing = append(missing, rac.Name())
} }
if len(missing) > 0 { if len(missing) > 0 {
@ -795,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. // This does not ensure that the chart is well-formed; only that the requested filename exists.
// //
// Order of resolution: // 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 // - if path is absolute or begins with '.', error out here
// - URL // - URL
// //
@ -808,6 +861,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
version := strings.TrimSpace(c.Version) version := strings.TrimSpace(c.Version)
if c.RepoURL == "" {
if _, err := os.Stat(name); err == nil { if _, err := os.Stat(name); err == nil {
abs, err := filepath.Abs(name) abs, err := filepath.Abs(name)
if err != nil { if err != nil {
@ -823,6 +877,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
if filepath.IsAbs(name) || strings.HasPrefix(name, ".") { if filepath.IsAbs(name) || strings.HasPrefix(name, ".") {
return name, fmt.Errorf("path %q not found", name) return name, fmt.Errorf("path %q not found", name)
} }
}
dl := downloader.ChartDownloader{ dl := downloader.ChartDownloader{
Out: os.Stdout, Out: os.Stdout,

@ -28,7 +28,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -48,9 +47,9 @@ import (
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
rcommon "helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
helmtime "helm.sh/helm/v4/pkg/time"
) )
type nameTemplateTestCase struct { type nameTemplateTestCase struct {
@ -132,14 +131,19 @@ func TestInstallRelease(t *testing.T) {
instAction := installAction(t) instAction := installAction(t)
vals := map[string]interface{}{} vals := map[string]interface{}{}
ctx, done := context.WithCancel(t.Context()) ctx, done := context.WithCancel(t.Context())
res, err := instAction.RunWithContext(ctx, buildChart(), vals) resi, err := instAction.RunWithContext(ctx, buildChart(), vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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.Name, "test-install-release", "Expected release name.")
is.Equal(res.Namespace, "spaced") 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.NoError(err)
is.Len(rel.Hooks, 1) is.Len(rel.Hooks, 1)
@ -158,7 +162,9 @@ func TestInstallRelease(t *testing.T) {
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
lastRelease, err := instAction.cfg.Releases.Last(rel.Name) lastRelease, err := instAction.cfg.Releases.Last(rel.Name)
req.NoError(err) 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) { func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) {
@ -177,12 +183,17 @@ func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) {
config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false)) config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false))
instAction := installActionWithConfig(config) instAction := installActionWithConfig(config)
instAction.TakeOwnership = true instAction.TakeOwnership = true
res, err := instAction.Run(buildChart(), nil) resi, err := instAction.Run(buildChart(), nil)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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.NoError(err)
is.Equal(rel.Info.Description, "Install complete") is.Equal(rel.Info.Description, "Install complete")
@ -195,11 +206,16 @@ func TestInstallReleaseWithTakeOwnership_ResourceOwned(t *testing.T) {
config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(true)) config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(true))
instAction := installActionWithConfig(config) instAction := installActionWithConfig(config)
instAction.TakeOwnership = false instAction.TakeOwnership = false
res, err := instAction.Run(buildChart(), nil) resi, err := instAction.Run(buildChart(), nil)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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.NoError(err)
is.Equal(rel.Info.Description, "Install complete") is.Equal(rel.Info.Description, "Install complete")
@ -229,14 +245,19 @@ func TestInstallReleaseWithValues(t *testing.T) {
"simpleKey": "simpleValue", "simpleKey": "simpleValue",
}, },
} }
res, err := instAction.Run(buildChart(withSampleValues()), userVals) resi, err := instAction.Run(buildChart(withSampleValues()), userVals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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.Name, "test-install-release", "Expected release name.")
is.Equal(res.Namespace, "spaced") 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.NoError(err)
is.Len(rel.Hooks, 1) is.Len(rel.Hooks, 1)
@ -251,16 +272,6 @@ func TestInstallReleaseWithValues(t *testing.T) {
is.Equal(expectedUserValues, rel.Config) 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) { func TestInstallRelease_NoName(t *testing.T) {
instAction := installAction(t) instAction := installAction(t)
instAction.ReleaseName = "" instAction.ReleaseName = ""
@ -277,15 +288,19 @@ func TestInstallRelease_WithNotes(t *testing.T) {
instAction := installAction(t) instAction := installAction(t)
instAction.ReleaseName = "with-notes" instAction.ReleaseName = "with-notes"
vals := map[string]interface{}{} 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 { if err != nil {
t.Fatalf("Failed install: %s", err) t.Fatalf("Failed install: %s", err)
} }
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Equal(res.Name, "with-notes") is.Equal(res.Name, "with-notes")
is.Equal(res.Namespace, "spaced") 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.NoError(err)
is.Len(rel.Hooks, 1) is.Len(rel.Hooks, 1)
is.Equal(rel.Hooks[0].Manifest, manifestWithHook) is.Equal(rel.Hooks[0].Manifest, manifestWithHook)
@ -304,12 +319,16 @@ func TestInstallRelease_WithNotesRendered(t *testing.T) {
instAction := installAction(t) instAction := installAction(t)
instAction.ReleaseName = "with-notes" instAction.ReleaseName = "with-notes"
vals := map[string]interface{}{} 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 { if err != nil {
t.Fatalf("Failed install: %s", err) 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.NoError(err)
expectedNotes := fmt.Sprintf("got-%s", res.Name) expectedNotes := fmt.Sprintf("got-%s", res.Name)
@ -323,14 +342,18 @@ func TestInstallRelease_WithChartAndDependencyParentNotes(t *testing.T) {
instAction := installAction(t) instAction := installAction(t)
instAction.ReleaseName = "with-notes" instAction.ReleaseName = "with-notes"
vals := map[string]interface{}{} 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 { if err != nil {
t.Fatalf("Failed install: %s", err) 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.Equal("with-notes", rel.Name) is.NoError(err)
rel, err := releaserToV1Release(r)
is.NoError(err) is.NoError(err)
is.Equal("with-notes", rel.Name)
is.Equal("parent", rel.Info.Notes) is.Equal("parent", rel.Info.Notes)
is.Equal(rel.Info.Description, "Install complete") is.Equal(rel.Info.Description, "Install complete")
} }
@ -342,14 +365,18 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) {
instAction.ReleaseName = "with-notes" instAction.ReleaseName = "with-notes"
instAction.SubNotes = true instAction.SubNotes = true
vals := map[string]interface{}{} 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 { if err != nil {
t.Fatalf("Failed install: %s", err) 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.Equal("with-notes", rel.Name)
is.NoError(err) 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' // test run can return as either 'parent\nchild' or 'child\nparent'
if !strings.Contains(rel.Info.Notes, "parent") && !strings.Contains(rel.Info.Notes, "child") { if !strings.Contains(rel.Info.Notes, "parent") && !strings.Contains(rel.Info.Notes, "child") {
t.Fatalf("Expected 'parent\nchild' or 'child\nparent', got '%s'", rel.Info.Notes) t.Fatalf("Expected 'parent\nchild' or 'child\nparent', got '%s'", rel.Info.Notes)
@ -357,15 +384,19 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) {
is.Equal(rel.Info.Description, "Install complete") is.Equal(rel.Info.Description, "Install complete")
} }
func TestInstallRelease_DryRun(t *testing.T) { func TestInstallRelease_DryRunClient(t *testing.T) {
for _, dryRunStrategy := range []DryRunStrategy{DryRunClient, DryRunServer} {
is := assert.New(t) is := assert.New(t)
instAction := installAction(t) instAction := installAction(t)
instAction.DryRun = true instAction.DryRunStrategy = dryRunStrategy
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(withSampleTemplates()), vals) resi, err := instAction.Run(buildChart(withSampleTemplates()), vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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/hello\nhello: world")
is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world")
@ -379,18 +410,21 @@ func TestInstallRelease_DryRun(t *testing.T) {
is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run") is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "expect hook to not be marked as run")
is.Equal(res.Info.Description, "Dry run complete") is.Equal(res.Info.Description, "Dry run complete")
} }
}
func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { func TestInstallRelease_DryRunHiddenSecret(t *testing.T) {
is := assert.New(t) is := assert.New(t)
instAction := installAction(t) instAction := installAction(t)
// First perform a normal dry-run with the secret and confirm its presence. // First perform a normal dry-run with the secret and confirm its presence.
instAction.DryRun = true instAction.DryRunStrategy = DryRunClient
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) resi, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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") is.Contains(res.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret")
_, err = instAction.cfg.Releases.Get(res.Name, res.Version) _, err = instAction.cfg.Releases.Get(res.Name, res.Version)
@ -400,10 +434,12 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) {
// Perform a dry-run where the secret should not be present // Perform a dry-run where the secret should not be present
instAction.HideSecret = true instAction.HideSecret = true
vals = map[string]interface{}{} vals = map[string]interface{}{}
res2, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) res2i, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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") is.NotContains(res2.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret")
@ -412,7 +448,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) {
is.Equal(res2.Info.Description, "Dry run complete") is.Equal(res2.Info.Description, "Dry run complete")
// Ensure there is an error when HideSecret True but not in a dry-run mode // 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{}{} vals = map[string]interface{}{}
_, err = instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) _, err = instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals)
if err == nil { if err == nil {
@ -424,19 +460,22 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) {
func TestInstallRelease_DryRun_Lookup(t *testing.T) { func TestInstallRelease_DryRun_Lookup(t *testing.T) {
is := assert.New(t) is := assert.New(t)
instAction := installAction(t) instAction := installAction(t)
instAction.DryRun = true instAction.DryRunStrategy = DryRunNone
vals := map[string]interface{}{} vals := map[string]interface{}{}
mockChart := buildChart(withSampleTemplates()) mockChart := buildChart(withSampleTemplates())
mockChart.Templates = append(mockChart.Templates, &common.File{ mockChart.Templates = append(mockChart.Templates, &common.File{
Name: "templates/lookup", Name: "templates/lookup",
ModTime: time.Now(),
Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`),
}) })
res, err := instAction.Run(mockChart, vals) resi, err := instAction.Run(mockChart, vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) t.Fatalf("Failed install: %s", err)
} }
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Manifest, "goodbye: map[]") is.Contains(res.Manifest, "goodbye: map[]")
} }
@ -444,7 +483,7 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) {
func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) {
is := assert.New(t) is := assert.New(t)
instAction := installAction(t) instAction := installAction(t)
instAction.DryRun = true instAction.DryRunStrategy = DryRunNone
vals := map[string]interface{}{} vals := map[string]interface{}{}
_, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals)
expectedErr := `hello/templates/incorrect:1:10 expectedErr := `hello/templates/incorrect:1:10
@ -453,10 +492,8 @@ func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) {
if err == nil { if err == nil {
t.Fatalf("Install should fail containing error: %s", expectedErr) t.Fatalf("Install should fail containing error: %s", expectedErr)
} }
if err != nil {
is.Contains(err.Error(), expectedErr) is.Contains(err.Error(), expectedErr)
} }
}
func TestInstallRelease_NoHooks(t *testing.T) { func TestInstallRelease_NoHooks(t *testing.T) {
is := assert.New(t) is := assert.New(t)
@ -466,10 +503,12 @@ func TestInstallRelease_NoHooks(t *testing.T) {
instAction.cfg.Releases.Create(releaseStub()) instAction.cfg.Releases.Create(releaseStub())
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(), vals) resi, err := instAction.Run(buildChart(), vals)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) 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") is.True(res.Hooks[0].LastRun.CompletedAt.IsZero(), "hooks should not run with no-hooks")
} }
@ -485,11 +524,13 @@ func TestInstallRelease_FailedHooks(t *testing.T) {
failer.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} failer.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(), vals) resi, err := instAction.Run(buildChart(), vals)
is.Error(err) is.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "failed post-install") is.Contains(res.Info.Description, "failed post-install")
is.Equal("", outBuffer.String()) is.Equal("", outBuffer.String())
is.Equal(release.StatusFailed, res.Info.Status) is.Equal(rcommon.StatusFailed, res.Info.Status)
} }
func TestInstallRelease_ReplaceRelease(t *testing.T) { func TestInstallRelease_ReplaceRelease(t *testing.T) {
@ -498,21 +539,25 @@ func TestInstallRelease_ReplaceRelease(t *testing.T) {
instAction.Replace = true instAction.Replace = true
rel := releaseStub() rel := releaseStub()
rel.Info.Status = release.StatusUninstalled rel.Info.Status = rcommon.StatusUninstalled
instAction.cfg.Releases.Create(rel) instAction.cfg.Releases.Create(rel)
instAction.ReleaseName = rel.Name instAction.ReleaseName = rel.Name
vals := map[string]interface{}{} 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) is.NoError(err)
// This should have been auto-incremented // This should have been auto-incremented
is.Equal(2, res.Version) is.Equal(2, res.Version)
is.Equal(res.Name, rel.Name) 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.NoError(err)
is.Equal(getres.Info.Status, release.StatusDeployed) is.Equal(getres.Info.Status, rcommon.StatusDeployed)
} }
func TestInstallRelease_KubeVersion(t *testing.T) { func TestInstallRelease_KubeVersion(t *testing.T) {
@ -540,14 +585,16 @@ func TestInstallRelease_Wait(t *testing.T) {
instAction.WaitStrategy = kube.StatusWatcherStrategy instAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{} vals := map[string]interface{}{}
goroutines := runtime.NumGoroutine() goroutines := instAction.getGoroutineCount()
res, err := instAction.Run(buildChart(), vals) resi, err := instAction.Run(buildChart(), vals)
is.Error(err) is.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "I timed out") 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, runtime.NumGoroutine()) is.Equal(goroutines, instAction.getGoroutineCount())
} }
func TestInstallRelease_Wait_Interrupted(t *testing.T) { func TestInstallRelease_Wait_Interrupted(t *testing.T) {
is := assert.New(t) is := assert.New(t)
@ -562,15 +609,15 @@ func TestInstallRelease_Wait_Interrupted(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel) time.AfterFunc(time.Second, cancel)
goroutines := runtime.NumGoroutine() goroutines := instAction.getGoroutineCount()
_, err := instAction.RunWithContext(ctx, buildChart(), vals) _, err := instAction.RunWithContext(ctx, buildChart(), vals)
is.Error(err) is.Error(err)
is.Contains(err.Error(), "context canceled") is.Contains(err.Error(), "context canceled")
is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background is.Equal(goroutines+1, instAction.getGoroutineCount()) // installation goroutine still is in background
time.Sleep(10 * time.Second) // wait for goroutine to finish time.Sleep(10 * time.Second) // wait for goroutine to finish
is.Equal(goroutines, runtime.NumGoroutine()) is.Equal(goroutines, instAction.getGoroutineCount())
} }
func TestInstallRelease_WaitForJobs(t *testing.T) { func TestInstallRelease_WaitForJobs(t *testing.T) {
is := assert.New(t) is := assert.New(t)
@ -583,10 +630,12 @@ func TestInstallRelease_WaitForJobs(t *testing.T) {
instAction.WaitForJobs = true instAction.WaitForJobs = true
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(), vals) resi, err := instAction.Run(buildChart(), vals)
is.Error(err) is.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "I timed out") 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) { func TestInstallRelease_RollbackOnFailure(t *testing.T) {
@ -604,11 +653,13 @@ func TestInstallRelease_RollbackOnFailure(t *testing.T) {
instAction.DisableHooks = true instAction.DisableHooks = true
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(), vals) resi, err := instAction.Run(buildChart(), vals)
is.Error(err) is.Error(err)
is.Contains(err.Error(), "I timed out") is.Contains(err.Error(), "I timed out")
is.Contains(err.Error(), "rollback-on-failure") is.Contains(err.Error(), "rollback-on-failure")
res, err := releaserToV1Release(resi)
is.NoError(err)
// Now make sure it isn't in storage anymore // Now make sure it isn't in storage anymore
_, err = instAction.cfg.Releases.Get(res.Name, res.Version) _, err = instAction.cfg.Releases.Get(res.Name, res.Version)
is.Error(err) is.Error(err)
@ -646,21 +697,23 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel) time.AfterFunc(time.Second, cancel)
goroutines := runtime.NumGoroutine() goroutines := instAction.getGoroutineCount()
res, err := instAction.RunWithContext(ctx, buildChart(), vals) resi, err := instAction.RunWithContext(ctx, buildChart(), vals)
is.Error(err) is.Error(err)
is.Contains(err.Error(), "context canceled") is.Contains(err.Error(), "context canceled")
is.Contains(err.Error(), "rollback-on-failure") is.Contains(err.Error(), "rollback-on-failure")
is.Contains(err.Error(), "uninstalled") is.Contains(err.Error(), "uninstalled")
res, err := releaserToV1Release(resi)
is.NoError(err)
// Now make sure it isn't in storage anymore // Now make sure it isn't in storage anymore
_, err = instAction.cfg.Releases.Get(res.Name, res.Version) _, err = instAction.cfg.Releases.Get(res.Name, res.Version)
is.Error(err) is.Error(err)
is.Equal(err, driver.ErrReleaseNotFound) is.Equal(err, driver.ErrReleaseNotFound)
is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background is.Equal(goroutines+1, instAction.getGoroutineCount()) // installation goroutine still is in background
time.Sleep(10 * time.Second) // wait for goroutine to finish time.Sleep(10 * time.Second) // wait for goroutine to finish
is.Equal(goroutines, runtime.NumGoroutine()) is.Equal(goroutines, instAction.getGoroutineCount())
} }
func TestNameTemplate(t *testing.T) { func TestNameTemplate(t *testing.T) {
@ -859,32 +912,32 @@ func TestNameAndChartGenerateName(t *testing.T) {
{ {
"local filepath", "local filepath",
"./chart", "./chart",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
{ {
"dot filepath", "dot filepath",
".", ".",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
{ {
"empty filepath", "empty filepath",
"", "",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
{ {
"packaged chart", "packaged chart",
"chart.tgz", "chart.tgz",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
{ {
"packaged chart with .tar.gz extension", "packaged chart with .tar.gz extension",
"chart.tar.gz", "chart.tar.gz",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
{ {
"packaged chart with local extension", "packaged chart with local extension",
"./chart.tgz", "./chart.tgz",
fmt.Sprintf("chart-%d", helmtime.Now().Unix()), fmt.Sprintf("chart-%d", time.Now().Unix()),
}, },
} }
@ -910,10 +963,12 @@ func TestInstallWithLabels(t *testing.T) {
"key1": "val1", "key1": "val1",
"key2": "val2", "key2": "val2",
} }
res, err := instAction.Run(buildChart(), nil) resi, err := instAction.Run(buildChart(), nil)
if err != nil { if err != nil {
t.Fatalf("Failed install: %s", err) t.Fatalf("Failed install: %s", err)
} }
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Equal(instAction.Labels, res.Labels) is.Equal(instAction.Labels, res.Labels)
} }
@ -997,3 +1052,19 @@ func TestUrlEqual(t *testing.T) {
}) })
} }
} }
func TestInstallRun_UnreachableKubeClient(t *testing.T) {
config := actionConfigFixture(t)
failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
failingKubeClient.ConnectionError = errors.New("connection refused")
config.KubeClient = &failingKubeClient
instAction := NewInstall(config)
ctx, done := context.WithCancel(t.Context())
chrt := buildChart()
res, err := instAction.RunWithContext(ctx, chrt, nil)
done()
assert.Nil(t, res)
assert.ErrorContains(t, err, "connection refused")
}

@ -22,6 +22,7 @@ import (
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
ri "helm.sh/helm/v4/pkg/release"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util" releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
) )
@ -139,13 +140,13 @@ type List struct {
// NewList constructs a new *List // NewList constructs a new *List
func NewList(cfg *Configuration) *List { func NewList(cfg *Configuration) *List {
return &List{ return &List{
StateMask: ListDeployed | ListFailed, StateMask: ListAll,
cfg: cfg, cfg: cfg,
} }
} }
// Run executes the list command, returning a set of matches. // 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 { if err := l.cfg.KubeClient.IsReachable(); err != nil {
return nil, err 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. // Skip anything that doesn't match the filter.
if filter != nil && !filter.MatchString(rel.Name) { if filter != nil && !filter.MatchString(r.Name) {
return false return false
} }
@ -176,30 +181,35 @@ func (l *List) Run() ([]*release.Release, error) {
return results, nil return results, nil
} }
rresults, err := releaseListToV1List(results)
if err != nil {
return nil, err
}
// by definition, superseded releases are never shown if // by definition, superseded releases are never shown if
// only the latest releases are returned. so if requested statemask // only the latest releases are returned. so if requested statemask
// is _only_ ListSuperseded, skip the latest release filter // is _only_ ListSuperseded, skip the latest release filter
if l.StateMask != ListSuperseded { if l.StateMask != ListSuperseded {
results = filterLatestReleases(results) rresults = filterLatestReleases(rresults)
} }
// State mask application must occur after filtering to // State mask application must occur after filtering to
// latest releases, otherwise outdated entries can be returned // latest releases, otherwise outdated entries can be returned
results = l.filterStateMask(results) rresults = l.filterStateMask(rresults)
// Skip anything that doesn't match the selector // Skip anything that doesn't match the selector
selectorObj, err := labels.Parse(l.Selector) selectorObj, err := labels.Parse(l.Selector)
if err != nil { if err != nil {
return nil, err 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 // Unfortunately, we have to sort before truncating, which can incur substantial overhead
l.sort(results) l.sort(rresults)
// Guard on offset // Guard on offset
if l.Offset >= len(results) { if l.Offset >= len(rresults) {
return []*release.Release{}, nil return releaseV1ListToReleaserList([]*release.Release{})
} }
// Calculate the limit and offset, and then truncate results if necessary. // 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 limit = l.Limit
} }
last := l.Offset + limit last := l.Offset + limit
if l := len(results); l < last { if l := len(rresults); l < last {
last = l 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 // 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 // Apply a default
if state == 0 { if state == 0 {
state = ListDeployed | ListFailed state = ListAll
} }
l.StateMask = state l.StateMask = state

@ -17,10 +17,15 @@ limitations under the License.
package action package action
import ( import (
"errors"
"io"
"testing" "testing"
"github.com/stretchr/testify/assert" "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" release "helm.sh/helm/v4/pkg/release/v1"
"helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage"
) )
@ -93,8 +98,11 @@ func TestList_Sort(t *testing.T) {
lister := newListFixture(t) lister := newListFixture(t)
lister.Sort = ByNameDesc // Other sorts are tested elsewhere lister.Sort = ByNameDesc // Other sorts are tested elsewhere
makeMeSomeReleases(t, lister.cfg.Releases) makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run() l, err := lister.Run()
is.NoError(err)
list, err := releaseListToV1List(l)
is.NoError(err) is.NoError(err)
is.Len(list, 3) is.Len(list, 3)
is.Equal("two", list[0].Name) is.Equal("two", list[0].Name)
is.Equal("three", list[1].Name) is.Equal("three", list[1].Name)
@ -106,7 +114,9 @@ func TestList_Limit(t *testing.T) {
lister := newListFixture(t) lister := newListFixture(t)
lister.Limit = 2 lister.Limit = 2
makeMeSomeReleases(t, lister.cfg.Releases) makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run() l, err := lister.Run()
is.NoError(err)
list, err := releaseListToV1List(l)
is.NoError(err) is.NoError(err)
is.Len(list, 2) is.Len(list, 2)
// Lex order means one, three, two // Lex order means one, three, two
@ -119,7 +129,9 @@ func TestList_BigLimit(t *testing.T) {
lister := newListFixture(t) lister := newListFixture(t)
lister.Limit = 20 lister.Limit = 20
makeMeSomeReleases(t, lister.cfg.Releases) makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run() l, err := lister.Run()
is.NoError(err)
list, err := releaseListToV1List(l)
is.NoError(err) is.NoError(err)
is.Len(list, 3) is.Len(list, 3)
@ -135,7 +147,9 @@ func TestList_LimitOffset(t *testing.T) {
lister.Limit = 2 lister.Limit = 2
lister.Offset = 1 lister.Offset = 1
makeMeSomeReleases(t, lister.cfg.Releases) makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run() l, err := lister.Run()
is.NoError(err)
list, err := releaseListToV1List(l)
is.NoError(err) is.NoError(err)
is.Len(list, 2) is.Len(list, 2)
@ -165,23 +179,45 @@ func TestList_StateMask(t *testing.T) {
is := assert.New(t) is := assert.New(t)
lister := newListFixture(t) lister := newListFixture(t)
makeMeSomeReleases(t, lister.cfg.Releases) makeMeSomeReleases(t, lister.cfg.Releases)
one, err := lister.cfg.Releases.Get("one", 1) oner, err := lister.cfg.Releases.Get("one", 1)
is.NoError(err) 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) err = lister.cfg.Releases.Update(one)
is.NoError(err) is.NoError(err)
res, err := lister.Run() res, err := lister.Run()
is.NoError(err) is.NoError(err)
is.Len(res, 2) is.Len(res, 3)
is.Equal("three", res[0].Name)
is.Equal("two", res[1].Name) 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 lister.StateMask = ListUninstalled
res, err = lister.Run() res, err = lister.Run()
is.NoError(err) is.NoError(err)
is.Len(res, 1) 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 lister.StateMask |= ListDeployed
res, err = lister.Run() res, err = lister.Run()
@ -203,28 +239,30 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) {
// "dirty" release should _not_ be present as most recent // "dirty" release should _not_ be present as most recent
// release is deployed despite failed release in past // 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) { func makeMeSomeReleasesWithStaleFailure(t *testing.T, store *storage.Storage) {
t.Helper() t.Helper()
one := namedReleaseStub("clean", release.StatusDeployed) one := namedReleaseStub("clean", common.StatusDeployed)
one.Namespace = "default" one.Namespace = "default"
one.Version = 1 one.Version = 1
two := namedReleaseStub("dirty", release.StatusDeployed) two := namedReleaseStub("dirty", common.StatusDeployed)
two.Namespace = "default" two.Namespace = "default"
two.Version = 1 two.Version = 1
three := namedReleaseStub("dirty", release.StatusFailed) three := namedReleaseStub("dirty", common.StatusFailed)
three.Namespace = "default" three.Namespace = "default"
three.Version = 2 three.Version = 2
four := namedReleaseStub("dirty", release.StatusDeployed) four := namedReleaseStub("dirty", common.StatusDeployed)
four.Namespace = "default" four.Namespace = "default"
four.Version = 3 four.Version = 3
five := namedReleaseStub("failed", release.StatusFailed) five := namedReleaseStub("failed", common.StatusFailed)
five.Namespace = "default" five.Namespace = "default"
five.Version = 1 five.Version = 1
@ -248,7 +286,9 @@ func TestList_Filter(t *testing.T) {
res, err := lister.Run() res, err := lister.Run()
is.NoError(err) is.NoError(err)
is.Len(res, 1) 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) { func TestList_FilterFailsCompile(t *testing.T) {
@ -367,3 +407,16 @@ func TestSelectorList(t *testing.T) {
assert.ElementsMatch(t, expectedFilteredList, res) assert.ElementsMatch(t, expectedFilteredList, res)
}) })
} }
func TestListRun_UnreachableKubeClient(t *testing.T) {
config := actionConfigFixture(t)
failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
failingKubeClient.ConnectionError = errors.New("connection refused")
config.KubeClient = &failingKubeClient
lister := NewList(config)
result, err := lister.Run()
assert.Nil(t, result)
assert.ErrorContains(t, err, "connection refused")
}

@ -28,7 +28,9 @@ import (
"golang.org/x/term" "golang.org/x/term"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/chart/v2/loader" ci "helm.sh/helm/v4/pkg/chart"
"helm.sh/helm/v4/pkg/chart/loader"
chart "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/provenance" "helm.sh/helm/v4/pkg/provenance"
) )
@ -69,7 +71,21 @@ func NewPackage() *Package {
// Run executes 'helm package' against the given chart and returns the path to the packaged chart. // Run executes 'helm package' against the given chart and returns the path to the packaged chart.
func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { func (p *Package) Run(path string, _ map[string]interface{}) (string, error) {
ch, err := loader.LoadDir(path) chrt, err := loader.LoadDir(path)
if err != nil {
return "", err
}
var ch *chart.Chart
switch c := chrt.(type) {
case *chart.Chart:
ch = c
case chart.Chart:
ch = &c
default:
return "", errors.New("invalid chart apiVersion")
}
ac, err := ci.NewAccessor(ch)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -87,7 +103,7 @@ func (p *Package) Run(path string, _ map[string]interface{}) (string, error) {
ch.Metadata.AppVersion = p.AppVersion ch.Metadata.AppVersion = p.AppVersion
} }
if reqs := ch.Metadata.Dependencies; reqs != nil { if reqs := ac.MetaDependencies(); reqs != nil {
if err := CheckDependencies(ch, reqs); err != nil { if err := CheckDependencies(ch, reqs); err != nil {
return "", err return "", err
} }
@ -146,13 +162,22 @@ func (p *Package) Clearsign(filename string) error {
} }
// Load the chart archive to extract metadata // Load the chart archive to extract metadata
chart, err := loader.LoadFile(filename) chrt, err := loader.LoadFile(filename)
if err != nil { if err != nil {
return fmt.Errorf("failed to load chart for signing: %w", err) return fmt.Errorf("failed to load chart for signing: %w", err)
} }
var ch *chart.Chart
switch c := chrt.(type) {
case *chart.Chart:
ch = c
case chart.Chart:
ch = &c
default:
return errors.New("invalid chart apiVersion")
}
// Marshal chart metadata to YAML bytes // Marshal chart metadata to YAML bytes
metadataBytes, err := yaml.Marshal(chart.Metadata) metadataBytes, err := yaml.Marshal(ch.Metadata)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal chart metadata: %w", err) return fmt.Errorf("failed to marshal chart metadata: %w", err)
} }

@ -28,6 +28,7 @@ import (
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
ri "helm.sh/helm/v4/pkg/release"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
) )
@ -45,7 +46,6 @@ type ReleaseTesting struct {
// Used for fetching logs from test pods // Used for fetching logs from test pods
Namespace string Namespace string
Filters map[string][]string Filters map[string][]string
HideNotes bool
} }
// NewReleaseTesting creates a new ReleaseTesting object with the given configuration. // 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. // 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 { if err := r.cfg.KubeClient.IsReachable(); err != nil {
return nil, err 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 // 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 { if err != nil {
return rel, err return rel, err
} }

@ -19,14 +19,15 @@ package action
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"log/slog"
"strings" "strings"
"time" "time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time"
) )
// Rollback is the action for rolling back to a given release. // Rollback is the action for rolling back to a given release.
@ -40,7 +41,8 @@ type Rollback struct {
WaitStrategy kube.WaitStrategy WaitStrategy kube.WaitStrategy
WaitForJobs bool WaitForJobs bool
DisableHooks 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. // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway.
// //
// This should be used with caution. // This should be used with caution.
@ -61,6 +63,7 @@ type Rollback struct {
func NewRollback(cfg *Configuration) *Rollback { func NewRollback(cfg *Configuration) *Rollback {
return &Rollback{ return &Rollback{
cfg: cfg, cfg: cfg,
DryRunStrategy: DryRunNone,
} }
} }
@ -72,26 +75,26 @@ func (r *Rollback) Run(name string) error {
r.cfg.Releases.MaxHistory = r.MaxHistory r.cfg.Releases.MaxHistory = r.MaxHistory
slog.Debug("preparing rollback", "name", name) r.cfg.Logger().Debug("preparing rollback", "name", name)
currentRelease, targetRelease, serverSideApply, err := r.prepareRollback(name) currentRelease, targetRelease, serverSideApply, err := r.prepareRollback(name)
if err != nil { if err != nil {
return err return err
} }
if !r.DryRun { if !isDryRun(r.DryRunStrategy) {
slog.Debug("creating rolled back release", "name", name) r.cfg.Logger().Debug("creating rolled back release", "name", name)
if err := r.cfg.Releases.Create(targetRelease); err != nil { if err := r.cfg.Releases.Create(targetRelease); err != nil {
return err return err
} }
} }
slog.Debug("performing rollback", "name", name) r.cfg.Logger().Debug("performing rollback", "name", name)
if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil { if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil {
return err return err
} }
if !r.DryRun { if !isDryRun(r.DryRunStrategy) {
slog.Debug("updating status for rolled back release", "name", name) r.cfg.Logger().Debug("updating status for rolled back release", "name", name)
if err := r.cfg.Releases.Update(targetRelease); err != nil { if err := r.cfg.Releases.Update(targetRelease); err != nil {
return err return err
} }
@ -110,7 +113,12 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
return nil, nil, false, errInvalidRevision 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 { if err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
@ -127,7 +135,11 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
// Check if the history version to be rolled back exists // Check if the history version to be rolled back exists
previousVersionExist := false 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 version := historyRelease.Version
if previousVersion == version { if previousVersion == version {
previousVersionExist = true previousVersionExist = true
@ -138,9 +150,13 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
return nil, nil, false, fmt.Errorf("release has no %d version", previousVersion) return nil, nil, false, fmt.Errorf("release has no %d version", previousVersion)
} }
slog.Debug("rolling back", "name", name, "currentVersion", currentRelease.Version, "targetVersion", previousVersion) r.cfg.Logger().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 { if err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
@ -158,8 +174,8 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
Config: previousRelease.Config, Config: previousRelease.Config,
Info: &release.Info{ Info: &release.Info{
FirstDeployed: currentRelease.Info.FirstDeployed, FirstDeployed: currentRelease.Info.FirstDeployed,
LastDeployed: helmtime.Now(), LastDeployed: time.Now(),
Status: release.StatusPendingRollback, Status: common.StatusPendingRollback,
Notes: previousRelease.Info.Notes, Notes: previousRelease.Info.Notes,
// Because we lose the reference to previous version elsewhere, we set the // Because we lose the reference to previous version elsewhere, we set the
// message here, and only override it later if we experience failure. // message here, and only override it later if we experience failure.
@ -176,8 +192,8 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
} }
func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) { 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) r.cfg.Logger().Debug("dry run", "name", targetRelease.Name)
return targetRelease, nil return targetRelease, nil
} }
@ -197,7 +213,7 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
return targetRelease, err return targetRelease, err
} }
} else { } else {
slog.Debug("rollback hooks disabled", "name", targetRelease.Name) r.cfg.Logger().Debug("rollback hooks disabled", "name", targetRelease.Name)
} }
// It is safe to use "forceOwnership" here because these are resources currently rendered by the chart. // It is safe to use "forceOwnership" here because these are resources currently rendered by the chart.
@ -215,39 +231,39 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
if err != nil { if err != nil {
msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err) msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)
slog.Warn(msg) r.cfg.Logger().Warn(msg)
currentRelease.Info.Status = release.StatusSuperseded currentRelease.Info.Status = common.StatusSuperseded
targetRelease.Info.Status = release.StatusFailed targetRelease.Info.Status = common.StatusFailed
targetRelease.Info.Description = msg targetRelease.Info.Description = msg
r.cfg.recordRelease(currentRelease) r.cfg.recordRelease(currentRelease)
r.cfg.recordRelease(targetRelease) r.cfg.recordRelease(targetRelease)
if r.CleanupOnFail { if r.CleanupOnFail {
slog.Debug("cleanup on fail set, cleaning up resources", "count", len(results.Created)) r.cfg.Logger().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 { if errs != nil {
return targetRelease, fmt.Errorf( return targetRelease, fmt.Errorf(
"an error occurred while cleaning up resources. original rollback error: %w", "an error occurred while cleaning up resources. original rollback error: %w",
fmt.Errorf("unable to cleanup resources: %w", joinErrors(errs, ", "))) fmt.Errorf("unable to cleanup resources: %w", joinErrors(errs, ", ")))
} }
slog.Debug("resource cleanup complete") r.cfg.Logger().Debug("resource cleanup complete")
} }
return targetRelease, err return targetRelease, err
} }
waiter, err := r.cfg.KubeClient.GetWaiter(r.WaitStrategy) waiter, err := r.cfg.KubeClient.GetWaiter(r.WaitStrategy)
if err != nil { if err != nil {
return nil, fmt.Errorf("unable to set metadata visitor from target release: %w", err) return nil, fmt.Errorf("unable to get waiter: %w", err)
} }
if r.WaitForJobs { if r.WaitForJobs {
if err := waiter.WaitWithJobs(target, r.Timeout); err != nil { 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(currentRelease)
r.cfg.recordRelease(targetRelease) r.cfg.recordRelease(targetRelease)
return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err) return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err)
} }
} else { } else {
if err := waiter.Wait(target, r.Timeout); err != nil { 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(currentRelease)
r.cfg.recordRelease(targetRelease) r.cfg.recordRelease(targetRelease)
return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err) return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err)
@ -266,13 +282,17 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
return nil, err return nil, err
} }
// Supersede all previous deployments, see issue #2941. // Supersede all previous deployments, see issue #2941.
for _, rel := range deployed { for _, reli := range deployed {
slog.Debug("superseding previous deployment", "version", rel.Version) rel, err := releaserToV1Release(reli)
rel.Info.Status = release.StatusSuperseded if err != nil {
return nil, err
}
r.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version)
rel.Info.Status = common.StatusSuperseded
r.cfg.recordRelease(rel) r.cfg.recordRelease(rel)
} }
targetRelease.Info.Status = release.StatusDeployed targetRelease.Info.Status = common.StatusDeployed
return targetRelease, nil return targetRelease, nil
} }

@ -18,6 +18,7 @@ package action
import ( import (
"testing" "testing"
"time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
@ -26,17 +27,18 @@ import (
func TestShow(t *testing.T) { func TestShow(t *testing.T) {
config := actionConfigFixture(t) config := actionConfigFixture(t)
client := NewShow(ShowAll, config) client := NewShow(ShowAll, config)
modTime := time.Now()
client.chart = &chart.Chart{ client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"}, Metadata: &chart.Metadata{Name: "alpine"},
Files: []*common.File{ Files: []*common.File{
{Name: "README.md", Data: []byte("README\n")}, {Name: "README.md", ModTime: modTime, Data: []byte("README\n")},
{Name: "crds/ignoreme.txt", Data: []byte("error")}, {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")},
{Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")},
{Name: "crds/bar.json", Data: []byte("---\nbar\n")}, {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")},
{Name: "crds/baz.yaml", Data: []byte("baz\n")}, {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")},
}, },
Raw: []*common.File{ Raw: []*common.File{
{Name: "values.yaml", Data: []byte("VALUES\n")}, {Name: "values.yaml", ModTime: modTime, Data: []byte("VALUES\n")},
}, },
Values: map[string]interface{}{}, Values: map[string]interface{}{},
} }
@ -104,13 +106,14 @@ func TestShowValuesByJsonPathFormat(t *testing.T) {
func TestShowCRDs(t *testing.T) { func TestShowCRDs(t *testing.T) {
config := actionConfigFixture(t) config := actionConfigFixture(t)
client := NewShow(ShowCRDs, config) client := NewShow(ShowCRDs, config)
modTime := time.Now()
client.chart = &chart.Chart{ client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"}, Metadata: &chart.Metadata{Name: "alpine"},
Files: []*common.File{ Files: []*common.File{
{Name: "crds/ignoreme.txt", Data: []byte("error")}, {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")},
{Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")},
{Name: "crds/bar.json", Data: []byte("---\nbar\n")}, {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")},
{Name: "crds/baz.yaml", Data: []byte("baz\n")}, {Name: "crds/baz.yaml", ModTime: modTime, Data: []byte("baz\n")},
}, },
} }
@ -137,12 +140,13 @@ baz
func TestShowNoReadme(t *testing.T) { func TestShowNoReadme(t *testing.T) {
config := actionConfigFixture(t) config := actionConfigFixture(t)
client := NewShow(ShowAll, config) client := NewShow(ShowAll, config)
modTime := time.Now()
client.chart = &chart.Chart{ client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"}, Metadata: &chart.Metadata{Name: "alpine"},
Files: []*common.File{ Files: []*common.File{
{Name: "crds/ignoreme.txt", Data: []byte("error")}, {Name: "crds/ignoreme.txt", ModTime: modTime, Data: []byte("error")},
{Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, {Name: "crds/foo.yaml", ModTime: modTime, Data: []byte("---\nfoo\n")},
{Name: "crds/bar.json", Data: []byte("---\nbar\n")}, {Name: "crds/bar.json", ModTime: modTime, Data: []byte("---\nbar\n")},
}, },
} }

@ -18,10 +18,9 @@ package action
import ( import (
"bytes" "bytes"
"errors"
"helm.sh/helm/v4/pkg/kube" "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. // Status is the action for checking the deployment status of releases.
@ -45,20 +44,24 @@ func NewStatus(cfg *Configuration) *Status {
} }
// Run executes 'helm status' against the given release. // 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 { if err := s.cfg.KubeClient.IsReachable(); err != nil {
return nil, err 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
}
rel, err := releaserToV1Release(reli)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if kubeClient, ok := s.cfg.KubeClient.(kube.InterfaceResources); ok {
var resources kube.ResourceList var resources kube.ResourceList
if s.ShowResourcesTable { if s.ShowResourcesTable {
resources, err = kubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false) resources, err = s.cfg.KubeClient.BuildTable(bytes.NewBufferString(rel.Manifest), false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -69,7 +72,7 @@ func (s *Status) Run(name string) (*release.Release, error) {
} }
} }
resp, err := kubeClient.Get(resources, true) resp, err := s.cfg.KubeClient.Get(resources, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -78,5 +81,3 @@ func (s *Status) Run(name string) (*release.Release, error) {
return rel, nil return rel, nil
} }
return nil, errors.New("unable to get kubeClient with interface InterfaceResources")
}

@ -27,10 +27,11 @@ import (
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube" "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" release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util" releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
helmtime "helm.sh/helm/v4/pkg/time"
) )
// Uninstall is the action for uninstalling releases. // Uninstall is the action for uninstalling releases.
@ -57,7 +58,7 @@ func NewUninstall(cfg *Configuration) *Uninstall {
} }
// Run uninstalls the given release. // 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 { if err := u.cfg.KubeClient.IsReachable(); err != nil {
return nil, err return nil, err
} }
@ -68,51 +69,61 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
} }
if u.DryRun { if u.DryRun {
r, err := u.cfg.releaseContent(name, 0) ri, err := u.cfg.releaseContent(name, 0)
if err != nil { if err != nil {
if u.IgnoreNotFound && errors.Is(err, driver.ErrReleaseNotFound) { if u.IgnoreNotFound && errors.Is(err, driver.ErrReleaseNotFound) {
return nil, nil return nil, nil
} }
return &release.UninstallReleaseResponse{}, err return &releasei.UninstallReleaseResponse{}, err
} }
return &release.UninstallReleaseResponse{Release: r}, nil r, err := releaserToV1Release(ri)
if err != nil {
return nil, err
}
return &releasei.UninstallReleaseResponse{Release: r}, nil
} }
if err := chartutil.ValidateReleaseName(name); err != nil { if err := chartutil.ValidateReleaseName(name); err != nil {
return nil, fmt.Errorf("uninstall: Release name is invalid: %s", name) 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 err != nil {
if u.IgnoreNotFound { if u.IgnoreNotFound {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("uninstall: Release not loaded: %s: %w", name, err) return nil, fmt.Errorf("uninstall: Release not loaded: %s: %w", name, err)
} }
if len(rels) < 1 { if len(relsi) < 1 {
return nil, errMissingRelease return nil, errMissingRelease
} }
rels, err := releaseListToV1List(relsi)
if err != nil {
return nil, err
}
releaseutil.SortByRevision(rels) releaseutil.SortByRevision(rels)
rel := rels[len(rels)-1] rel := rels[len(rels)-1]
// TODO: Are there any cases where we want to force a delete even if it's // TODO: Are there any cases where we want to force a delete even if it's
// already marked deleted? // already marked deleted?
if rel.Info.Status == release.StatusUninstalled { if rel.Info.Status == common.StatusUninstalled {
if !u.KeepHistory { if !u.KeepHistory {
if err := u.purgeReleases(rels...); err != nil { if err := u.purgeReleases(rels...); err != nil {
return nil, fmt.Errorf("uninstall: Failed to purge the release: %w", err) 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) return nil, fmt.Errorf("the release named %q is already deleted", name)
} }
slog.Debug("uninstall: deleting release", "name", name) u.cfg.Logger().Debug("uninstall: deleting release", "name", name)
rel.Info.Status = release.StatusUninstalling rel.Info.Status = common.StatusUninstalling
rel.Info.Deleted = helmtime.Now() rel.Info.Deleted = time.Now()
rel.Info.Description = "Deletion in progress (or silently failed)" rel.Info.Description = "Deletion in progress (or silently failed)"
res := &release.UninstallReleaseResponse{Release: rel} res := &releasei.UninstallReleaseResponse{Release: rel}
if !u.DisableHooks { if !u.DisableHooks {
serverSideApply := true serverSideApply := true
@ -120,18 +131,18 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
return res, err return res, err
} }
} else { } else {
slog.Debug("delete hooks disabled", "release", name) u.cfg.Logger().Debug("delete hooks disabled", "release", name)
} }
// From here on out, the release is currently considered to be in StatusUninstalling // From here on out, the release is currently considered to be in StatusUninstalling
// state. // state.
if err := u.cfg.Releases.Update(rel); err != nil { if err := u.cfg.Releases.Update(rel); err != nil {
slog.Debug("uninstall: Failed to store updated release", slog.Any("error", err)) u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err))
} }
deletedResources, kept, errs := u.deleteRelease(rel) deletedResources, kept, errs := u.deleteRelease(rel)
if errs != nil { if errs != nil {
slog.Debug("uninstall: Failed to delete release", slog.Any("error", errs)) u.cfg.Logger().Debug("uninstall: Failed to delete release", slog.Any("error", errs))
return nil, fmt.Errorf("failed to delete release: %s", name) return nil, fmt.Errorf("failed to delete release: %s", name)
} }
@ -151,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 { if len(u.Description) > 0 {
rel.Info.Description = u.Description rel.Info.Description = u.Description
} else { } else {
@ -159,7 +170,7 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
} }
if !u.KeepHistory { if !u.KeepHistory {
slog.Debug("purge requested", "release", name) u.cfg.Logger().Debug("purge requested", "release", name)
err := u.purgeReleases(rels...) err := u.purgeReleases(rels...)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("uninstall: Failed to purge the release: %w", err)) errs = append(errs, fmt.Errorf("uninstall: Failed to purge the release: %w", err))
@ -174,7 +185,7 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
} }
if err := u.cfg.Releases.Update(rel); err != nil { if err := u.cfg.Releases.Update(rel); err != nil {
slog.Debug("uninstall: Failed to store updated release", slog.Any("error", err)) u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err))
} }
if len(errs) > 0 { if len(errs) > 0 {
@ -246,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)} return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)}
} }
if len(resources) > 0 { if len(resources) > 0 {
if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok { _, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation))
_, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.DeletionPropagation))
return resources, kept, errs
}
_, errs = u.cfg.KubeClient.Delete(resources)
} }
return resources, kept, errs return resources, kept, errs
} }

@ -17,7 +17,9 @@ limitations under the License.
package action package action
import ( import (
"errors"
"fmt" "fmt"
"io"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -25,7 +27,7 @@ import (
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake" 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 { func uninstallAction(t *testing.T) *Uninstall {
@ -114,10 +116,12 @@ func TestUninstallRelease_Wait(t *testing.T) {
failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitForDeleteError = fmt.Errorf("U timed out") failer.WaitForDeleteError = fmt.Errorf("U timed out")
unAction.cfg.KubeClient = failer unAction.cfg.KubeClient = failer
res, err := unAction.Run(rel.Name) resi, err := unAction.Run(rel.Name)
is.Error(err) is.Error(err)
is.Contains(err.Error(), "U timed out") 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) { func TestUninstallRelease_Cascade(t *testing.T) {
@ -144,10 +148,24 @@ func TestUninstallRelease_Cascade(t *testing.T) {
}` }`
unAction.cfg.Releases.Create(rel) unAction.cfg.Releases.Create(rel)
failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) 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 failer.BuildDummy = true
unAction.cfg.KubeClient = failer unAction.cfg.KubeClient = failer
_, err := unAction.Run(rel.Name) _, err := unAction.Run(rel.Name)
require.Error(t, err) require.Error(t, err)
is.Contains(err.Error(), "failed to delete release: come-fail-away") is.Contains(err.Error(), "failed to delete release: come-fail-away")
} }
func TestUninstallRun_UnreachableKubeClient(t *testing.T) {
t.Helper()
config := actionConfigFixture(t)
failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
failingKubeClient.ConnectionError = errors.New("connection refused")
config.KubeClient = &failingKubeClient
client := NewUninstall(config)
result, err := client.Run("")
assert.Nil(t, result)
assert.ErrorContains(t, err, "connection refused")
}

@ -26,15 +26,19 @@ import (
"sync" "sync"
"time" "time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/resource" "k8s.io/cli-runtime/pkg/resource"
"helm.sh/helm/v4/pkg/chart"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/common/util" "helm.sh/helm/v4/pkg/chart/common/util"
chart "helm.sh/helm/v4/pkg/chart/v2" chartv2 "helm.sh/helm/v4/pkg/chart/v2"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
"helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/kube"
"helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/postrenderer"
"helm.sh/helm/v4/pkg/registry" "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" release "helm.sh/helm/v4/pkg/release/v1"
releaseutil "helm.sh/helm/v4/pkg/release/v1/util" releaseutil "helm.sh/helm/v4/pkg/release/v1/util"
"helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/storage/driver"
@ -72,10 +76,8 @@ type Upgrade struct {
WaitForJobs bool WaitForJobs bool
// DisableHooks disables hook processing if set to true. // DisableHooks disables hook processing if set to true.
DisableHooks bool DisableHooks bool
// DryRun controls whether the operation is prepared, but not executed. // DryRunStrategy can be set to prepare, but not execute the operation and whether or not to interact with the remote cluster
DryRun bool DryRunStrategy DryRunStrategy
// DryRunOption controls whether the operation is prepared, but not executed with options on whether or not to interact with the remote cluster.
DryRunOption string
// HideSecret can be set to true when DryRun is enabled in order to hide // 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. // Kubernetes Secrets in the output. It cannot be used outside of DryRun.
HideSecret bool HideSecret bool
@ -139,6 +141,7 @@ func NewUpgrade(cfg *Configuration) *Upgrade {
up := &Upgrade{ up := &Upgrade{
cfg: cfg, cfg: cfg,
ServerSideApply: "auto", ServerSideApply: "auto",
DryRunStrategy: DryRunNone,
} }
up.registryClient = cfg.RegistryClient up.registryClient = cfg.RegistryClient
@ -151,17 +154,27 @@ func (u *Upgrade) SetRegistryClient(client *registry.Client) {
} }
// Run executes the upgrade on the given release. // Run executes the upgrade on the given release.
func (u *Upgrade) Run(name string, chart *chart.Chart, 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() ctx := context.Background()
return u.RunWithContext(ctx, name, chart, vals) return u.RunWithContext(ctx, name, chart, vals)
} }
// RunWithContext executes the upgrade on the given release with context. // RunWithContext executes the upgrade on the given release with context.
func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.Chart, 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 { if err := u.cfg.KubeClient.IsReachable(); err != nil {
return nil, err return nil, err
} }
var chrt *chartv2.Chart
switch c := ch.(type) {
case *chartv2.Chart:
chrt = c
case chartv2.Chart:
chrt = &c
default:
return nil, errors.New("invalid chart apiVersion")
}
// Make sure wait is set if RollbackOnFailure. This makes it so // Make sure wait is set if RollbackOnFailure. This makes it so
// the user doesn't have to specify both // the user doesn't have to specify both
if u.WaitStrategy == kube.HookOnlyStrategy && u.RollbackOnFailure { if u.WaitStrategy == kube.HookOnlyStrategy && u.RollbackOnFailure {
@ -172,23 +185,23 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
return nil, fmt.Errorf("release name is invalid: %s", name) return nil, fmt.Errorf("release name is invalid: %s", name)
} }
slog.Debug("preparing upgrade", "name", name) u.cfg.Logger().Debug("preparing upgrade", "name", name)
currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(name, chart, vals) currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(name, chrt, vals)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u.cfg.Releases.MaxHistory = u.MaxHistory u.cfg.Releases.MaxHistory = u.MaxHistory
slog.Debug("performing update", "name", name) u.cfg.Logger().Debug("performing update", "name", name)
res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply) res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply)
if err != nil { if err != nil {
return res, err return res, err
} }
// Do not update for dry runs // Do not update for dry runs
if !u.isDryRun() { if !isDryRun(u.DryRunStrategy) {
slog.Debug("updating status for upgraded release", "name", name) u.cfg.Logger().Debug("updating status for upgraded release", "name", name)
if err := u.cfg.Releases.Update(upgradedRelease); err != nil { if err := u.cfg.Releases.Update(upgradedRelease); err != nil {
return res, err return res, err
} }
@ -197,27 +210,19 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
return res, nil 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. // prepareUpgrade builds an upgraded release for an upgrade operation.
func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) { func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) {
if chart == nil { if chart == nil {
return nil, nil, false, errMissingChart return nil, nil, false, errMissingChart
} }
// HideSecret must be used with dry run. Otherwise, return an error. // 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") return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode")
} }
// finds the last non-deleted release with the given name // 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 { if err != nil {
// to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist // 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) { if errors.Is(err, driver.ErrReleaseNotFound) {
@ -226,26 +231,37 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
return nil, nil, false, err 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. // 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() { if lastRelease.Info.Status.IsPending() {
return nil, nil, false, errPending return nil, nil, false, errPending
} }
var currentRelease *release.Release 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 // no need to retrieve the last deployed release from storage as the last release is deployed
currentRelease = lastRelease currentRelease = lastRelease
} else { } else {
// finds the deployed release with the given name // 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 err != nil {
if errors.Is(err, driver.ErrNoDeployedReleases) && 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 currentRelease = lastRelease
} else { } else {
return nil, nil, false, err return nil, nil, false, err
} }
} }
} }
// determine if values will be reused // determine if values will be reused
@ -278,13 +294,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
return nil, nil, false, err return nil, nil, false, err
} }
// Determine whether or not to interact with remote hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret)
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)
if err != nil { if err != nil {
return nil, nil, false, err return nil, nil, false, err
} }
@ -298,7 +308,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
return nil, nil, false, err return nil, nil, false, err
} }
slog.Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod)) u.cfg.Logger().Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod))
// Store an upgraded release. // Store an upgraded release.
upgradedRelease := &release.Release{ upgradedRelease := &release.Release{
@ -309,7 +319,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
Info: &release.Info{ Info: &release.Info{
FirstDeployed: currentRelease.Info.FirstDeployed, FirstDeployed: currentRelease.Info.FirstDeployed,
LastDeployed: Timestamper(), LastDeployed: Timestamper(),
Status: release.StatusPendingUpgrade, Status: rcommon.StatusPendingUpgrade,
Description: "Preparing upgrade", // This should be overwritten later. Description: "Preparing upgrade", // This should be overwritten later.
}, },
Version: revision, Version: revision,
@ -380,9 +390,8 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
return nil return nil
}) })
// Run if it is a dry run if isDryRun(u.DryRunStrategy) {
if u.isDryRun() { u.cfg.Logger().Debug("dry run for release", "name", upgradedRelease.Name)
slog.Debug("dry run for release", "name", upgradedRelease.Name)
if len(u.Description) > 0 { if len(u.Description) > 0 {
upgradedRelease.Info.Description = u.Description upgradedRelease.Info.Description = u.Description
} else { } else {
@ -391,7 +400,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
return upgradedRelease, nil return upgradedRelease, nil
} }
slog.Debug("creating upgraded release", "name", upgradedRelease.Name) u.cfg.Logger().Debug("creating upgraded release", "name", upgradedRelease.Name)
if err := u.cfg.Releases.Create(upgradedRelease); err != nil { if err := u.cfg.Releases.Create(upgradedRelease); err != nil {
return nil, err return nil, err
} }
@ -448,7 +457,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
return return
} }
} else { } else {
slog.Debug("upgrade hooks disabled", "name", upgradedRelease.Name) u.cfg.Logger().Debug("upgrade hooks disabled", "name", upgradedRelease.Name)
} }
upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply
@ -492,10 +501,10 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
} }
} }
originalRelease.Info.Status = release.StatusSuperseded originalRelease.Info.Status = rcommon.StatusSuperseded
u.cfg.recordRelease(originalRelease) u.cfg.recordRelease(originalRelease)
upgradedRelease.Info.Status = release.StatusDeployed upgradedRelease.Info.Status = rcommon.StatusDeployed
if len(u.Description) > 0 { if len(u.Description) > 0 {
upgradedRelease.Info.Description = u.Description upgradedRelease.Info.Description = u.Description
} else { } else {
@ -506,14 +515,14 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) { func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) {
msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err) msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err)
slog.Warn("upgrade failed", "name", rel.Name, slog.Any("error", err)) u.cfg.Logger().Warn("upgrade failed", "name", rel.Name, slog.Any("error", err))
rel.Info.Status = release.StatusFailed rel.Info.Status = rcommon.StatusFailed
rel.Info.Description = msg rel.Info.Description = msg
u.cfg.recordRelease(rel) u.cfg.recordRelease(rel)
if u.CleanupOnFail && len(created) > 0 { if u.CleanupOnFail && len(created) > 0 {
slog.Debug("cleanup on fail set", "cleaning_resources", len(created)) u.cfg.Logger().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 { if errs != nil {
return rel, fmt.Errorf( return rel, fmt.Errorf(
"an error occurred while cleaning up resources. original upgrade error: %w: %w", "an error occurred while cleaning up resources. original upgrade error: %w: %w",
@ -524,11 +533,11 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
), ),
) )
} }
slog.Debug("resource cleanup complete") u.cfg.Logger().Debug("resource cleanup complete")
} }
if u.RollbackOnFailure { if u.RollbackOnFailure {
slog.Debug("Upgrade failed and rollback-on-failure is set, rolling back to previous successful release") u.cfg.Logger().Debug("Upgrade failed and rollback-on-failure is set, rolling back to previous successful release")
// As a protection, get the last successful release before rollback. // As a protection, get the last successful release before rollback.
// If there are no successful releases, bail out // If there are no successful releases, bail out
@ -538,12 +547,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) 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 // There isn't a way to tell if a previous release was successful, but
// generally failed releases do not get superseded unless the next // generally failed releases do not get superseded unless the next
// release is successful, so this should be relatively safe // release is successful, so this should be relatively safe
filteredHistory := releaseutil.FilterFunc(func(r *release.Release) bool { filteredHistory := releaseutil.FilterFunc(func(r *release.Release) bool {
return r.Info.Status == release.StatusSuperseded || r.Info.Status == release.StatusDeployed return r.Info.Status == rcommon.StatusSuperseded || r.Info.Status == rcommon.StatusDeployed
}).Filter(fullHistory) }).Filter(fullHistoryV1)
if len(filteredHistory) == 0 { if len(filteredHistory) == 0 {
return rel, fmt.Errorf("unable to find a previously successful release when attempting to rollback. original upgrade error: %w", err) return rel, fmt.Errorf("unable to find a previously successful release when attempting to rollback. original upgrade error: %w", err)
} }
@ -552,9 +565,7 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
rollin := NewRollback(u.cfg) rollin := NewRollback(u.cfg)
rollin.Version = filteredHistory[0].Version rollin.Version = filteredHistory[0].Version
if u.WaitStrategy == kube.HookOnlyStrategy { rollin.WaitStrategy = u.WaitStrategy
rollin.WaitStrategy = kube.StatusWatcherStrategy
}
rollin.WaitForJobs = u.WaitForJobs rollin.WaitForJobs = u.WaitForJobs
rollin.DisableHooks = u.DisableHooks rollin.DisableHooks = u.DisableHooks
rollin.ForceReplace = u.ForceReplace rollin.ForceReplace = u.ForceReplace
@ -578,16 +589,16 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
// //
// This is skipped if the u.ResetValues flag is set, in which case the // This is skipped if the u.ResetValues flag is set, in which case the
// request values are not altered. // request values are not altered.
func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newVals map[string]interface{}) (map[string]interface{}, error) { func (u *Upgrade) reuseValues(chart *chartv2.Chart, current *release.Release, newVals map[string]interface{}) (map[string]interface{}, error) {
if u.ResetValues { if u.ResetValues {
// If ResetValues is set, we completely ignore current.Config. // If ResetValues is set, we completely ignore current.Config.
slog.Debug("resetting values to the chart's original version") u.cfg.Logger().Debug("resetting values to the chart's original version")
return newVals, nil return newVals, nil
} }
// If the ReuseValues flag is set, we always copy the old values over the new config's values. // If the ReuseValues flag is set, we always copy the old values over the new config's values.
if u.ReuseValues { if u.ReuseValues {
slog.Debug("reusing the old release's values") u.cfg.Logger().Debug("reusing the old release's values")
// We have to regenerate the old coalesced values: // We have to regenerate the old coalesced values:
oldVals, err := util.CoalesceValues(current.Chart, current.Config) oldVals, err := util.CoalesceValues(current.Chart, current.Config)
@ -604,7 +615,7 @@ func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newV
// If the ResetThenReuseValues flag is set, we use the new chart's values, but we copy the old config's values over the new config's values. // If the ResetThenReuseValues flag is set, we use the new chart's values, but we copy the old config's values over the new config's values.
if u.ResetThenReuseValues { if u.ResetThenReuseValues {
slog.Debug("merging values from old release to new values") u.cfg.Logger().Debug("merging values from old release to new values")
newVals = util.CoalesceTables(newVals, current.Config) newVals = util.CoalesceTables(newVals, current.Config)
@ -612,7 +623,7 @@ func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newV
} }
if len(newVals) == 0 && len(current.Config) > 0 { if len(newVals) == 0 && len(current.Config) > 0 {
slog.Debug("copying values from old release", "name", current.Name, "version", current.Version) u.cfg.Logger().Debug("copying values from old release", "name", current.Name, "version", current.Version)
newVals = current.Config newVals = current.Config
} }
return newVals, nil return newVals, nil

@ -18,7 +18,9 @@ package action
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io"
"reflect" "reflect"
"testing" "testing"
"time" "time"
@ -31,8 +33,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
kubefake "helm.sh/helm/v4/pkg/kube/fake" kubefake "helm.sh/helm/v4/pkg/kube/fake"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time"
) )
func upgradeAction(t *testing.T) *Upgrade { func upgradeAction(t *testing.T) *Upgrade {
@ -51,24 +53,28 @@ func TestUpgradeRelease_Success(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "previous-release" rel.Name = "previous-release"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
req.NoError(upAction.cfg.Releases.Create(rel)) req.NoError(upAction.cfg.Releases.Create(rel))
upAction.WaitStrategy = kube.StatusWatcherStrategy upAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{} vals := map[string]interface{}{}
ctx, done := context.WithCancel(t.Context()) ctx, done := context.WithCancel(t.Context())
res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals)
done()
req.NoError(err) 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 // Detecting previous bug where context termination after successful release
// caused release to fail. // caused release to fail.
time.Sleep(time.Millisecond * 100) time.Sleep(time.Millisecond * 100)
lastRelease, err := upAction.cfg.Releases.Last(rel.Name) lastReleasei, err := upAction.cfg.Releases.Last(rel.Name)
req.NoError(err) req.NoError(err)
is.Equal(lastRelease.Info.Status, release.StatusDeployed) lastRelease, err := releaserToV1Release(lastReleasei)
req.NoError(err)
is.Equal(lastRelease.Info.Status, common.StatusDeployed)
} }
func TestUpgradeRelease_Wait(t *testing.T) { func TestUpgradeRelease_Wait(t *testing.T) {
@ -78,7 +84,7 @@ func TestUpgradeRelease_Wait(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "come-fail-away" rel.Name = "come-fail-away"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -87,10 +93,12 @@ func TestUpgradeRelease_Wait(t *testing.T) {
upAction.WaitStrategy = kube.StatusWatcherStrategy upAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals) resi, err := upAction.Run(rel.Name, buildChart(), vals)
req.Error(err) req.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "I timed out") 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) { func TestUpgradeRelease_WaitForJobs(t *testing.T) {
@ -100,7 +108,7 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "come-fail-away" rel.Name = "come-fail-away"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -110,10 +118,12 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) {
upAction.WaitForJobs = true upAction.WaitForJobs = true
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals) resi, err := upAction.Run(rel.Name, buildChart(), vals)
req.Error(err) req.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "I timed out") 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) { func TestUpgradeRelease_CleanupOnFail(t *testing.T) {
@ -123,7 +133,7 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "come-fail-away" rel.Name = "come-fail-away"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -134,11 +144,13 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) {
upAction.CleanupOnFail = true upAction.CleanupOnFail = true
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals) resi, err := upAction.Run(rel.Name, buildChart(), vals)
req.Error(err) req.Error(err)
is.NotContains(err.Error(), "unable to cleanup resources") is.NotContains(err.Error(), "unable to cleanup resources")
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "I timed out") 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) { func TestUpgradeRelease_RollbackOnFailure(t *testing.T) {
@ -150,7 +162,7 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) {
rel := releaseStub() rel := releaseStub()
rel.Name = "nuketown" rel.Name = "nuketown"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -160,23 +172,27 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) {
upAction.RollbackOnFailure = true upAction.RollbackOnFailure = true
vals := map[string]interface{}{} vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals) resi, err := upAction.Run(rel.Name, buildChart(), vals)
req.Error(err) req.Error(err)
is.Contains(err.Error(), "arming key removed") is.Contains(err.Error(), "arming key removed")
is.Contains(err.Error(), "rollback-on-failure") is.Contains(err.Error(), "rollback-on-failure")
res, err := releaserToV1Release(resi)
is.NoError(err)
// Now make sure it is actually upgraded // 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) is.NoError(err)
// Should have rolled back to the previous // 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) { t.Run("rollback-on-failure uninstall fails", func(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "fallout" rel.Name = "fallout"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -217,7 +233,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
rel := releaseStub() rel := releaseStub()
rel.Name = "nuketown" rel.Name = "nuketown"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
rel.Config = existingValues rel.Config = existingValues
err := upAction.cfg.Releases.Create(rel) err := upAction.cfg.Releases.Create(rel)
@ -225,18 +241,23 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
upAction.ReuseValues = true upAction.ReuseValues = true
// setting newValues and upgrading // 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) is.NoError(err)
// Now make sure it is actually upgraded // 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) is.NoError(err)
if updatedRes == nil { if updatedResi == nil {
is.Fail("Updated Release is nil") is.Fail("Updated Release is nil")
return 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(expectedValues, updatedRes.Config)
}) })
@ -258,7 +279,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
withValues(chartDefaultValues), withValues(chartDefaultValues),
withMetadataDependency(dependency), withMetadataDependency(dependency),
) )
now := helmtime.Now() now := time.Now()
existingValues := map[string]interface{}{ existingValues := map[string]interface{}{
"subchart": map[string]interface{}{ "subchart": map[string]interface{}{
"enabled": false, "enabled": false,
@ -269,7 +290,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
Info: &release.Info{ Info: &release.Info{
FirstDeployed: now, FirstDeployed: now,
LastDeployed: now, LastDeployed: now,
Status: release.StatusDeployed, Status: common.StatusDeployed,
Description: "Named Release Stub", Description: "Named Release Stub",
}, },
Chart: sampleChart, Chart: sampleChart,
@ -287,18 +308,23 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
withMetadataDependency(dependency), withMetadataDependency(dependency),
) )
// reusing values and upgrading // 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) is.NoError(err)
// Now get the upgraded release // 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) is.NoError(err)
if updatedRes == nil { if updatedResi == nil {
is.Fail("Updated Release is nil") is.Fail("Updated Release is nil")
return 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") is.Equal(0, len(updatedRes.Chart.Dependencies()), "expected 0 dependencies")
expectedValues := map[string]interface{}{ expectedValues := map[string]interface{}{
@ -338,7 +364,7 @@ func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) {
rel := releaseStub() rel := releaseStub()
rel.Name = "nuketown" rel.Name = "nuketown"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
rel.Config = existingValues rel.Config = existingValues
err := upAction.cfg.Releases.Create(rel) err := upAction.cfg.Releases.Create(rel)
@ -346,18 +372,23 @@ func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) {
upAction.ResetThenReuseValues = true upAction.ResetThenReuseValues = true
// setting newValues and upgrading // 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) is.NoError(err)
// Now make sure it is actually upgraded // 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) is.NoError(err)
if updatedRes == nil { if updatedResi == nil {
is.Fail("Updated Release is nil") is.Fail("Updated Release is nil")
return 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(expectedValues, updatedRes.Config)
is.Equal(newChartValues, updatedRes.Chart.Values) is.Equal(newChartValues, updatedRes.Chart.Values)
}) })
@ -369,11 +400,11 @@ func TestUpgradeRelease_Pending(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "come-fail-away" rel.Name = "come-fail-away"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
rel2 := releaseStub() rel2 := releaseStub()
rel2.Name = "come-fail-away" rel2.Name = "come-fail-away"
rel2.Info.Status = release.StatusPendingUpgrade rel2.Info.Status = common.StatusPendingUpgrade
rel2.Version = 2 rel2.Version = 2
upAction.cfg.Releases.Create(rel2) upAction.cfg.Releases.Create(rel2)
@ -390,7 +421,7 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "interrupted-release" rel.Name = "interrupted-release"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -402,11 +433,13 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel) 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) req.Error(err)
res, err := releaserToV1Release(resi)
is.NoError(err)
is.Contains(res.Info.Description, "Upgrade \"interrupted-release\" failed: context canceled") 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) { func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) {
@ -417,7 +450,7 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "interrupted-release" rel.Name = "interrupted-release"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
upAction.cfg.Releases.Create(rel) upAction.cfg.Releases.Create(rel)
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
@ -429,16 +462,19 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel) 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) 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") 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 // 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) is.NoError(err)
// Should have rolled back to the previous // 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) { func TestMergeCustomLabels(t *testing.T) {
@ -467,7 +503,7 @@ func TestUpgradeRelease_Labels(t *testing.T) {
"key1": "val1", "key1": "val1",
"key2": "val2.1", "key2": "val2.1",
} }
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
err := upAction.cfg.Releases.Create(rel) err := upAction.cfg.Releases.Create(rel)
is.NoError(err) is.NoError(err)
@ -478,29 +514,35 @@ func TestUpgradeRelease_Labels(t *testing.T) {
"key3": "val3", "key3": "val3",
} }
// setting newValues and upgrading // 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) is.NoError(err)
// Now make sure it is actually upgraded and labels were merged // 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) is.NoError(err)
if updatedRes == nil { if updatedResi == nil {
is.Fail("Updated Release is nil") is.Fail("Updated Release is nil")
return 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) is.Equal(mergeCustomLabels(rel.Labels, upAction.Labels), updatedRes.Labels)
// Now make sure it is suppressed release still contains original 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) is.NoError(err)
if initialRes == nil { if initialResi == nil {
is.Fail("Updated Release is nil") is.Fail("Updated Release is nil")
return 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) is.Equal(initialRes.Labels, rel.Labels)
} }
@ -515,7 +557,7 @@ func TestUpgradeRelease_SystemLabels(t *testing.T) {
"key1": "val1", "key1": "val1",
"key2": "val2.1", "key2": "val2.1",
} }
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
err := upAction.cfg.Releases.Create(rel) err := upAction.cfg.Releases.Create(rel)
is.NoError(err) is.NoError(err)
@ -541,22 +583,26 @@ func TestUpgradeRelease_DryRun(t *testing.T) {
upAction := upgradeAction(t) upAction := upgradeAction(t)
rel := releaseStub() rel := releaseStub()
rel.Name = "previous-release" rel.Name = "previous-release"
rel.Info.Status = release.StatusDeployed rel.Info.Status = common.StatusDeployed
req.NoError(upAction.cfg.Releases.Create(rel)) req.NoError(upAction.cfg.Releases.Create(rel))
upAction.DryRun = true upAction.DryRunStrategy = DryRunClient
vals := map[string]interface{}{} vals := map[string]interface{}{}
ctx, done := context.WithCancel(t.Context()) 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() done()
req.NoError(err) 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") 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)
lastRelease, err := releaserToV1Release(lastReleasei)
req.NoError(err) req.NoError(err)
is.Equal(lastRelease.Info.Status, release.StatusDeployed) is.Equal(lastRelease.Info.Status, common.StatusDeployed)
is.Equal(1, lastRelease.Version) is.Equal(1, lastRelease.Version)
// Test the case for hiding the secret to ensure it is not displayed // Test the case for hiding the secret to ensure it is not displayed
@ -564,19 +610,23 @@ func TestUpgradeRelease_DryRun(t *testing.T) {
vals = map[string]interface{}{} vals = map[string]interface{}{}
ctx, done = context.WithCancel(t.Context()) 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() done()
req.NoError(err) 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") 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) req.NoError(err)
is.Equal(lastRelease.Info.Status, release.StatusDeployed) is.Equal(lastRelease.Info.Status, common.StatusDeployed)
is.Equal(1, lastRelease.Version) is.Equal(1, lastRelease.Version)
// Ensure in a dry run mode when using HideSecret // Ensure in a dry run mode when using HideSecret
upAction.DryRun = false upAction.DryRunStrategy = DryRunNone
vals = map[string]interface{}{} vals = map[string]interface{}{}
ctx, done = context.WithCancel(t.Context()) ctx, done = context.WithCancel(t.Context())
@ -690,3 +740,18 @@ func TestGetUpgradeServerSideValue(t *testing.T) {
} }
} }
func TestUpgradeRun_UnreachableKubeClient(t *testing.T) {
t.Helper()
config := actionConfigFixture(t)
failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil}
failingKubeClient.ConnectionError = errors.New("connection refused")
config.KubeClient = &failingKubeClient
client := NewUpgrade(config)
vals := map[string]interface{}{}
result, err := client.Run("", buildChart(), vals)
assert.Nil(t, result)
assert.ErrorContains(t, err, "connection refused")
}

@ -28,7 +28,6 @@ import (
// It provides the implementation of 'helm verify'. // It provides the implementation of 'helm verify'.
type Verify struct { type Verify struct {
Keyring string Keyring string
Out string
} }
// NewVerify creates a new Verify object with the given configuration. // NewVerify creates a new Verify object with the given configuration.
@ -37,23 +36,18 @@ func NewVerify() *Verify {
} }
// Run executes 'helm verify'. // Run executes 'helm verify'.
func (v *Verify) Run(chartfile string) error { func (v *Verify) Run(chartfile string) (string, error) {
var out strings.Builder var out strings.Builder
p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring) p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring)
if err != nil { if err != nil {
return err return "", err
} }
for name := range p.SignedBy.Identities { 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, "Using Key With Fingerprint: %X\n", p.SignedBy.PrimaryKey.Fingerprint)
fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash) _, _ = fmt.Fprintf(&out, "Chart Hash Verified: %s\n", p.FileHash)
// TODO(mattfarina): The output is set as a property rather than returned return out.String(), err
// 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
} }

@ -93,6 +93,14 @@ func (r *v2Accessor) Dependencies() []Charter {
return deps return deps
} }
func (r *v2Accessor) MetaDependencies() []Dependency {
var deps = make([]Dependency, len(r.chrt.Metadata.Dependencies))
for i, c := range r.chrt.Metadata.Dependencies {
deps[i] = c
}
return deps
}
func (r *v2Accessor) Values() map[string]interface{} { func (r *v2Accessor) Values() map[string]interface{} {
return r.chrt.Values return r.chrt.Values
} }
@ -101,6 +109,10 @@ func (r *v2Accessor) Schema() []byte {
return r.chrt.Schema return r.chrt.Schema
} }
func (r *v2Accessor) Deprecated() bool {
return r.chrt.Metadata.Deprecated
}
type v3Accessor struct { type v3Accessor struct {
chrt *v3chart.Chart chrt *v3chart.Chart
} }
@ -150,6 +162,14 @@ func (r *v3Accessor) Dependencies() []Charter {
return deps return deps
} }
func (r *v3Accessor) MetaDependencies() []Dependency {
var deps = make([]Dependency, len(r.chrt.Dependencies()))
for i, c := range r.chrt.Metadata.Dependencies {
deps[i] = c
}
return deps
}
func (r *v3Accessor) Values() map[string]interface{} { func (r *v3Accessor) Values() map[string]interface{} {
return r.chrt.Values return r.chrt.Values
} }
@ -158,6 +178,10 @@ func (r *v3Accessor) Schema() []byte {
return r.chrt.Schema return r.chrt.Schema
} }
func (r *v3Accessor) Deprecated() bool {
return r.chrt.Metadata.Deprecated
}
func structToMap(obj interface{}) (map[string]interface{}, error) { func structToMap(obj interface{}) (map[string]interface{}, error) {
objValue := reflect.ValueOf(obj) objValue := reflect.ValueOf(obj)

@ -15,6 +15,8 @@ limitations under the License.
package common package common
import "time"
// File represents a file as a name/value pair. // File represents a file as a name/value pair.
// //
// By convention, name is a relative path within the scope of the chart's // By convention, name is a relative path within the scope of the chart's
@ -24,4 +26,6 @@ type File struct {
Name string `json:"name"` Name string `json:"name"`
// Data is the template as byte data. // Data is the template as byte data.
Data []byte `json:"data"` Data []byte `json:"data"`
// ModTime is the file's mod-time
ModTime time.Time `json:"modtime,omitzero"`
} }

@ -94,7 +94,22 @@ func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) erro
if err != nil { if err != nil {
return err return err
} }
subchartValues := values[sub.Name()].(map[string]interface{})
raw, exists := values[sub.Name()]
if !exists || raw == nil {
// No values provided for this subchart; nothing to validate
continue
}
subchartValues, ok := raw.(map[string]any)
if !ok {
sb.WriteString(fmt.Sprintf(
"%s:\ninvalid type for values: expected object (map), got %T\n",
sub.Name(), raw,
))
continue
}
if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { if err := ValidateAgainstSchema(subchart, subchartValues); err != nil {
sb.WriteString(err.Error()) sb.WriteString(err.Error())
} }

@ -299,3 +299,93 @@ func TestValidateAgainstSingleSchema_UnresolvedURN_Ignored(t *testing.T) {
t.Fatalf("expected no error when URN unresolved is ignored, got: %v", err) t.Fatalf("expected no error when URN unresolved is ignored, got: %v", err)
} }
} }
// Non-regression tests for https://github.com/helm/helm/issues/31202
// Ensure ValidateAgainstSchema does not panic when:
// - subchart key is missing
// - subchart value is nil
// - subchart value has an invalid type
func TestValidateAgainstSchema_MissingSubchartValues_NoPanic(t *testing.T) {
subchartJSON := []byte(subchartSchema)
subchart := &chart.Chart{
Metadata: &chart.Metadata{Name: "subchart"},
Schema: subchartJSON,
}
chrt := &chart.Chart{
Metadata: &chart.Metadata{Name: "chrt"},
}
chrt.AddDependency(subchart)
// No "subchart" key present in values
vals := map[string]any{
"name": "John",
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("ValidateAgainstSchema panicked (missing subchart values): %v", r)
}
}()
if err := ValidateAgainstSchema(chrt, vals); err != nil {
t.Fatalf("expected no error when subchart values are missing, got: %v", err)
}
}
func TestValidateAgainstSchema_SubchartNil_NoPanic(t *testing.T) {
subchartJSON := []byte(subchartSchema)
subchart := &chart.Chart{
Metadata: &chart.Metadata{Name: "subchart"},
Schema: subchartJSON,
}
chrt := &chart.Chart{
Metadata: &chart.Metadata{Name: "chrt"},
}
chrt.AddDependency(subchart)
// "subchart" key present but nil
vals := map[string]any{
"name": "John",
"subchart": nil,
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("ValidateAgainstSchema panicked (nil subchart values): %v", r)
}
}()
if err := ValidateAgainstSchema(chrt, vals); err != nil {
t.Fatalf("expected no error when subchart values are nil, got: %v", err)
}
}
func TestValidateAgainstSchema_InvalidSubchartValuesType_NoPanic(t *testing.T) {
subchartJSON := []byte(subchartSchema)
subchart := &chart.Chart{
Metadata: &chart.Metadata{Name: "subchart"},
Schema: subchartJSON,
}
chrt := &chart.Chart{
Metadata: &chart.Metadata{Name: "chrt"},
}
chrt.AddDependency(subchart)
// "subchart" is the wrong type (string instead of map)
vals := map[string]any{
"name": "John",
"subchart": "oops",
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("ValidateAgainstSchema panicked (invalid subchart values type): %v", r)
}
}()
// We expect a non-nil error (invalid type), but crucially no panic.
if err := ValidateAgainstSchema(chrt, vals); err == nil {
t.Fatalf("expected an error when subchart values have invalid type, got nil")
}
}

@ -18,6 +18,7 @@ package util
import ( import (
"testing" "testing"
"time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
@ -46,7 +47,7 @@ func TestToRenderValues(t *testing.T) {
Templates: []*common.File{}, Templates: []*common.File{},
Values: chartValues, Values: chartValues,
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")},
}, },
} }
c.AddDependency(&chart.Chart{ c.AddDependency(&chart.Chart{

@ -0,0 +1,64 @@
/*
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 chart
import (
"errors"
v3chart "helm.sh/helm/v4/internal/chart/v3"
v2chart "helm.sh/helm/v4/pkg/chart/v2"
)
var NewDependencyAccessor func(dep Dependency) (DependencyAccessor, error) = NewDefaultDependencyAccessor //nolint:revive
func NewDefaultDependencyAccessor(dep Dependency) (DependencyAccessor, error) {
switch v := dep.(type) {
case v2chart.Dependency:
return &v2DependencyAccessor{&v}, nil
case *v2chart.Dependency:
return &v2DependencyAccessor{v}, nil
case v3chart.Dependency:
return &v3DependencyAccessor{&v}, nil
case *v3chart.Dependency:
return &v3DependencyAccessor{v}, nil
default:
return nil, errors.New("unsupported chart dependency type")
}
}
type v2DependencyAccessor struct {
dep *v2chart.Dependency
}
func (r *v2DependencyAccessor) Name() string {
return r.dep.Name
}
func (r *v2DependencyAccessor) Alias() string {
return r.dep.Alias
}
type v3DependencyAccessor struct {
dep *v3chart.Dependency
}
func (r *v3DependencyAccessor) Name() string {
return r.dep.Name
}
func (r *v3DependencyAccessor) Alias() string {
return r.dep.Alias
}

@ -21,6 +21,8 @@ import (
type Charter interface{} type Charter interface{}
type Dependency interface{}
type Accessor interface { type Accessor interface {
Name() string Name() string
IsRoot() bool IsRoot() bool
@ -30,6 +32,13 @@ type Accessor interface {
ChartFullPath() string ChartFullPath() string
IsLibraryChart() bool IsLibraryChart() bool
Dependencies() []Charter Dependencies() []Charter
MetaDependencies() []Dependency
Values() map[string]interface{} Values() map[string]interface{}
Schema() []byte Schema() []byte
Deprecated() bool
}
type DependencyAccessor interface {
Name() string
Alias() string
} }

@ -0,0 +1,197 @@
/*
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.
*/
// archive provides utility functions for working with Helm chart archive files
package archive
import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"regexp"
"strings"
"time"
)
// MaxDecompressedChartSize is the maximum size of a chart archive that will be
// decompressed. This is the decompressed size of all the files.
// The default value is 100 MiB.
var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
// The size of the file is the decompressed version of it when it is stored in an archive.
var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
var utf8bom = []byte{0xEF, 0xBB, 0xBF}
// BufferedFile represents an archive file buffered for later processing.
type BufferedFile struct {
Name string
ModTime time.Time
Data []byte
}
// LoadArchiveFiles reads in files out of an archive into memory. This function
// performs important path security checks and should always be used before
// expanding a tarball
func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
unzipped, err := gzip.NewReader(in)
if err != nil {
return nil, err
}
defer unzipped.Close()
files := []*BufferedFile{}
tr := tar.NewReader(unzipped)
remainingSize := MaxDecompressedChartSize
for {
b := bytes.NewBuffer(nil)
hd, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if hd.FileInfo().IsDir() {
// Use this instead of hd.Typeflag because we don't have to do any
// inference chasing.
continue
}
switch hd.Typeflag {
// We don't want to process these extension header files.
case tar.TypeXGlobalHeader, tar.TypeXHeader:
continue
}
// Archive could contain \ if generated on Windows
delimiter := "/"
if strings.ContainsRune(hd.Name, '\\') {
delimiter = "\\"
}
parts := strings.Split(hd.Name, delimiter)
n := strings.Join(parts[1:], delimiter)
// Normalize the path to the / delimiter
n = strings.ReplaceAll(n, delimiter, "/")
if path.IsAbs(n) {
return nil, errors.New("chart illegally contains absolute paths")
}
n = path.Clean(n)
if n == "." {
// In this case, the original path was relative when it should have been absolute.
return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
}
if strings.HasPrefix(n, "..") {
return nil, errors.New("chart illegally references parent directory")
}
// In some particularly arcane acts of path creativity, it is possible to intermix
// UNIX and Windows style paths in such a way that you produce a result of the form
// c:/foo even after all the built-in absolute path checks. So we explicitly check
// for this condition.
if drivePathPattern.MatchString(n) {
return nil, errors.New("chart contains illegally named files")
}
if parts[0] == "Chart.yaml" {
return nil, errors.New("chart yaml not in base directory")
}
if hd.Size > remainingSize {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
if hd.Size > MaxDecompressedFileSize {
return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
}
limitedReader := io.LimitReader(tr, remainingSize)
bytesWritten, err := io.Copy(b, limitedReader)
if err != nil {
return nil, err
}
remainingSize -= bytesWritten
// When the bytesWritten are less than the file size it means the limit reader ended
// copying early. Here we report that error. This is important if the last file extracted
// is the one that goes over the limit. It assumes the Size stored in the tar header
// is correct, something many applications do.
if bytesWritten < hd.Size || remainingSize <= 0 {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
data := bytes.TrimPrefix(b.Bytes(), utf8bom)
files = append(files, &BufferedFile{Name: n, ModTime: hd.ModTime, Data: data})
b.Reset()
}
if len(files) == 0 {
return nil, errors.New("no files in chart archive")
}
return files, nil
}
// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive.
//
// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence
// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error
// if we didn't check for this.
func EnsureArchive(name string, raw *os.File) error {
defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed.
// Check the file format to give us a chance to provide the user with more actionable feedback.
buffer := make([]byte, 512)
_, err := raw.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("file '%s' cannot be read: %s", name, err)
}
// Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject.
// Fix for: https://github.com/helm/helm/issues/12261
if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) {
// TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide
// variety of content (Makefile, .zshrc) as valid YAML without errors.
// Wrong content type. Let's check if it's yaml and give an extra hint?
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name)
}
return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType)
}
return nil
}
// isGZipApplication checks whether the archive is of the application/x-gzip type.
func isGZipApplication(data []byte) bool {
sig := []byte("\x1F\x8B\x08")
return bytes.HasPrefix(data, sig)
}

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
package loader package archive
import ( import (
"archive/tar" "archive/tar"

@ -0,0 +1,163 @@
/*
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 loader
import (
"compress/gzip"
"errors"
"fmt"
"os"
"path/filepath"
"sigs.k8s.io/yaml"
c3 "helm.sh/helm/v4/internal/chart/v3"
c3load "helm.sh/helm/v4/internal/chart/v3/loader"
"helm.sh/helm/v4/pkg/chart"
"helm.sh/helm/v4/pkg/chart/loader/archive"
c2 "helm.sh/helm/v4/pkg/chart/v2"
c2load "helm.sh/helm/v4/pkg/chart/v2/loader"
)
// ChartLoader loads a chart.
type ChartLoader interface {
Load() (chart.Charter, error)
}
// Loader returns a new ChartLoader appropriate for the given chart name
func Loader(name string) (ChartLoader, error) {
fi, err := os.Stat(name)
if err != nil {
return nil, err
}
if fi.IsDir() {
return DirLoader(name), nil
}
return FileLoader(name), nil
}
// Load takes a string name, tries to resolve it to a file or directory, and then loads it.
//
// This is the preferred way to load a chart. It will discover the chart encoding
// and hand off to the appropriate chart reader.
//
// If a .helmignore file is present, the directory loader will skip loading any files
// matching it. But .helmignore is not evaluated when reading out of an archive.
func Load(name string) (chart.Charter, error) {
l, err := Loader(name)
if err != nil {
return nil, err
}
return l.Load()
}
// DirLoader loads a chart from a directory
type DirLoader string
// Load loads the chart
func (l DirLoader) Load() (chart.Charter, error) {
return LoadDir(string(l))
}
func LoadDir(dir string) (chart.Charter, error) {
topdir, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
name := filepath.Join(topdir, "Chart.yaml")
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("unable to detect chart at %s: %w", name, err)
}
c := new(chartBase)
err = yaml.Unmarshal(data, c)
if err != nil {
return nil, fmt.Errorf("cannot load Chart.yaml: %w", err)
}
switch c.APIVersion {
case c2.APIVersionV1, c2.APIVersionV2, "":
return c2load.Load(dir)
case c3.APIVersionV3:
return c3load.Load(dir)
default:
return nil, errors.New("unsupported chart version")
}
}
// FileLoader loads a chart from a file
type FileLoader string
// Load loads a chart
func (l FileLoader) Load() (chart.Charter, error) {
return LoadFile(string(l))
}
func LoadFile(name string) (chart.Charter, error) {
if fi, err := os.Stat(name); err != nil {
return nil, err
} else if fi.IsDir() {
return nil, errors.New("cannot load a directory")
}
raw, err := os.Open(name)
if err != nil {
return nil, err
}
defer raw.Close()
err = archive.EnsureArchive(name, raw)
if err != nil {
return nil, err
}
files, err := archive.LoadArchiveFiles(raw)
if err != nil {
if err == gzip.ErrHeader {
return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err)
}
return nil, errors.New("unable to load chart archive")
}
for _, f := range files {
if f.Name == "Chart.yaml" {
c := new(chartBase)
if err := yaml.Unmarshal(f.Data, c); err != nil {
return c, fmt.Errorf("cannot load Chart.yaml: %w", err)
}
switch c.APIVersion {
case c2.APIVersionV1, c2.APIVersionV2, "":
return c2load.Load(name)
case c3.APIVersionV3:
return c3load.Load(name)
default:
return nil, errors.New("unsupported chart version")
}
}
}
return nil, errors.New("unable to detect chart version, no Chart.yaml found")
}
// chartBase is used to detect the API Version for the chart to run it through the
// loader for that type.
type chartBase struct {
APIVersion string `json:"apiVersion,omitempty"`
}

@ -19,6 +19,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
) )
@ -50,9 +51,13 @@ type Chart struct {
Values map[string]interface{} `json:"values"` Values map[string]interface{} `json:"values"`
// Schema is an optional JSON schema for imposing structure on Values // Schema is an optional JSON schema for imposing structure on Values
Schema []byte `json:"schema"` Schema []byte `json:"schema"`
// SchemaModTime the schema was last modified
SchemaModTime time.Time `json:"schemamodtime,omitempty"`
// Files are miscellaneous files in a chart archive, // Files are miscellaneous files in a chart archive,
// e.g. README, LICENSE, etc. // e.g. README, LICENSE, etc.
Files []*common.File `json:"files"` Files []*common.File `json:"files"`
// ModTime the chart metadata was last modified
ModTime time.Time `json:"modtime,omitzero"`
parent *Chart parent *Chart
dependencies []*Chart dependencies []*Chart

@ -18,6 +18,7 @@ package v2
import ( import (
"encoding/json" "encoding/json"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -25,26 +26,32 @@ import (
) )
func TestCRDs(t *testing.T) { func TestCRDs(t *testing.T) {
modTime := time.Now()
chrt := Chart{ chrt := Chart{
Files: []*common.File{ Files: []*common.File{
{ {
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "bar.yaml", Name: "bar.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crdsfoo/bar/baz.yaml", Name: "crdsfoo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/README.md", Name: "crds/README.md",
ModTime: modTime,
Data: []byte("# hello"), Data: []byte("# hello"),
}, },
}, },
@ -62,6 +69,7 @@ func TestSaveChartNoRawData(t *testing.T) {
Raw: []*common.File{ Raw: []*common.File{
{ {
Name: "fhqwhgads.yaml", Name: "fhqwhgads.yaml",
ModTime: time.Now(),
Data: []byte("Everybody to the Limit"), Data: []byte("Everybody to the Limit"),
}, },
}, },
@ -163,26 +171,32 @@ func TestChartFullPath(t *testing.T) {
} }
func TestCRDObjects(t *testing.T) { func TestCRDObjects(t *testing.T) {
modTime := time.Now()
chrt := Chart{ chrt := Chart{
Files: []*common.File{ Files: []*common.File{
{ {
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "bar.yaml", Name: "bar.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crdsfoo/bar/baz.yaml", Name: "crdsfoo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
{ {
Name: "crds/README.md", Name: "crds/README.md",
ModTime: modTime,
Data: []byte("# hello"), Data: []byte("# hello"),
}, },
}, },
@ -194,6 +208,7 @@ func TestCRDObjects(t *testing.T) {
Filename: "crds/foo.yaml", Filename: "crds/foo.yaml",
File: &common.File{ File: &common.File{
Name: "crds/foo.yaml", Name: "crds/foo.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
}, },
@ -202,6 +217,7 @@ func TestCRDObjects(t *testing.T) {
Filename: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml",
File: &common.File{ File: &common.File{
Name: "crds/foo/bar/baz.yaml", Name: "crds/foo/bar/baz.yaml",
ModTime: modTime,
Data: []byte("hello"), Data: []byte("hello"),
}, },
}, },

@ -57,8 +57,13 @@ func RunAll(baseDir string, values map[string]interface{}, namespace string, opt
} }
rules.Chartfile(&result) rules.Chartfile(&result)
rules.ValuesWithOverrides(&result, values) 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.Dependencies(&result)
rules.Crds(&result) rules.Crds(&result)

@ -27,8 +27,6 @@ import (
chartutil "helm.sh/helm/v4/pkg/chart/v2/util" chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
) )
var values map[string]interface{}
const namespace = "testNamespace" const namespace = "testNamespace"
const badChartDir = "rules/testdata/badchartfile" const badChartDir = "rules/testdata/badchartfile"
@ -41,13 +39,14 @@ const malformedTemplate = "rules/testdata/malformed-template"
const invalidChartFileDir = "rules/testdata/invalidchartfile" const invalidChartFileDir = "rules/testdata/invalidchartfile"
func TestBadChart(t *testing.T) { func TestBadChart(t *testing.T) {
var values map[string]any
m := RunAll(badChartDir, values, namespace).Messages m := RunAll(badChartDir, values, namespace).Messages
if len(m) != 8 { if len(m) != 9 {
t.Errorf("Number of errors %v", len(m)) t.Errorf("Number of errors %v", len(m))
t.Errorf("All didn't fail with expected errors, got %#v", m) t.Errorf("All didn't fail with expected errors, got %#v", m)
} }
// There should be one INFO, one WARNING, and 2 ERROR messages, check for them // There should be one INFO, 2 WARNING and 2 ERROR messages, check for them
var i, w, e, e2, e3, e4, e5, e6 bool var i, w, w2, e, e2, e3, e4, e5, e6 bool
for _, msg := range m { for _, msg := range m {
if msg.Severity == support.InfoSev { if msg.Severity == support.InfoSev {
if strings.Contains(msg.Err.Error(), "icon is recommended") { if strings.Contains(msg.Err.Error(), "icon is recommended") {
@ -83,13 +82,19 @@ func TestBadChart(t *testing.T) {
e6 = true e6 = true
} }
} }
if msg.Severity == support.WarningSev {
if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") {
w2 = true
}
}
} }
if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w { if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w || !w2 {
t.Errorf("Didn't find all the expected errors, got %#v", m) t.Errorf("Didn't find all the expected errors, got %#v", m)
} }
} }
func TestInvalidYaml(t *testing.T) { func TestInvalidYaml(t *testing.T) {
var values map[string]any
m := RunAll(badYamlFileDir, values, namespace).Messages m := RunAll(badYamlFileDir, values, namespace).Messages
if len(m) != 1 { if len(m) != 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m) t.Fatalf("All didn't fail with expected errors, got %#v", m)
@ -100,6 +105,7 @@ func TestInvalidYaml(t *testing.T) {
} }
func TestInvalidChartYaml(t *testing.T) { func TestInvalidChartYaml(t *testing.T) {
var values map[string]any
m := RunAll(invalidChartFileDir, values, namespace).Messages m := RunAll(invalidChartFileDir, values, namespace).Messages
if len(m) != 2 { if len(m) != 2 {
t.Fatalf("All didn't fail with expected errors, got %#v", m) t.Fatalf("All didn't fail with expected errors, got %#v", m)
@ -110,6 +116,7 @@ func TestInvalidChartYaml(t *testing.T) {
} }
func TestBadValues(t *testing.T) { func TestBadValues(t *testing.T) {
var values map[string]any
m := RunAll(badValuesFileDir, values, namespace).Messages m := RunAll(badValuesFileDir, values, namespace).Messages
if len(m) < 1 { if len(m) < 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m) t.Fatalf("All didn't fail with expected errors, got %#v", m)
@ -120,6 +127,7 @@ func TestBadValues(t *testing.T) {
} }
func TestBadCrdFile(t *testing.T) { func TestBadCrdFile(t *testing.T) {
var values map[string]any
m := RunAll(badCrdFileDir, values, namespace).Messages m := RunAll(badCrdFileDir, values, namespace).Messages
assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) 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'") assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'")
@ -127,6 +135,7 @@ func TestBadCrdFile(t *testing.T) {
} }
func TestGoodChart(t *testing.T) { func TestGoodChart(t *testing.T) {
var values map[string]any
m := RunAll(goodChartDir, values, namespace).Messages m := RunAll(goodChartDir, values, namespace).Messages
if len(m) != 0 { if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have") t.Error("All returned linter messages when it shouldn't have")
@ -140,6 +149,7 @@ func TestGoodChart(t *testing.T) {
// //
// See https://github.com/helm/helm/issues/7923 // See https://github.com/helm/helm/issues/7923
func TestHelmCreateChart(t *testing.T) { func TestHelmCreateChart(t *testing.T) {
var values map[string]any
dir := t.TempDir() dir := t.TempDir()
createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir)
@ -189,11 +199,11 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) {
// Add values to enable hpa, and ingress which are disabled by default. // Add values to enable hpa, and ingress which are disabled by default.
// This is the equivalent of: // This is the equivalent of:
// helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true'
updatedValues := map[string]interface{}{ updatedValues := map[string]any{
"autoscaling": map[string]interface{}{ "autoscaling": map[string]any{
"enabled": true, "enabled": true,
}, },
"ingress": map[string]interface{}{ "ingress": map[string]any{
"enabled": true, "enabled": true,
}, },
} }
@ -212,6 +222,7 @@ func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) {
// lint ignores import-values // lint ignores import-values
// See https://github.com/helm/helm/issues/9658 // See https://github.com/helm/helm/issues/9658
func TestSubChartValuesChart(t *testing.T) { func TestSubChartValuesChart(t *testing.T) {
var values map[string]any
m := RunAll(subChartValuesDir, values, namespace).Messages m := RunAll(subChartValuesDir, values, namespace).Messages
if len(m) != 0 { if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have") t.Error("All returned linter messages when it shouldn't have")
@ -224,6 +235,7 @@ func TestSubChartValuesChart(t *testing.T) {
// lint stuck with malformed template object // lint stuck with malformed template object
// See https://github.com/helm/helm/issues/11391 // See https://github.com/helm/helm/issues/11391
func TestMalformedTemplate(t *testing.T) { func TestMalformedTemplate(t *testing.T) {
var values map[string]any
c := time.After(3 * time.Second) c := time.After(3 * time.Second)
ch := make(chan int, 1) ch := make(chan int, 1)
var m []support.Message var m []support.Message

@ -67,6 +67,7 @@ func Chartfile(linter *support.Linter) {
linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile))
linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile))
linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile))
linter.RunLinterRule(support.WarningSev, chartFileName, validateChartVersionStrictSemVerV2(chartFile))
} }
func validateChartVersionType(data map[string]interface{}) error { func validateChartVersionType(data map[string]interface{}) error {
@ -158,6 +159,16 @@ func validateChartVersion(cf *chart.Metadata) error {
return nil return nil
} }
func validateChartVersionStrictSemVerV2(cf *chart.Metadata) error {
_, err := semver.StrictNewVersion(cf.Version)
if err != nil {
return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version)
}
return nil
}
func validateChartMaintainer(cf *chart.Metadata) error { func validateChartMaintainer(cf *chart.Metadata) error {
for _, maintainer := range cf.Maintainers { for _, maintainer := range cf.Maintainers {
if maintainer == nil { if maintainer == nil {

@ -108,6 +108,35 @@ func TestValidateChartVersion(t *testing.T) {
} }
} }
func TestValidateChartVersionStrictSemVerV2(t *testing.T) {
var failTest = []struct {
Version string
ErrorMsg string
}{
{"", "version '' is not a valid SemVerV2"},
{"1", "version '1' is not a valid SemVerV2"},
{"1.1", "version '1.1' is not a valid SemVerV2"},
}
var successTest = []string{"1.1.1", "0.0.1+build", "0.0.1-beta"}
for _, test := range failTest {
badChart.Version = test.Version
err := validateChartVersionStrictSemVerV2(badChart)
if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) {
t.Errorf("validateChartVersionStrictSemVerV2(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg)
}
}
for _, version := range successTest {
badChart.Version = version
err := validateChartVersionStrictSemVerV2(badChart)
if err != nil {
t.Errorf("validateChartVersionStrictSemVerV2(%s) to return no error, got a linter error", version)
}
}
}
func TestValidateChartMaintainer(t *testing.T) { func TestValidateChartMaintainer(t *testing.T) {
var failTest = []struct { var failTest = []struct {
Name string Name string
@ -226,7 +255,7 @@ func TestChartfile(t *testing.T) {
linter := support.Linter{ChartDir: badChartDir} linter := support.Linter{ChartDir: badChartDir}
Chartfile(&linter) Chartfile(&linter)
msgs := linter.Messages msgs := linter.Messages
expectedNumberOfErrorMessages := 6 expectedNumberOfErrorMessages := 7
if len(msgs) != expectedNumberOfErrorMessages { if len(msgs) != expectedNumberOfErrorMessages {
t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs))
@ -256,13 +285,16 @@ func TestChartfile(t *testing.T) {
if !strings.Contains(msgs[5].Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { if !strings.Contains(msgs[5].Err.Error(), "dependencies are not valid in the Chart file with apiVersion") {
t.Errorf("Unexpected message 5: %s", msgs[5].Err) t.Errorf("Unexpected message 5: %s", msgs[5].Err)
} }
if !strings.Contains(msgs[6].Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") {
t.Errorf("Unexpected message 6: %s", msgs[6].Err)
}
}) })
t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) {
linter := support.Linter{ChartDir: anotherBadChartDir} linter := support.Linter{ChartDir: anotherBadChartDir}
Chartfile(&linter) Chartfile(&linter)
msgs := linter.Messages msgs := linter.Messages
expectedNumberOfErrorMessages := 3 expectedNumberOfErrorMessages := 4
if len(msgs) != expectedNumberOfErrorMessages { if len(msgs) != expectedNumberOfErrorMessages {
t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs))
@ -280,5 +312,8 @@ func TestChartfile(t *testing.T) {
if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") {
t.Errorf("Unexpected message 2: %s", msgs[2].Err) t.Errorf("Unexpected message 2: %s", msgs[2].Err)
} }
if !strings.Contains(msgs[3].Err.Error(), "version '7.2445e+06' is not a valid SemVerV2") {
t.Errorf("Unexpected message 3: %s", msgs[3].Err)
}
}) })
} }

@ -42,35 +42,66 @@ import (
) )
// Templates lints the templates in the Linter. // Templates lints the templates in the Linter.
func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { func Templates(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) {
TemplatesWithKubeVersion(linter, values, namespace, nil) templateLinter := newTemplateLinter(linter, namespace, values, options...)
templateLinter.Lint()
} }
// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. type TemplateLinterOption func(*templateLinter)
func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) {
TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) 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 newTemplateLinter(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) templateLinter {
func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) {
fpath := "templates/" result := templateLinter{
templatesPath := filepath.Join(linter.ChartDir, fpath) linter: linter,
values: values,
namespace: namespace,
}
for _, o := range options {
o(&result)
}
// Templates directory is optional for now return result
templatesDirExists := linter.RunLinterRule(support.WarningSev, fpath, templatesDirExists(templatesPath)) }
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 { if !templatesDirExists {
return return
} }
validTemplatesDir := linter.RunLinterRule(support.ErrorSev, fpath, validateTemplatesDir(templatesPath)) validTemplatesDir := t.linter.RunLinterRule(support.ErrorSev, templatesDir, validateTemplatesDir(templatesPath))
if !validTemplatesDir { if !validTemplatesDir {
return return
} }
// Load chart and parse templates // 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 { if !chartLoaded {
return return
@ -78,35 +109,35 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string
options := common.ReleaseOptions{ options := common.ReleaseOptions{
Name: "test-release", Name: "test-release",
Namespace: namespace, Namespace: t.namespace,
} }
caps := common.DefaultCapabilities.Copy() caps := common.DefaultCapabilities.Copy()
if kubeVersion != nil { if t.kubeVersion != nil {
caps.KubeVersion = *kubeVersion caps.KubeVersion = *t.kubeVersion
} }
// lint ignores import-values // lint ignores import-values
// See https://github.com/helm/helm/issues/9658 // 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 return
} }
cvals, err := util.CoalesceValues(chart, values) cvals, err := util.CoalesceValues(chart, t.values)
if err != nil { if err != nil {
return return
} }
valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, t.skipSchemaValidation)
if err != nil { if err != nil {
linter.RunLinterRule(support.ErrorSev, fpath, err) t.linter.RunLinterRule(support.ErrorSev, templatesDir, err)
return return
} }
var e engine.Engine var e engine.Engine
e.LintMode = true e.LintMode = true
renderedContentMap, err := e.Render(chart, valuesToRender) renderedContentMap, err := e.Render(chart, valuesToRender)
renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) renderOk := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err)
if !renderOk { if !renderOk {
return return
@ -121,12 +152,11 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string
*/ */
for _, template := range chart.Templates { for _, template := range chart.Templates {
fileName := template.Name 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 // We only apply the following lint rules to yaml files
if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { if !isYamlFileExtension(fileName) {
continue continue
} }
@ -139,7 +169,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string
renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)]
if strings.TrimSpace(renderedContent) != "" { 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) 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. // 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 // 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 return
} }
if yamlStruct != nil { if yamlStruct != nil {
// NOTE: set to warnings to allow users to support out-of-date kubernetes // NOTE: set to warnings to allow users to support out-of-date kubernetes
// Refs https://github.com/helm/helm/issues/8596 // Refs https://github.com/helm/helm/issues/8596
linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct)) t.linter.RunLinterRule(support.WarningSev, fileName, validateMetadataName(yamlStruct))
linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion)) t.linter.RunLinterRule(support.WarningSev, fileName, validateNoDeprecations(yamlStruct, t.kubeVersion))
linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent)) t.linter.RunLinterRule(support.ErrorSev, fileName, validateMatchSelector(yamlStruct, renderedContent))
linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent)) t.linter.RunLinterRule(support.ErrorSev, fileName, validateListAnnotations(yamlStruct, renderedContent))
} }
} }
} }
@ -234,6 +264,7 @@ func validateYamlContent(err error) error {
if err != nil { if err != nil {
return fmt.Errorf("unable to parse YAML: %w", err) return fmt.Errorf("unable to parse YAML: %w", err)
} }
return nil return nil
} }
@ -335,6 +366,11 @@ func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error {
return nil return nil
} }
func isYamlFileExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
return ext == ".yaml" || ext == ".yml"
}
// k8sYamlStruct stubs a Kubernetes YAML file. // k8sYamlStruct stubs a Kubernetes YAML file.
type k8sYamlStruct struct { type k8sYamlStruct struct {
APIVersion string `json:"apiVersion"` APIVersion string `json:"apiVersion"`

@ -22,6 +22,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
@ -51,11 +52,14 @@ func TestValidateAllowedExtension(t *testing.T) {
var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} var values = map[string]interface{}{"nameOverride": "", "httpPort": 80}
const namespace = "testNamespace" const namespace = "testNamespace"
const strict = false
func TestTemplateParsing(t *testing.T) { func TestTemplateParsing(t *testing.T) {
linter := support.Linter{ChartDir: templateTestBasedir} linter := support.Linter{ChartDir: templateTestBasedir}
Templates(&linter, values, namespace, strict) Templates(
&linter,
namespace,
values,
TemplateLinterSkipSchemaValidation(false))
res := linter.Messages res := linter.Messages
if len(res) != 1 { if len(res) != 1 {
@ -78,7 +82,11 @@ func TestTemplateIntegrationHappyPath(t *testing.T) {
defer os.Rename(ignoredTemplatePath, wrongTemplatePath) defer os.Rename(ignoredTemplatePath, wrongTemplatePath)
linter := support.Linter{ChartDir: templateTestBasedir} linter := support.Linter{ChartDir: templateTestBasedir}
Templates(&linter, values, namespace, strict) Templates(
&linter,
namespace,
values,
TemplateLinterSkipSchemaValidation(false))
res := linter.Messages res := linter.Messages
if len(res) != 0 { if len(res) != 0 {
@ -88,7 +96,11 @@ func TestTemplateIntegrationHappyPath(t *testing.T) {
func TestMultiTemplateFail(t *testing.T) { func TestMultiTemplateFail(t *testing.T) {
linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} linter := support.Linter{ChartDir: "./testdata/multi-template-fail"}
Templates(&linter, values, namespace, strict) Templates(
&linter,
namespace,
values,
TemplateLinterSkipSchemaValidation(false))
res := linter.Messages res := linter.Messages
if len(res) != 1 { if len(res) != 1 {
@ -183,6 +195,7 @@ func TestValidateMetadataName(t *testing.T) {
} }
func TestDeprecatedAPIFails(t *testing.T) { func TestDeprecatedAPIFails(t *testing.T) {
modTime := time.Now()
mychart := chart.Chart{ mychart := chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
APIVersion: "v2", APIVersion: "v2",
@ -193,10 +206,12 @@ func TestDeprecatedAPIFails(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/baddeployment.yaml", Name: "templates/baddeployment.yaml",
ModTime: modTime,
Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"),
}, },
{ {
Name: "templates/goodsecret.yaml", Name: "templates/goodsecret.yaml",
ModTime: modTime,
Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"),
}, },
}, },
@ -208,7 +223,11 @@ func TestDeprecatedAPIFails(t *testing.T) {
} }
linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} 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 { if l := len(linter.Messages); l != 1 {
for i, msg := range linter.Messages { for i, msg := range linter.Messages {
t.Logf("Message %d: %s", i, msg) t.Logf("Message %d: %s", i, msg)
@ -253,6 +272,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/configmap.yaml", Name: "templates/configmap.yaml",
ModTime: time.Now(),
Data: []byte(manifest), Data: []byte(manifest),
}, },
}, },
@ -264,7 +284,11 @@ func TestStrictTemplateParsingMapError(t *testing.T) {
linter := &support.Linter{ linter := &support.Linter{
ChartDir: filepath.Join(dir, ch.Metadata.Name), 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 { if len(linter.Messages) != 0 {
t.Errorf("expected zero messages, got %d", len(linter.Messages)) t.Errorf("expected zero messages, got %d", len(linter.Messages))
for i, msg := range linter.Messages { for i, msg := range linter.Messages {
@ -382,6 +406,7 @@ func TestEmptyWithCommentsManifests(t *testing.T) {
Templates: []*common.File{ Templates: []*common.File{
{ {
Name: "templates/empty-with-comments.yaml", Name: "templates/empty-with-comments.yaml",
ModTime: time.Now(),
Data: []byte("#@formatter:off\n"), Data: []byte("#@formatter:off\n"),
}, },
}, },
@ -393,7 +418,11 @@ func TestEmptyWithCommentsManifests(t *testing.T) {
} }
linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} 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 { if l := len(linter.Messages); l > 0 {
for i, msg := range linter.Messages { for i, msg := range linter.Messages {
t.Logf("Message %d: %s", i, msg) t.Logf("Message %d: %s", i, msg)
@ -439,3 +468,23 @@ items:
t.Fatalf("List objects keep annotations should pass. got: %s", err) t.Fatalf("List objects keep annotations should pass. got: %s", err)
} }
} }
func TestIsYamlFileExtension(t *testing.T) {
tests := []struct {
filename string
expected bool
}{
{"test.yaml", true},
{"test.yml", true},
{"test.txt", false},
{"test", false},
}
for _, test := range tests {
result := isYamlFileExtension(test.filename)
if result != test.expected {
t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected)
}
}
}

@ -32,7 +32,7 @@ import (
// they are only tested for well-formedness. // they are only tested for well-formedness.
// //
// If additional values are supplied, they are coalesced into the values in values.yaml. // If additional values are supplied, they are coalesced into the values in values.yaml.
func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) { func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) {
file := "values.yaml" file := "values.yaml"
vf := filepath.Join(linter.ChartDir, file) vf := filepath.Join(linter.ChartDir, file)
fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf))
@ -41,7 +41,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter
return return
} }
linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides)) linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation))
} }
func validateValuesFileExistence(valuesPath string) error { func validateValuesFileExistence(valuesPath string) error {
@ -52,7 +52,7 @@ func validateValuesFileExistence(valuesPath string) error {
return nil return nil
} }
func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error {
values, err := common.ReadValuesFile(valuesPath) values, err := common.ReadValuesFile(valuesPath)
if err != nil { if err != nil {
return fmt.Errorf("unable to parse YAML: %w", err) return fmt.Errorf("unable to parse YAML: %w", err)
@ -75,5 +75,10 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err
if err != nil { if err != nil {
return err return err
} }
if !skipSchemaValidation {
return util.ValidateAgainstSingleSchema(coalescedValues, schema) return util.ValidateAgainstSingleSchema(coalescedValues, schema)
} }
return nil
}

@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) {
` `
tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml))
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil {
t.Fatal("expected values file to fail parsing") t.Fatal("expected values file to fail parsing")
} }
} }
@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) {
createTestingSchema(t, tmpdir) createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil {
t.Fatalf("Failed validation with %s", err) t.Fatalf("Failed validation with %s", err)
} }
} }
@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) {
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, map[string]interface{}{}) err := validateValuesFile(valfile, map[string]interface{}{}, false)
if err == nil { if err == nil {
t.Fatal("expected values file to fail parsing") t.Fatal("expected values file to fail parsing")
} }
@ -99,6 +99,20 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) {
assert.Contains(t, err.Error(), "- at '/username': got number, want string") assert.Contains(t, err.Error(), "- at '/username': got number, want string")
} }
func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) {
// 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation.
yaml := "username: 1234\npassword: swordfish"
tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml))
createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, map[string]interface{}{}, true)
if err != nil {
t.Fatal("expected values file to pass parsing because of skipSchemaValidation")
}
}
func TestValidateValuesFileSchemaOverrides(t *testing.T) { func TestValidateValuesFileSchemaOverrides(t *testing.T) {
yaml := "username: admin" yaml := "username: admin"
overrides := map[string]interface{}{ overrides := map[string]interface{}{
@ -108,7 +122,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) {
createTestingSchema(t, tmpdir) createTestingSchema(t, tmpdir)
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
if err := validateValuesFile(valfile, overrides); err != nil { if err := validateValuesFile(valfile, overrides, false); err != nil {
t.Fatalf("Failed validation with %s", err) t.Fatalf("Failed validation with %s", err)
} }
} }
@ -145,7 +159,7 @@ func TestValidateValuesFile(t *testing.T) {
valfile := filepath.Join(tmpdir, "values.yaml") valfile := filepath.Join(tmpdir, "values.yaml")
err := validateValuesFile(valfile, tt.overrides) err := validateValuesFile(valfile, tt.overrides, false)
switch { switch {
case err != nil && tt.errorMessage == "": case err != nil && tt.errorMessage == "":

@ -17,32 +17,16 @@ limitations under the License.
package loader package loader
import ( import (
"archive/tar"
"bytes"
"compress/gzip" "compress/gzip"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"os" "os"
"path"
"regexp"
"strings"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
) )
// MaxDecompressedChartSize is the maximum size of a chart archive that will be
// decompressed. This is the decompressed size of all the files.
// The default value is 100 MiB.
var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
// The size of the file is the decompressed version of it when it is stored in an archive.
var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
// FileLoader loads a chart from a file // FileLoader loads a chart from a file
type FileLoader string type FileLoader string
@ -65,7 +49,7 @@ func LoadFile(name string) (*chart.Chart, error) {
} }
defer raw.Close() defer raw.Close()
err = ensureArchive(name, raw) err = archive.EnsureArchive(name, raw)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -79,153 +63,9 @@ func LoadFile(name string) (*chart.Chart, error) {
return c, err return c, err
} }
// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive.
//
// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence
// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error
// if we didn't check for this.
func ensureArchive(name string, raw *os.File) error {
defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed.
// Check the file format to give us a chance to provide the user with more actionable feedback.
buffer := make([]byte, 512)
_, err := raw.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("file '%s' cannot be read: %s", name, err)
}
// Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject.
// Fix for: https://github.com/helm/helm/issues/12261
if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) {
// TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide
// variety of content (Makefile, .zshrc) as valid YAML without errors.
// Wrong content type. Let's check if it's yaml and give an extra hint?
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name)
}
return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType)
}
return nil
}
// isGZipApplication checks whether the archive is of the application/x-gzip type.
func isGZipApplication(data []byte) bool {
sig := []byte("\x1F\x8B\x08")
return bytes.HasPrefix(data, sig)
}
// LoadArchiveFiles reads in files out of an archive into memory. This function
// performs important path security checks and should always be used before
// expanding a tarball
func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
unzipped, err := gzip.NewReader(in)
if err != nil {
return nil, err
}
defer unzipped.Close()
files := []*BufferedFile{}
tr := tar.NewReader(unzipped)
remainingSize := MaxDecompressedChartSize
for {
b := bytes.NewBuffer(nil)
hd, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if hd.FileInfo().IsDir() {
// Use this instead of hd.Typeflag because we don't have to do any
// inference chasing.
continue
}
switch hd.Typeflag {
// We don't want to process these extension header files.
case tar.TypeXGlobalHeader, tar.TypeXHeader:
continue
}
// Archive could contain \ if generated on Windows
delimiter := "/"
if strings.ContainsRune(hd.Name, '\\') {
delimiter = "\\"
}
parts := strings.Split(hd.Name, delimiter)
n := strings.Join(parts[1:], delimiter)
// Normalize the path to the / delimiter
n = strings.ReplaceAll(n, delimiter, "/")
if path.IsAbs(n) {
return nil, errors.New("chart illegally contains absolute paths")
}
n = path.Clean(n)
if n == "." {
// In this case, the original path was relative when it should have been absolute.
return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
}
if strings.HasPrefix(n, "..") {
return nil, errors.New("chart illegally references parent directory")
}
// In some particularly arcane acts of path creativity, it is possible to intermix
// UNIX and Windows style paths in such a way that you produce a result of the form
// c:/foo even after all the built-in absolute path checks. So we explicitly check
// for this condition.
if drivePathPattern.MatchString(n) {
return nil, errors.New("chart contains illegally named files")
}
if parts[0] == "Chart.yaml" {
return nil, errors.New("chart yaml not in base directory")
}
if hd.Size > remainingSize {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
if hd.Size > MaxDecompressedFileSize {
return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
}
limitedReader := io.LimitReader(tr, remainingSize)
bytesWritten, err := io.Copy(b, limitedReader)
if err != nil {
return nil, err
}
remainingSize -= bytesWritten
// When the bytesWritten are less than the file size it means the limit reader ended
// copying early. Here we report that error. This is important if the last file extracted
// is the one that goes over the limit. It assumes the Size stored in the tar header
// is correct, something many applications do.
if bytesWritten < hd.Size || remainingSize <= 0 {
return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
}
data := bytes.TrimPrefix(b.Bytes(), utf8bom)
files = append(files, &BufferedFile{Name: n, Data: data})
b.Reset()
}
if len(files) == 0 {
return nil, errors.New("no files in chart archive")
}
return files, nil
}
// LoadArchive loads from a reader containing a compressed tar archive. // LoadArchive loads from a reader containing a compressed tar archive.
func LoadArchive(in io.Reader) (*chart.Chart, error) { func LoadArchive(in io.Reader) (*chart.Chart, error) {
files, err := LoadArchiveFiles(in) files, err := archive.LoadArchiveFiles(in)
if err != nil { if err != nil {
return nil, err return nil, err
} }

@ -1,92 +0,0 @@
/*
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 loader
import (
"archive/tar"
"bytes"
"compress/gzip"
"testing"
)
func TestLoadArchiveFiles(t *testing.T) {
tcs := []struct {
name string
generate func(w *tar.Writer)
check func(t *testing.T, files []*BufferedFile, err error)
}{
{
name: "empty input should return no files",
generate: func(_ *tar.Writer) {},
check: func(t *testing.T, _ []*BufferedFile, err error) {
t.Helper()
if err.Error() != "no files in chart archive" {
t.Fatalf(`expected "no files in chart archive", got [%#v]`, err)
}
},
},
{
name: "should ignore files with XGlobalHeader type",
generate: func(w *tar.Writer) {
// simulate the presence of a `pax_global_header` file like you would get when
// processing a GitHub release archive.
err := w.WriteHeader(&tar.Header{
Typeflag: tar.TypeXGlobalHeader,
Name: "pax_global_header",
})
if err != nil {
t.Fatal(err)
}
// we need to have at least one file, otherwise we'll get the "no files in chart archive" error
err = w.WriteHeader(&tar.Header{
Typeflag: tar.TypeReg,
Name: "dir/empty",
})
if err != nil {
t.Fatal(err)
}
},
check: func(t *testing.T, files []*BufferedFile, err error) {
t.Helper()
if err != nil {
t.Fatalf(`got unwanted error [%#v] for tar file with pax_global_header content`, err)
}
if len(files) != 1 {
t.Fatalf(`expected to get one file but got [%v]`, files)
}
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
buf := &bytes.Buffer{}
gzw := gzip.NewWriter(buf)
tw := tar.NewWriter(gzw)
tc.generate(tw)
_ = tw.Close()
_ = gzw.Close()
files, err := LoadArchiveFiles(buf)
tc.check(t, files, err)
})
}
}

@ -24,6 +24,7 @@ import (
"strings" "strings"
"helm.sh/helm/v4/internal/sympath" "helm.sh/helm/v4/internal/sympath"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/ignore" "helm.sh/helm/v4/pkg/ignore"
) )
@ -61,7 +62,7 @@ func LoadDir(dir string) (*chart.Chart, error) {
} }
rules.AddDefaults() rules.AddDefaults()
files := []*BufferedFile{} files := []*archive.BufferedFile{}
topdir += string(filepath.Separator) topdir += string(filepath.Separator)
walk := func(name string, fi os.FileInfo, err error) error { walk := func(name string, fi os.FileInfo, err error) error {
@ -99,8 +100,8 @@ func LoadDir(dir string) (*chart.Chart, error) {
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
} }
if fi.Size() > MaxDecompressedFileSize { if fi.Size() > archive.MaxDecompressedFileSize {
return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize) return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize)
} }
data, err := os.ReadFile(name) data, err := os.ReadFile(name)
@ -110,7 +111,7 @@ func LoadDir(dir string) (*chart.Chart, error) {
data = bytes.TrimPrefix(data, utf8bom) data = bytes.TrimPrefix(data, utf8bom)
files = append(files, &BufferedFile{Name: n, Data: data}) files = append(files, &archive.BufferedFile{Name: n, ModTime: fi.ModTime(), Data: data})
return nil return nil
} }
if err = sympath.Walk(topdir, walk); err != nil { if err = sympath.Walk(topdir, walk); err != nil {

@ -32,6 +32,7 @@ import (
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
) )
@ -67,21 +68,15 @@ func Load(name string) (*chart.Chart, error) {
return l.Load() return l.Load()
} }
// BufferedFile represents an archive file buffered for later processing.
type BufferedFile struct {
Name string
Data []byte
}
// LoadFiles loads from in-memory files. // LoadFiles loads from in-memory files.
func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) {
c := new(chart.Chart) c := new(chart.Chart)
subcharts := make(map[string][]*BufferedFile) subcharts := make(map[string][]*archive.BufferedFile)
// do not rely on assumed ordering of files in the chart and crash // do not rely on assumed ordering of files in the chart and crash
// if Chart.yaml was not coming early enough to initialize metadata // if Chart.yaml was not coming early enough to initialize metadata
for _, f := range files { for _, f := range files {
c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) c.Raw = append(c.Raw, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
if f.Name == "Chart.yaml" { if f.Name == "Chart.yaml" {
if c.Metadata == nil { if c.Metadata == nil {
c.Metadata = new(chart.Metadata) c.Metadata = new(chart.Metadata)
@ -95,6 +90,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
if c.Metadata.APIVersion == "" { if c.Metadata.APIVersion == "" {
c.Metadata.APIVersion = chart.APIVersionV1 c.Metadata.APIVersion = chart.APIVersionV1
} }
c.ModTime = f.ModTime
} }
} }
for _, f := range files { for _, f := range files {
@ -115,6 +111,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
c.Values = values c.Values = values
case f.Name == "values.schema.json": case f.Name == "values.schema.json":
c.Schema = f.Data c.Schema = f.Data
c.SchemaModTime = f.ModTime
// Deprecated: requirements.yaml is deprecated use Chart.yaml. // Deprecated: requirements.yaml is deprecated use Chart.yaml.
// We will handle it for you because we are nice people // We will handle it for you because we are nice people
@ -129,7 +126,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
return c, fmt.Errorf("cannot load requirements.yaml: %w", err) return c, fmt.Errorf("cannot load requirements.yaml: %w", err)
} }
if c.Metadata.APIVersion == chart.APIVersionV1 { if c.Metadata.APIVersion == chart.APIVersionV1 {
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
} }
// Deprecated: requirements.lock is deprecated use Chart.lock. // Deprecated: requirements.lock is deprecated use Chart.lock.
case f.Name == "requirements.lock": case f.Name == "requirements.lock":
@ -144,22 +141,22 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.")
} }
if c.Metadata.APIVersion == chart.APIVersionV1 { if c.Metadata.APIVersion == chart.APIVersionV1 {
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
} }
case strings.HasPrefix(f.Name, "templates/"): case strings.HasPrefix(f.Name, "templates/"):
c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) c.Templates = append(c.Templates, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
case strings.HasPrefix(f.Name, "charts/"): case strings.HasPrefix(f.Name, "charts/"):
if filepath.Ext(f.Name) == ".prov" { if filepath.Ext(f.Name) == ".prov" {
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
continue continue
} }
fname := strings.TrimPrefix(f.Name, "charts/") fname := strings.TrimPrefix(f.Name, "charts/")
cname := strings.SplitN(fname, "/", 2)[0] cname := strings.SplitN(fname, "/", 2)[0]
subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) subcharts[cname] = append(subcharts[cname], &archive.BufferedFile{Name: fname, ModTime: f.ModTime, Data: f.Data})
default: default:
c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data})
} }
} }
@ -187,7 +184,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
default: default:
// We have to trim the prefix off of every file, and ignore any file // We have to trim the prefix off of every file, and ignore any file
// that is in charts/, but isn't actually a chart. // that is in charts/, but isn't actually a chart.
buff := make([]*BufferedFile, 0, len(files)) buff := make([]*archive.BufferedFile, 0, len(files))
for _, f := range files { for _, f := range files {
parts := strings.SplitN(f.Name, "/", 2) parts := strings.SplitN(f.Name, "/", 2)
if len(parts) < 2 { if len(parts) < 2 {

@ -31,6 +31,7 @@ import (
"time" "time"
"helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
) )
@ -211,14 +212,15 @@ func TestLoadFile(t *testing.T) {
func TestLoadFiles_BadCases(t *testing.T) { func TestLoadFiles_BadCases(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string
bufferedFiles []*BufferedFile bufferedFiles []*archive.BufferedFile
expectError string expectError string
}{ }{
{ {
name: "These files contain only requirements.lock", name: "These files contain only requirements.lock",
bufferedFiles: []*BufferedFile{ bufferedFiles: []*archive.BufferedFile{
{ {
Name: "requirements.lock", Name: "requirements.lock",
ModTime: time.Now(),
Data: []byte(""), Data: []byte(""),
}, },
}, },
@ -235,9 +237,11 @@ func TestLoadFiles_BadCases(t *testing.T) {
} }
func TestLoadFiles(t *testing.T) { func TestLoadFiles(t *testing.T) {
goodFiles := []*BufferedFile{ modTime := time.Now()
goodFiles := []*archive.BufferedFile{
{ {
Name: "Chart.yaml", Name: "Chart.yaml",
ModTime: modTime,
Data: []byte(`apiVersion: v1 Data: []byte(`apiVersion: v1
name: frobnitz name: frobnitz
description: This is a frobnitz. description: This is a frobnitz.
@ -259,18 +263,22 @@ icon: https://example.com/64x64.png
}, },
{ {
Name: "values.yaml", Name: "values.yaml",
ModTime: modTime,
Data: []byte("var: some values"), Data: []byte("var: some values"),
}, },
{ {
Name: "values.schema.json", Name: "values.schema.json",
ModTime: modTime,
Data: []byte("type: Values"), Data: []byte("type: Values"),
}, },
{ {
Name: "templates/deployment.yaml", Name: "templates/deployment.yaml",
ModTime: modTime,
Data: []byte("some deployment"), Data: []byte("some deployment"),
}, },
{ {
Name: "templates/service.yaml", Name: "templates/service.yaml",
ModTime: modTime,
Data: []byte("some service"), Data: []byte("some service"),
}, },
} }
@ -300,7 +308,7 @@ icon: https://example.com/64x64.png
t.Errorf("Expected number of templates == 2, got %d", len(c.Templates)) t.Errorf("Expected number of templates == 2, got %d", len(c.Templates))
} }
if _, err = LoadFiles([]*BufferedFile{}); err == nil { if _, err = LoadFiles([]*archive.BufferedFile{}); err == nil {
t.Fatal("Expected err to be non-nil") t.Fatal("Expected err to be non-nil")
} }
if err.Error() != "Chart.yaml file is missing" { if err.Error() != "Chart.yaml file is missing" {
@ -311,26 +319,32 @@ icon: https://example.com/64x64.png
// Test the order of file loading. The Chart.yaml file needs to come first for // Test the order of file loading. The Chart.yaml file needs to come first for
// later comparison checks. See https://github.com/helm/helm/pull/8948 // later comparison checks. See https://github.com/helm/helm/pull/8948
func TestLoadFilesOrder(t *testing.T) { func TestLoadFilesOrder(t *testing.T) {
goodFiles := []*BufferedFile{ modTime := time.Now()
goodFiles := []*archive.BufferedFile{
{ {
Name: "requirements.yaml", Name: "requirements.yaml",
ModTime: modTime,
Data: []byte("dependencies:"), Data: []byte("dependencies:"),
}, },
{ {
Name: "values.yaml", Name: "values.yaml",
ModTime: modTime,
Data: []byte("var: some values"), Data: []byte("var: some values"),
}, },
{ {
Name: "templates/deployment.yaml", Name: "templates/deployment.yaml",
ModTime: modTime,
Data: []byte("some deployment"), Data: []byte("some deployment"),
}, },
{ {
Name: "templates/service.yaml", Name: "templates/service.yaml",
ModTime: modTime,
Data: []byte("some service"), Data: []byte("some service"),
}, },
{ {
Name: "Chart.yaml", Name: "Chart.yaml",
ModTime: modTime,
Data: []byte(`apiVersion: v1 Data: []byte(`apiVersion: v1
name: frobnitz name: frobnitz
description: This is a frobnitz. description: This is a frobnitz.

@ -52,7 +52,7 @@ type Metadata struct {
Home string `json:"home,omitempty"` Home string `json:"home,omitempty"`
// Source is the URL to the source code of this chart // Source is the URL to the source code of this chart
Sources []string `json:"sources,omitempty"` Sources []string `json:"sources,omitempty"`
// A SemVer 2 conformant version string of the chart. Required. // A version string of the chart. Required.
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
// A one-sentence description of the chart // A one-sentence description of the chart
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`

@ -660,7 +660,7 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error {
for _, template := range schart.Templates { for _, template := range schart.Templates {
newData := transform(string(template.Data), schart.Name()) newData := transform(string(template.Data), schart.Name())
updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, ModTime: template.ModTime, Data: newData})
} }
schart.Templates = updatedTemplates schart.Templates = updatedTemplates

@ -26,13 +26,13 @@ import (
securejoin "github.com/cyphar/filepath-securejoin" securejoin "github.com/cyphar/filepath-securejoin"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/chart/loader/archive"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/chart/v2/loader"
) )
// Expand uncompresses and extracts a chart into the specified directory. // Expand uncompresses and extracts a chart into the specified directory.
func Expand(dir string, r io.Reader) error { func Expand(dir string, r io.Reader) error {
files, err := loader.LoadArchiveFiles(r) files, err := archive.LoadArchiveFiles(r)
if err != nil { if err != nil {
return err return err
} }

@ -175,7 +175,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil { if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata, c.ModTime); err != nil {
return err return err
} }
@ -187,7 +187,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if err != nil { if err != nil {
return err return err
} }
if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil { if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata, c.Lock.Generated); err != nil {
return err return err
} }
} }
@ -196,7 +196,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save values.yaml // Save values.yaml
for _, f := range c.Raw { for _, f := range c.Raw {
if f.Name == ValuesfileName { if f.Name == ValuesfileName {
if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil { if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -207,7 +207,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
if !json.Valid(c.Schema) { if !json.Valid(c.Schema) {
return errors.New("invalid JSON in " + SchemafileName) return errors.New("invalid JSON in " + SchemafileName)
} }
if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil { if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema, c.SchemaModTime); err != nil {
return err return err
} }
} }
@ -215,7 +215,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save templates // Save templates
for _, f := range c.Templates { for _, f := range c.Templates {
n := filepath.Join(base, f.Name) n := filepath.Join(base, f.Name)
if err := writeToTar(out, n, f.Data); err != nil { if err := writeToTar(out, n, f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -223,7 +223,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save files // Save files
for _, f := range c.Files { for _, f := range c.Files {
n := filepath.Join(base, f.Name) n := filepath.Join(base, f.Name)
if err := writeToTar(out, n, f.Data); err != nil { if err := writeToTar(out, n, f.Data, f.ModTime); err != nil {
return err return err
} }
} }
@ -238,13 +238,16 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
} }
// writeToTar writes a single file to a tar archive. // writeToTar writes a single file to a tar archive.
func writeToTar(out *tar.Writer, name string, body []byte) error { func writeToTar(out *tar.Writer, name string, body []byte, modTime time.Time) error {
// TODO: Do we need to create dummy parent directory names if none exist? // TODO: Do we need to create dummy parent directory names if none exist?
h := &tar.Header{ h := &tar.Header{
Name: filepath.ToSlash(name), Name: filepath.ToSlash(name),
Mode: 0644, Mode: 0644,
Size: int64(len(body)), Size: int64(len(body)),
ModTime: time.Now(), ModTime: modTime,
}
if h.ModTime.IsZero() {
h.ModTime = time.Now()
} }
if err := out.WriteHeader(h); err != nil { if err := out.WriteHeader(h); err != nil {
return err return err

@ -20,6 +20,8 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/sha256"
"fmt"
"io" "io"
"os" "os"
"path" "path"
@ -49,7 +51,7 @@ func TestSave(t *testing.T) {
Digest: "testdigest", Digest: "testdigest",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")},
}, },
Schema: []byte("{\n \"title\": \"Values\"\n}"), Schema: []byte("{\n \"title\": \"Values\"\n}"),
} }
@ -118,7 +120,7 @@ func TestSave(t *testing.T) {
Digest: "testdigest", Digest: "testdigest",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: time.Now(), Data: []byte("1,001 Nights")},
}, },
} }
_, err := Save(c, tmp) _, err := Save(c, tmp)
@ -153,14 +155,16 @@ func TestSavePreservesTimestamps(t *testing.T) {
Name: "ahab", Name: "ahab",
Version: "1.2.3", Version: "1.2.3",
}, },
ModTime: initialCreateTime,
Values: map[string]interface{}{ Values: map[string]interface{}{
"imageName": "testimage", "imageName": "testimage",
"imageId": 42, "imageId": 42,
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: initialCreateTime, Data: []byte("1,001 Nights")},
}, },
Schema: []byte("{\n \"title\": \"Values\"\n}"), Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: initialCreateTime,
} }
where, err := Save(c, tmp) where, err := Save(c, tmp)
@ -173,8 +177,9 @@ func TestSavePreservesTimestamps(t *testing.T) {
t.Fatalf("Failed to parse tar: %v", err) t.Fatalf("Failed to parse tar: %v", err)
} }
roundedTime := initialCreateTime.Round(time.Second)
for _, header := range allHeaders { for _, header := range allHeaders {
if header.ModTime.Before(initialCreateTime) { if !header.ModTime.Equal(roundedTime) {
t.Fatalf("File timestamp not preserved: %v", header.ModTime) t.Fatalf("File timestamp not preserved: %v", header.ModTime)
} }
} }
@ -217,6 +222,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) {
func TestSaveDir(t *testing.T) { func TestSaveDir(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
modTime := time.Now()
c := &chart.Chart{ c := &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV1, APIVersion: chart.APIVersionV1,
@ -224,10 +230,10 @@ func TestSaveDir(t *testing.T) {
Version: "1.2.3", Version: "1.2.3",
}, },
Files: []*common.File{ Files: []*common.File{
{Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, {Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
}, },
Templates: []*common.File{ Templates: []*common.File{
{Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), ModTime: modTime, Data: []byte("abc: {{ .Values.abc }}")},
}, },
} }
@ -263,3 +269,92 @@ func TestSaveDir(t *testing.T) {
t.Fatalf("Did not get expected error for chart named %q", c.Name()) t.Fatalf("Did not get expected error for chart named %q", c.Name())
} }
} }
func TestRepeatableSave(t *testing.T) {
tmp := t.TempDir()
defer os.RemoveAll(tmp)
modTime := time.Date(2021, 9, 1, 20, 34, 58, 651387237, time.UTC)
tests := []struct {
name string
chart *chart.Chart
want string
}{
{
name: "Package 1 file",
chart: &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV2,
Name: "ahab",
Version: "1.2.3",
},
ModTime: modTime,
Lock: &chart.Lock{
Digest: "testdigest",
Generated: modTime,
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: modTime,
},
want: "fea2662522317b65c2788ff9e5fc446a9264830038dac618d4449493d99b3257",
},
{
name: "Package 2 files",
chart: &chart.Chart{
Metadata: &chart.Metadata{
APIVersion: chart.APIVersionV2,
Name: "ahab",
Version: "1.2.3",
},
ModTime: modTime,
Lock: &chart.Lock{
Digest: "testdigest",
Generated: modTime,
},
Files: []*common.File{
{Name: "scheherazade/shahryar.txt", ModTime: modTime, Data: []byte("1,001 Nights")},
{Name: "scheherazade/dunyazad.txt", ModTime: modTime, Data: []byte("1,001 Nights again")},
},
Schema: []byte("{\n \"title\": \"Values\"\n}"),
SchemaModTime: modTime,
},
want: "7ae92b2f274bb51ea3f1969e4187d78cc52b5f6f663b44b8fb3b40bcb8ee46f3",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// create package
dest := path.Join(tmp, "newdir")
where, err := Save(test.chart, dest)
if err != nil {
t.Fatalf("Failed to save: %s", err)
}
// get shasum for package
result, err := sha256Sum(where)
if err != nil {
t.Fatalf("Failed to check shasum: %s", err)
}
// assert that the package SHA is what we wanted.
if result != test.want {
t.Errorf("FormatName() result = %v, want %v", result, test.want)
}
})
}
}
func sha256Sum(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}

@ -22,6 +22,7 @@ import (
"testing" "testing"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
) )
@ -31,7 +32,7 @@ func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) {
storage := storageFixture() storage := storageFixture()
storage.Create(&release.Release{ storage.Create(&release.Release{
Name: "myrelease", Name: "myrelease",
Info: &release.Info{Status: release.StatusDeployed}, Info: &release.Info{Status: common.StatusDeployed},
Chart: &chart.Chart{ Chart: &chart.Chart{
Metadata: &chart.Metadata{ Metadata: &chart.Metadata{
Name: "Myrelease-Chart", Name: "Myrelease-Chart",

@ -19,19 +19,20 @@ package cmd
import ( import (
"fmt" "fmt"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
"helm.sh/helm/v4/pkg/release/common"
release "helm.sh/helm/v4/pkg/release/v1" release "helm.sh/helm/v4/pkg/release/v1"
helmtime "helm.sh/helm/v4/pkg/time"
) )
func outputFlagCompletionTest(t *testing.T, cmdName string) { func outputFlagCompletionTest(t *testing.T, cmdName string) {
t.Helper() t.Helper()
releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release {
info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() info.LastDeployed = time.Unix(1452902400, 0).UTC()
return []*release.Release{{ return []*release.Release{{
Name: "athos", Name: "athos",
Namespace: "default", Namespace: "default",
@ -64,35 +65,35 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
cmd: fmt.Sprintf("__complete %s --output ''", cmdName), cmd: fmt.Sprintf("__complete %s --output ''", cmdName),
golden: "output/output-comp.txt", golden: "output/output-comp.txt",
rels: releasesMockWithStatus(&release.Info{ rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}), }),
}, { }, {
name: "completion for output flag long and after arg", name: "completion for output flag long and after arg",
cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName), cmd: fmt.Sprintf("__complete %s aramis --output ''", cmdName),
golden: "output/output-comp.txt", golden: "output/output-comp.txt",
rels: releasesMockWithStatus(&release.Info{ rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}), }),
}, { }, {
name: "completion for output flag short and before arg", name: "completion for output flag short and before arg",
cmd: fmt.Sprintf("__complete %s -o ''", cmdName), cmd: fmt.Sprintf("__complete %s -o ''", cmdName),
golden: "output/output-comp.txt", golden: "output/output-comp.txt",
rels: releasesMockWithStatus(&release.Info{ rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}), }),
}, { }, {
name: "completion for output flag short and after arg", name: "completion for output flag short and after arg",
cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName), cmd: fmt.Sprintf("__complete %s aramis -o ''", cmdName),
golden: "output/output-comp.txt", golden: "output/output-comp.txt",
rels: releasesMockWithStatus(&release.Info{ rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}), }),
}, { }, {
name: "completion for output flag, no filter", name: "completion for output flag, no filter",
cmd: fmt.Sprintf("__complete %s --output jso", cmdName), cmd: fmt.Sprintf("__complete %s --output jso", cmdName),
golden: "output/output-comp.txt", golden: "output/output-comp.txt",
rels: releasesMockWithStatus(&release.Info{ rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed, Status: common.StatusDeployed,
}), }),
}} }}
runTestCmd(t, tests) runTestCmd(t, tests)

@ -25,6 +25,7 @@ import (
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/release"
) )
const getHooksHelp = ` const getHooksHelp = `
@ -52,8 +53,16 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
for _, hook := range res.Hooks { rac, err := release.NewAccessor(res)
fmt.Fprintf(out, "---\n# Source: %s\n%s\n", hook.Path, hook.Manifest) 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 return nil
}, },

@ -25,6 +25,7 @@ import (
"helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/action"
"helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/release"
) )
var getManifestHelp = ` var getManifestHelp = `
@ -54,7 +55,11 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
if err != nil { if err != nil {
return err return err
} }
fmt.Fprintln(out, res.Manifest) rac, err := release.NewAccessor(res)
if err != nil {
return err
}
fmt.Fprintln(out, rac.Manifest())
return nil return nil
}, },
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save