diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index dbd885350..54fa3a078 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -18,11 +18,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # pin@6.1.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1606902c7..0f3fe6d8f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index ff441aad5..9d5723329 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -13,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # pin@6.1.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true - name: golangci-lint - uses: golangci/golangci-lint-action@0a35821d5c230e903fcfe077583637dea1b27b47 #pin@9.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 #pin@9.2.0 with: version: ${{ env.GOLANGCI_LINT_VERSION }} diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 84d260a8f..e8f2560e3 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -3,6 +3,11 @@ on: push: paths: - go.sum + - .github/workflows/govulncheck.yml + pull_request: + paths: + - go.sum + - .github/workflows/govulncheck.yml schedule: - cron: "0 0 * * *" @@ -14,11 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 + with: + persist-credentials: false - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # pin@6.1.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e4a8bf1e..cf8595742 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,11 @@ permissions: read-all # job is triggered by a tag push, VERSION should be the tag ref. jobs: release: - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') && github.repository == 'helm/helm' runs-on: ubuntu-latest-16-cores steps: - name: Checkout source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 with: fetch-depth: 0 @@ -28,9 +28,10 @@ jobs: run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # pin@6.1.0 with: go-version: '${{ env.GOLANG_VERSION }}' + check-latest: true - name: Run unit tests run: make test-coverage - name: Build Helm Binaries @@ -81,16 +82,16 @@ jobs: canary-release: runs-on: ubuntu-latest-16-cores - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.repository == 'helm/helm' steps: - name: Checkout source code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # pin@v6.0.1 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0 + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # pin@6.1.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 4b836a33c..514a649cb 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -28,7 +28,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -55,7 +55,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: SARIF file path: results.sarif diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 3d72d1e17..574427a5d 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -7,7 +7,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.' diff --git a/.golangci.yml b/.golangci.yml index 236dadf7b..7eca135e5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,11 +26,13 @@ linters: - misspell - nakedret - revive + - sloglint - staticcheck - thelper - unused - usestdlibvars - usetesting + - exhaustive exclusions: @@ -72,6 +74,9 @@ linters: recommendations: - github.com/evanphx/json-patch/v5 + exhaustive: + default-signifies-exhaustive: true + run: timeout: 10m diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ab93403d..e809e7ca2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ specific upcoming bugfix or feature release could fall into one of two different Issues and PRs which are deemed backwards-incompatible may be added to the discussion items for Helm 4 with [label:v4.x](https://github.com/helm/helm/labels/v4.x). An issue or PR that we are not -sure we will be addressing will not be added to any milestone. +sure if we will be addressing will not be added to any milestone. A milestone (and hence release) can be closed when all outstanding issues/PRs have been closed or moved to another milestone and the associated release has been published. @@ -160,8 +160,8 @@ There are 5 types of issues (each with their own corresponding [label](#labels)) discussion, these can turn into `feature` or `bug` issues. - `proposal`: Used for items (like this one) that propose a new ideas or functionality that require a larger community discussion. This allows for feedback from others in the community before a - feature is actually developed. This is not needed for small additions. Final word on whether or - not a feature needs a proposal is up to the core maintainers. All issues that are proposals should + feature is actually developed. This is not needed for small additions. Final word on whether + a feature needs a proposal is up to the core maintainers. All issues that are proposals should both have a label and an issue title of "Proposal: [the rest of the title]." A proposal can become a `feature` and does not require a milestone. - `feature`: These track specific feature requests and ideas until they are complete. They can @@ -179,7 +179,7 @@ below. 2. Triage - The maintainer in charge of triaging will apply the proper labels for the issue. This includes labels for priority, type, and metadata (such as `good first issue`). The only issue priority - we will be tracking is whether or not the issue is "critical." If additional levels are needed + we will be tracking is whether the issue is "critical." If additional levels are needed in the future, we will add them. - (If needed) Clean up the title to succinctly and clearly state the issue. Also ensure that proposals are prefaced with "Proposal: [the rest of the title]". diff --git a/Makefile b/Makefile index 4cf779438..a18b83f0d 100644 --- a/Makefile +++ b/Makefile @@ -58,20 +58,6 @@ LDFLAGS += -X helm.sh/helm/v4/internal/version.gitCommit=${GIT_COMMIT} LDFLAGS += -X helm.sh/helm/v4/internal/version.gitTreeState=${GIT_DIRTY} LDFLAGS += $(EXT_LDFLAGS) -# Define constants based on the client-go version -K8S_MODULES_VER=$(subst ., ,$(subst v,,$(shell go list -f '{{.Version}}' -m k8s.io/client-go))) -K8S_MODULES_MAJOR_VER=$(shell echo $$(($(firstword $(K8S_MODULES_VER)) + 1))) -K8S_MODULES_MINOR_VER=$(word 2,$(K8S_MODULES_VER)) - -LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) -LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) -LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_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.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 all: build @@ -129,6 +115,13 @@ test-coverage: .PHONY: test-style test-style: + @EXPECTED_VERSION=$$(grep GOLANGCI_LINT_VERSION .github/env | cut -d= -f2); \ + ACTUAL_VERSION=$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \ + if [ "v$$ACTUAL_VERSION" != "$$EXPECTED_VERSION" ]; then \ + echo "Warning: golangci-lint version is v$$ACTUAL_VERSION (expected $$EXPECTED_VERSION from CI)"; \ + echo "To install the correct version, run:"; \ + echo " curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b \$$(go env GOPATH)/bin $$EXPECTED_VERSION"; \ + fi golangci-lint run ./... @scripts/validate-license.sh diff --git a/OWNERS b/OWNERS index 761cf76a3..13827661a 100644 --- a/OWNERS +++ b/OWNERS @@ -1,4 +1,5 @@ maintainers: + - banjoh - gjenkins8 - joejulian - marckhouzam @@ -7,9 +8,8 @@ maintainers: - sabre1041 - scottrigby - technosophos -triage: - - banjoh - TerryHowe +triage: - yxxhero - zonggen - z4ce diff --git a/README.md b/README.md index 66fdab041..37bc8abaa 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![GoDoc](https://img.shields.io/static/v1?label=godoc&message=reference&color=blue)](https://pkg.go.dev/helm.sh/helm/v4) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/3131/badge)](https://bestpractices.coreinfrastructure.org/projects/3131) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/helm/helm/badge)](https://scorecard.dev/viewer/?uri=github.com/helm/helm) -[![LFX Health Score](https://insights.production.lfx.dev/api/badge/health-score?project=helm)](https://insights.linuxfoundation.org/project/helm) +[![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=helm)](https://insights.linuxfoundation.org/project/helm) Helm is a tool for managing Charts. Charts are packages of pre-configured Kubernetes resources. @@ -49,6 +49,7 @@ If you want to use a package manager: - [Scoop](https://scoop.sh/) users can use `scoop install helm`. - [Snapcraft](https://snapcraft.io/) users can use `snap install helm --classic`. - [Flox](https://flox.dev) users can use `flox install kubernetes-helm`. +- [Mise-en-place](https://mise.jdx.dev/) users can use `mise use -g helm@latest` To rapidly get Helm up and running, start with the [Quick Start Guide](https://helm.sh/docs/intro/quickstart/). diff --git a/go.mod b/go.mod index 3d251758f..8b5b2f4a3 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ module helm.sh/helm/v4 -go 1.24.0 +go 1.25.0 require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 - github.com/BurntSushi/toml v1.5.0 + github.com/BurntSushi/toml v1.6.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/semver/v3 v3.4.0 github.com/Masterminds/sprig/v3 v3.3.0 @@ -12,42 +12,41 @@ require ( github.com/Masterminds/vcs v1.13.3 github.com/ProtonMail/go-crypto v1.3.0 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/cyphar/filepath-securejoin v0.6.0 + github.com/cyphar/filepath-securejoin v0.6.1 github.com/distribution/distribution/v3 v3.0.0 github.com/evanphx/json-patch/v5 v5.9.11 github.com/extism/go-sdk v1.7.1 github.com/fatih/color v1.18.0 - github.com/fluxcd/cli-utils v0.36.0-flux.14 - github.com/foxcpp/go-mockdns v1.1.0 + github.com/fluxcd/cli-utils v0.37.0-flux.1 + github.com/foxcpp/go-mockdns v1.2.0 github.com/gobwas/glob v0.2.3 github.com/gofrs/flock v0.13.0 github.com/gosuri/uitable v0.0.4 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 github.com/mattn/go-shellwords v1.0.12 - github.com/mitchellh/copystructure v1.2.0 github.com/moby/term v0.5.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/rubenv/sql-migrate v1.8.0 + github.com/rubenv/sql-migrate v1.8.1 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.10.1 + github.com/tetratelabs/wazero v1.11.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/crypto v0.43.0 - golang.org/x/term v0.37.0 - golang.org/x/text v0.30.0 + golang.org/x/crypto v0.46.0 + golang.org/x/term v0.38.0 + golang.org/x/text v0.32.0 gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.1 - k8s.io/apiextensions-apiserver v0.34.1 - k8s.io/apimachinery v0.34.1 - k8s.io/apiserver v0.34.1 - k8s.io/cli-runtime v0.34.1 - k8s.io/client-go v0.34.1 + k8s.io/api v0.35.0 + k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/apiserver v0.35.0 + k8s.io/cli-runtime v0.35.0 + k8s.io/client-go v0.35.0 k8s.io/klog/v2 v2.130.1 - k8s.io/kubectl v0.34.1 + k8s.io/kubectl v0.35.0 oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/controller-runtime v0.22.4 sigs.k8s.io/kustomize/kyaml v0.21.0 @@ -86,14 +85,12 @@ require ( github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect @@ -112,21 +109,19 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/miekg/dns v1.1.57 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/onsi/gomega v1.37.0 // indirect + github.com/onsi/gomega v1.38.2 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect @@ -141,7 +136,7 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect @@ -156,30 +151,30 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect go.opentelemetry.io/otel/log v0.8.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.36.0 // indirect go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.45.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/grpc v1.72.2 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/component-base v0.34.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/component-base v0.35.0 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 32dbbbb70..4e39db6d8 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9 github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -26,8 +26,6 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -59,8 +57,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= -github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -93,10 +91,10 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fluxcd/cli-utils v0.36.0-flux.14 h1:I//AMVUXTc+M04UtIXArMXQZCazGMwfemodV1j/yG8c= -github.com/fluxcd/cli-utils v0.36.0-flux.14/go.mod h1:uDo7BYOfbdmk/asnHuI0IQPl6u0FCgcN54AHDu3Y5As= -github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= -github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/fluxcd/cli-utils v0.37.0-flux.1 h1:k/VvPNT3tGa/l2N+qzHduaQr3GVbgoWS6nw7tGZz16w= +github.com/fluxcd/cli-utils v0.37.0-flux.1/go.mod h1:aND5wX3LuTFtB7eUT7vsWr8mmxRVSPR2Wkvbn0SqPfw= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -132,8 +130,6 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -155,8 +151,6 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -182,8 +176,6 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= @@ -225,8 +217,6 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -242,12 +232,10 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -255,8 +243,6 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -265,16 +251,16 @@ github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjz github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= -github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= @@ -289,8 +275,8 @@ github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= -github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= @@ -304,8 +290,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/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.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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= @@ -321,14 +307,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= -github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= @@ -336,8 +320,8 @@ go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGh go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= @@ -366,52 +350,43 @@ go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWer go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -419,28 +394,24 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -454,8 +425,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -463,8 +434,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -472,38 +443,33 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -512,34 +478,34 @@ 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= -k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= -k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= -k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= -k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= -k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= +k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 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/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI= -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/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.35.0 h1:cL/wJKHDe8E8+rP3G7avnymcMg6bH6JEcR5w5uo06wc= +k8s.io/kubectl v0.35.0/go.mod h1:VR5/TSkYyxZwrRwY5I5dDq6l5KXmiCb+9w8IKplk3Qo= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/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/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= sigs.k8s.io/kustomize/kyaml v0.21.0 h1:7mQAf3dUwf0wBerWJd8rXhVcnkk5Tvn/q91cGkaP6HQ= diff --git a/internal/chart/v3/lint/lint_test.go b/internal/chart/v3/lint/lint_test.go index d61a9a740..221de8572 100644 --- a/internal/chart/v3/lint/lint_test.go +++ b/internal/chart/v3/lint/lint_test.go @@ -175,16 +175,6 @@ func TestHelmCreateChart(t *testing.T) { // // Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent // of the `--set` flag. -// -// Note: This test requires the following ldflags to be set per the current Kubernetes version to avoid false-positive -// results. -// 1. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor= -// 2. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor= -// or directly use '$(LDFLAGS)' in Makefile. -// -// When run without ldflags, the test passes giving a false-positive result. This is because the variables -// `k8sVersionMajor` and `k8sVersionMinor` by default are set to an older version of Kubernetes, with which, there -// might not be the deprecation warning. func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir()) if err != nil { diff --git a/internal/chart/v3/lint/rules/crds.go b/internal/chart/v3/lint/rules/crds.go index 6bafb52eb..deedeb0f2 100644 --- a/internal/chart/v3/lint/rules/crds.go +++ b/internal/chart/v3/lint/rules/crds.go @@ -70,7 +70,7 @@ func Crds(linter *support.Linter) { var yamlStruct *k8sYamlStruct err := decoder.Decode(&yamlStruct) - if err == io.EOF { + if errors.Is(err, io.EOF) { break } @@ -80,8 +80,10 @@ func Crds(linter *support.Linter) { return } - linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) - linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + if yamlStruct != nil { + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + } } } } diff --git a/internal/chart/v3/lint/rules/crds_test.go b/internal/chart/v3/lint/rules/crds_test.go index d93e3d978..e435b8ea3 100644 --- a/internal/chart/v3/lint/rules/crds_test.go +++ b/internal/chart/v3/lint/rules/crds_test.go @@ -17,6 +17,8 @@ limitations under the License. package rules import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -34,3 +36,31 @@ func TestInvalidCrdsDir(t *testing.T) { assert.Len(t, res, 1) assert.ErrorContains(t, res[0].Err, "not a directory") } + +// multi-document YAML with empty documents would panic +func TestCrdWithEmptyDocument(t *testing.T) { + chartDir := t.TempDir() + + os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte( + `apiVersion: v1 +name: test +version: 0.1.0 +`), 0644) + + // CRD with comments before --- (creates empty document) + crdsDir := filepath.Join(chartDir, "crds") + os.Mkdir(crdsDir, 0755) + os.WriteFile(filepath.Join(crdsDir, "test.yaml"), []byte( + `# Comments create empty document +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test.example.io +`), 0644) + + linter := support.Linter{ChartDir: chartDir} + Crds(&linter) + + assert.Len(t, linter.Messages, 0) +} diff --git a/internal/chart/v3/lint/rules/deprecations.go b/internal/chart/v3/lint/rules/deprecations.go index 6f86bdbbd..a607a5fb4 100644 --- a/internal/chart/v3/lint/rules/deprecations.go +++ b/internal/chart/v3/lint/rules/deprecations.go @@ -28,14 +28,6 @@ import ( kscheme "k8s.io/client-go/kubernetes/scheme" ) -var ( - // This should be set in the Makefile based on the version of client-go being imported. - // These constants will be overwritten with LDFLAGS. The version components must be - // strings in order for LDFLAGS to set them. - k8sVersionMajor = "1" - k8sVersionMinor = "20" -) - // deprecatedAPIError indicates than an API is deprecated in Kubernetes type deprecatedAPIError struct { Deprecated string @@ -56,33 +48,29 @@ func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVer return nil } - majorVersion := k8sVersionMajor - minorVersion := k8sVersionMinor - - if kubeVersion != nil { - majorVersion = kubeVersion.Major - minorVersion = kubeVersion.Minor + if kubeVersion == nil { + kubeVersion = &common.DefaultCapabilities.KubeVersion } - runtimeObject, err := resourceToRuntimeObject(resource) + kubeVersionMajor, err := strconv.Atoi(kubeVersion.Major) if err != nil { - // do not error for non-kubernetes resources - if runtime.IsNotRegisteredError(err) { - return nil - } return err } - - major, err := strconv.Atoi(majorVersion) + kubeVersionMinor, err := strconv.Atoi(kubeVersion.Minor) if err != nil { return err } - minor, err := strconv.Atoi(minorVersion) + + runtimeObject, err := resourceToRuntimeObject(resource) if err != nil { + // do not error for non-kubernetes resources + if runtime.IsNotRegisteredError(err) { + return nil + } return err } - if !deprecation.IsDeprecated(runtimeObject, major, minor) { + if !deprecation.IsDeprecated(runtimeObject, kubeVersionMajor, kubeVersionMinor) { return nil } gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) diff --git a/internal/chart/v3/lint/rules/template.go b/internal/chart/v3/lint/rules/template.go index 204966364..38e602b7e 100644 --- a/internal/chart/v3/lint/rules/template.go +++ b/internal/chart/v3/lint/rules/template.go @@ -150,7 +150,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string var yamlStruct *k8sYamlStruct err := decoder.Decode(&yamlStruct) - if err == io.EOF { + if errors.Is(err, io.EOF) { break } diff --git a/internal/chart/v3/loader/archive.go b/internal/chart/v3/loader/archive.go index 358c2ce4d..a9d4faf8f 100644 --- a/internal/chart/v3/loader/archive.go +++ b/internal/chart/v3/loader/archive.go @@ -56,7 +56,7 @@ func LoadFile(name string) (*chart.Chart, error) { c, err := LoadArchive(raw) if err != nil { - if err == gzip.ErrHeader { + if errors.Is(err, gzip.ErrHeader) { return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) } } diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go index 1c5b4cad1..373c4659f 100644 --- a/internal/chart/v3/loader/load.go +++ b/internal/chart/v3/loader/load.go @@ -181,7 +181,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { // LoadValues loads values from a reader. // // The reader is expected to contain one or more YAML documents, the values of which are merged. -// And the values can be either a chart's default values or a user-supplied values. +// And the values can be either a chart's default values or user-supplied values. func LoadValues(data io.Reader) (map[string]interface{}, error) { values := map[string]interface{}{} reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) @@ -189,7 +189,7 @@ func LoadValues(data io.Reader) (map[string]interface{}, error) { currentMap := map[string]interface{}{} raw, err := reader.Read() if err != nil { - if err == io.EOF { + if errors.Is(err, io.EOF) { break } return nil, fmt.Errorf("error reading yaml document: %w", err) diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index f91005bf6..12403f9c2 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -20,6 +20,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "errors" "io" "log" "os" @@ -116,7 +117,7 @@ func TestBomTestData(t *testing.T) { tr := tar.NewReader(unzipped) for { file, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 489772115..4ef9e6961 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -20,9 +20,8 @@ import ( "log/slog" "strings" - "github.com/mitchellh/copystructure" - chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/copystructure" "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common/util" ) @@ -281,7 +280,12 @@ func processImportValues(c *chart.Chart, merge bool) error { // get child table vv, err := cvals.Table(r.Name + "." + child) if err != nil { - slog.Warn("ImportValues missing table from chart", "chart", r.Name, slog.Any("error", err)) + slog.Warn( + "ImportValues missing table from chart", + slog.String("chart", "chart"), + slog.String("name", r.Name), + slog.Any("error", err), + ) continue } // create value map from child to be merged into parent diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 62625919b..7a42a76af 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -21,6 +21,7 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "errors" "fmt" "io" "os" @@ -201,7 +202,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { headers := []*tar.Header{} for { hd, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } diff --git a/internal/copystructure/copystructure.go b/internal/copystructure/copystructure.go new file mode 100644 index 000000000..aa5510298 --- /dev/null +++ b/internal/copystructure/copystructure.go @@ -0,0 +1,120 @@ +/* +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 copystructure + +import ( + "fmt" + "reflect" +) + +// Copy performs a deep copy of the given src. +// This implementation handles the specific use cases needed by Helm. +func Copy(src any) (any, error) { + if src == nil { + return make(map[string]any), nil + } + return copyValue(reflect.ValueOf(src)) +} + +// copyValue handles copying using reflection for non-map types +func copyValue(original reflect.Value) (any, error) { + switch original.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String, reflect.Array: + return original.Interface(), nil + + case reflect.Interface: + if original.IsNil() { + return original.Interface(), nil + } + return copyValue(original.Elem()) + + case reflect.Map: + if original.IsNil() { + return original.Interface(), nil + } + copied := reflect.MakeMap(original.Type()) + + var err error + var child any + iter := original.MapRange() + for iter.Next() { + key := iter.Key() + value := iter.Value() + + if value.Kind() == reflect.Interface && value.IsNil() { + copied.SetMapIndex(key, value) + continue + } + + child, err = copyValue(value) + if err != nil { + return nil, err + } + copied.SetMapIndex(key, reflect.ValueOf(child)) + } + return copied.Interface(), nil + + case reflect.Pointer: + if original.IsNil() { + return original.Interface(), nil + } + copied, err := copyValue(original.Elem()) + if err != nil { + return nil, err + } + ptr := reflect.New(original.Type().Elem()) + ptr.Elem().Set(reflect.ValueOf(copied)) + return ptr.Interface(), nil + + case reflect.Slice: + if original.IsNil() { + return original.Interface(), nil + } + copied := reflect.MakeSlice(original.Type(), original.Len(), original.Cap()) + for i := 0; i < original.Len(); i++ { + val, err := copyValue(original.Index(i)) + if err != nil { + return nil, err + } + copied.Index(i).Set(reflect.ValueOf(val)) + } + return copied.Interface(), nil + + case reflect.Struct: + copied := reflect.New(original.Type()).Elem() + for i := 0; i < original.NumField(); i++ { + elem, err := copyValue(original.Field(i)) + if err != nil { + return nil, err + } + copied.Field(i).Set(reflect.ValueOf(elem)) + } + return copied.Interface(), nil + + case reflect.Func, reflect.Chan, reflect.UnsafePointer: + if original.IsNil() { + return original.Interface(), nil + } + return original.Interface(), nil + + default: + return original.Interface(), fmt.Errorf("unsupported type %v", original) + } +} diff --git a/internal/copystructure/copystructure_test.go b/internal/copystructure/copystructure_test.go new file mode 100644 index 000000000..d1708dc75 --- /dev/null +++ b/internal/copystructure/copystructure_test.go @@ -0,0 +1,374 @@ +/* +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 copystructure + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopy_Nil(t *testing.T) { + result, err := Copy(nil) + require.NoError(t, err) + assert.Equal(t, map[string]any{}, result) +} + +func TestCopy_PrimitiveTypes(t *testing.T) { + tests := []struct { + name string + input any + }{ + {"bool", true}, + {"int", 42}, + {"int8", int8(8)}, + {"int16", int16(16)}, + {"int32", int32(32)}, + {"int64", int64(64)}, + {"uint", uint(42)}, + {"uint8", uint8(8)}, + {"uint16", uint16(16)}, + {"uint32", uint32(32)}, + {"uint64", uint64(64)}, + {"float32", float32(3.14)}, + {"float64", 3.14159}, + {"complex64", complex64(1 + 2i)}, + {"complex128", 1 + 2i}, + {"string", "hello world"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Copy(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.input, result) + }) + } +} + +func TestCopy_Array(t *testing.T) { + input := [3]int{1, 2, 3} + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) +} + +func TestCopy_Slice(t *testing.T) { + t.Run("slice of ints", func(t *testing.T) { + input := []int{1, 2, 3, 4, 5} + result, err := Copy(input) + require.NoError(t, err) + + resultSlice, ok := result.([]int) + require.True(t, ok) + assert.Equal(t, input, resultSlice) + + // Verify it's a deep copy by modifying original + input[0] = 999 + assert.Equal(t, 1, resultSlice[0]) + }) + + t.Run("slice of strings", func(t *testing.T) { + input := []string{"a", "b", "c"} + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil slice", func(t *testing.T) { + var input []int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("slice of maps", func(t *testing.T) { + input := []map[string]any{ + {"key1": "value1"}, + {"key2": "value2"}, + } + result, err := Copy(input) + require.NoError(t, err) + + resultSlice, ok := result.([]map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultSlice) + + // Verify deep copy + input[0]["key1"] = "modified" + assert.Equal(t, "value1", resultSlice[0]["key1"]) + }) +} + +func TestCopy_Map(t *testing.T) { + t.Run("map[string]any", func(t *testing.T) { + input := map[string]any{ + "string": "value", + "int": 42, + "bool": true, + "nested": map[string]any{ + "inner": "value", + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + + // Verify deep copy + input["string"] = "modified" + assert.Equal(t, "value", resultMap["string"]) + + nestedInput := input["nested"].(map[string]any) + nestedResult := resultMap["nested"].(map[string]any) + nestedInput["inner"] = "modified" + assert.Equal(t, "value", nestedResult["inner"]) + }) + + t.Run("map[string]string", func(t *testing.T) { + input := map[string]string{ + "key1": "value1", + "key2": "value2", + } + + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil map", func(t *testing.T) { + var input map[string]any + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("map with nil values", func(t *testing.T) { + input := map[string]any{ + "key1": "value1", + "key2": nil, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + assert.Nil(t, resultMap["key2"]) + }) +} + +func TestCopy_Struct(t *testing.T) { + type TestStruct struct { + Name string + Age int + Active bool + Scores []int + Metadata map[string]any + } + + input := TestStruct{ + Name: "John", + Age: 30, + Active: true, + Scores: []int{95, 87, 92}, + Metadata: map[string]any{ + "level": "advanced", + "tags": []string{"go", "programming"}, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultStruct, ok := result.(TestStruct) + require.True(t, ok) + assert.Equal(t, input, resultStruct) + + // Verify deep copy + input.Name = "Modified" + input.Scores[0] = 999 + assert.Equal(t, "John", resultStruct.Name) + assert.Equal(t, 95, resultStruct.Scores[0]) +} + +func TestCopy_Pointer(t *testing.T) { + t.Run("pointer to int", func(t *testing.T) { + value := 42 + input := &value + + result, err := Copy(input) + require.NoError(t, err) + + resultPtr, ok := result.(*int) + require.True(t, ok) + assert.Equal(t, *input, *resultPtr) + + // Verify they point to different memory locations + assert.NotSame(t, input, resultPtr) + + // Verify deep copy + *input = 999 + assert.Equal(t, 42, *resultPtr) + }) + + t.Run("pointer to struct", func(t *testing.T) { + type Person struct { + Name string + Age int + } + + input := &Person{Name: "Alice", Age: 25} + + result, err := Copy(input) + require.NoError(t, err) + + resultPtr, ok := result.(*Person) + require.True(t, ok) + assert.Equal(t, *input, *resultPtr) + assert.NotSame(t, input, resultPtr) + }) + + t.Run("nil pointer", func(t *testing.T) { + var input *int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestCopy_Interface(t *testing.T) { + t.Run("any with value", func(t *testing.T) { + var input any = "hello" + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) + + t.Run("nil any", func(t *testing.T) { + var input any + result, err := Copy(input) + require.NoError(t, err) + // Copy(nil) returns an empty map according to the implementation + assert.Equal(t, map[string]any{}, result) + }) + + t.Run("any with complex value", func(t *testing.T) { + var input any = map[string]any{ + "key": "value", + "nested": map[string]any{ + "inner": 42, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + assert.Equal(t, input, result) + }) +} + +func TestCopy_ComplexNested(t *testing.T) { + input := map[string]any{ + "users": []map[string]any{ + { + "name": "Alice", + "age": 30, + "addresses": []map[string]any{ + {"type": "home", "city": "NYC"}, + {"type": "work", "city": "SF"}, + }, + }, + { + "name": "Bob", + "age": 25, + "addresses": []map[string]any{ + {"type": "home", "city": "LA"}, + }, + }, + }, + "metadata": map[string]any{ + "version": "1.0", + "flags": []bool{true, false, true}, + }, + } + + result, err := Copy(input) + require.NoError(t, err) + + resultMap, ok := result.(map[string]any) + require.True(t, ok) + assert.Equal(t, input, resultMap) + + // Verify deep copy by modifying nested values + users := input["users"].([]map[string]any) + addresses := users[0]["addresses"].([]map[string]any) + addresses[0]["city"] = "Modified" + + resultUsers := resultMap["users"].([]map[string]any) + resultAddresses := resultUsers[0]["addresses"].([]map[string]any) + assert.Equal(t, "NYC", resultAddresses[0]["city"]) +} + +func TestCopy_Functions(t *testing.T) { + t.Run("function", func(t *testing.T) { + input := func() string { return "hello" } + result, err := Copy(input) + require.NoError(t, err) + + // Functions should be copied as-is (same reference) + resultFunc, ok := result.(func() string) + require.True(t, ok) + assert.Equal(t, input(), resultFunc()) + }) + + t.Run("nil function", func(t *testing.T) { + var input func() + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestCopy_Channels(t *testing.T) { + t.Run("channel", func(t *testing.T) { + input := make(chan int, 1) + input <- 42 + + result, err := Copy(input) + require.NoError(t, err) + + // Channels should be copied as-is (same reference) + resultChan, ok := result.(chan int) + require.True(t, ok) + + // Since channels are copied as references, verify we can read from the result channel + value := <-resultChan + assert.Equal(t, 42, value) + }) + + t.Run("nil channel", func(t *testing.T) { + var input chan int + result, err := Copy(input) + require.NoError(t, err) + assert.Nil(t, result) + }) +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index b8faf859e..674e2db34 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -36,6 +36,9 @@ type DebugCheckHandler struct { // Enabled implements slog.Handler.Enabled func (h *DebugCheckHandler) Enabled(_ context.Context, level slog.Level) bool { if level == slog.LevelDebug { + if h.debugEnabled == nil { + return false + } return h.debugEnabled() } return true // Always log other levels diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index 75e6c4025..d22a47a31 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -18,8 +18,10 @@ package logging import ( "bytes" + "context" "log/slog" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -113,3 +115,259 @@ func TestLogHolder_InterfaceCompliance(t *testing.T) { assert.Equal(t, handler, logger.Handler()) }) } + +func TestDebugCheckHandler_Enabled(t *testing.T) { + t.Run("returns debugEnabled function result for debug level", func(t *testing.T) { + // Test with debug enabled + debugEnabled := func() bool { return true } + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("returns false for debug level when debug disabled", func(t *testing.T) { + // Test with debug disabled + debugEnabled := func() bool { return false } + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + assert.False(t, handler.Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("always returns true for non-debug levels", func(t *testing.T) { + debugEnabled := func() bool { return false } // Debug disabled + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + // Even with debug disabled, other levels should always be enabled + assert.True(t, handler.Enabled(t.Context(), slog.LevelInfo)) + assert.True(t, handler.Enabled(t.Context(), slog.LevelWarn)) + assert.True(t, handler.Enabled(t.Context(), slog.LevelError)) + }) + + t.Run("calls debugEnabled function dynamically", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 // Alternates between true and false + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + // First call should return true + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + // Second call should return false + assert.False(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + + // Third call should return true again + assert.True(t, handler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 3, callCount) + }) +} + +func TestDebugCheckHandler_Handle(t *testing.T) { + t.Run("delegates to underlying handler", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + record := slog.NewRecord(time.Now(), slog.LevelInfo, "test message", 0) + err := handler.Handle(t.Context(), record) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "test message") + }) + + t.Run("handles context correctly", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + type testKey string + ctx := context.WithValue(t.Context(), testKey("test"), "value") + record := slog.NewRecord(time.Now(), slog.LevelInfo, "context test", 0) + err := handler.Handle(ctx, record) + + assert.NoError(t, err) + assert.Contains(t, buf.String(), "context test") + }) +} + +func TestDebugCheckHandler_WithAttrs(t *testing.T) { + t.Run("returns new DebugCheckHandler with attributes", func(t *testing.T) { + logger := NewLogger(func() bool { return true }) + handler := logger.Handler() + newHandler := handler.WithAttrs([]slog.Attr{ + slog.String("key1", "value1"), + slog.Int("key2", 42), + }) + + // Should return a DebugCheckHandler + debugHandler, ok := newHandler.(*DebugCheckHandler) + assert.True(t, ok) + assert.NotNil(t, debugHandler) + + // Should preserve the debugEnabled function + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should have the attributes applied to the underlying handler + assert.NotEqual(t, handler, debugHandler.handler) + }) + + t.Run("preserves debugEnabled function", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + attrs := []slog.Attr{slog.String("test", "value")} + newHandler := handler.WithAttrs(attrs) + + // The new handler should use the same debugEnabled function + assert.True(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + assert.False(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + }) +} + +func TestDebugCheckHandler_WithGroup(t *testing.T) { + t.Run("returns new DebugCheckHandler with group", func(t *testing.T) { + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: func() bool { return true }, + } + + newHandler := handler.WithGroup("testgroup") + + // Should return a DebugCheckHandler + debugHandler, ok := newHandler.(*DebugCheckHandler) + assert.True(t, ok) + assert.NotNil(t, debugHandler) + + // Should preserve the debugEnabled function + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should have the group applied to the underlying handler + assert.NotEqual(t, handler.handler, debugHandler.handler) + }) + + t.Run("preserves debugEnabled function", func(t *testing.T) { + callCount := 0 + debugEnabled := func() bool { + callCount++ + return callCount%2 == 1 + } + + buf := &bytes.Buffer{} + baseHandler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := &DebugCheckHandler{ + handler: baseHandler, + debugEnabled: debugEnabled, + } + + newHandler := handler.WithGroup("testgroup") + + // The new handler should use the same debugEnabled function + assert.True(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 1, callCount) + + assert.False(t, newHandler.Enabled(t.Context(), slog.LevelDebug)) + assert.Equal(t, 2, callCount) + }) +} + +func TestDebugCheckHandler_Integration(t *testing.T) { + t.Run("works with NewLogger function", func(t *testing.T) { + debugEnabled := func() bool { return true } + logger := NewLogger(debugEnabled) + + assert.NotNil(t, logger) + + // The logger should have a DebugCheckHandler + handler := logger.Handler() + debugHandler, ok := handler.(*DebugCheckHandler) + assert.True(t, ok) + + // Should enable debug when debugEnabled returns true + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Should enable other levels regardless + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelInfo)) + }) + + t.Run("dynamic debug checking works in practice", func(t *testing.T) { + debugState := false + debugEnabled := func() bool { return debugState } + + logger := NewLogger(debugEnabled) + + // Initially debug should be disabled + assert.False(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + + // Enable debug + debugState = true + assert.True(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + + // Disable debug again + debugState = false + assert.False(t, logger.Handler().(*DebugCheckHandler).Enabled(t.Context(), slog.LevelDebug)) + }) + + t.Run("handles nil debugEnabled function", func(t *testing.T) { + logger := NewLogger(nil) + + assert.NotNil(t, logger) + + // The logger should have a DebugCheckHandler + handler := logger.Handler() + debugHandler, ok := handler.(*DebugCheckHandler) + assert.True(t, ok) + + // When debugEnabled is nil, debug level should be disabled (default behavior) + assert.False(t, debugHandler.Enabled(t.Context(), slog.LevelDebug)) + + // Other levels should always be enabled + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelInfo)) + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelWarn)) + assert.True(t, debugHandler.Enabled(t.Context(), slog.LevelError)) + }) +} diff --git a/internal/plugin/config.go b/internal/plugin/config.go index e1f491779..785bde840 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -23,14 +23,13 @@ import ( "go.yaml.in/yaml/v3" ) -// Config represents an plugin type specific configuration -// It is expected to type assert (cast) the a Config to its expected underlying type (schema.ConfigCLIV1, schema.ConfigGetterV1, etc). +// Config represents a plugin type specific configuration +// It is expected to type assert (cast) the Config to its expected underlying type (schema.ConfigCLIV1, schema.ConfigGetterV1, etc). type Config interface { Validate() error } -func unmarshaConfig(pluginType string, configData map[string]any) (Config, error) { - +func unmarshalConfig(pluginType string, configData map[string]any) (Config, error) { pluginTypeMeta, ok := pluginTypesIndex[pluginType] if !ok { return nil, fmt.Errorf("unknown plugin type %q", pluginType) diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go index c51b77ff0..beac05abf 100644 --- a/internal/plugin/config_test.go +++ b/internal/plugin/config_test.go @@ -27,7 +27,7 @@ import ( func TestUnmarshaConfig(t *testing.T) { // Test unmarshalling a CLI plugin config { - config, err := unmarshaConfig("cli/v1", map[string]any{ + config, err := unmarshalConfig("cli/v1", map[string]any{ "usage": "usage string", "shortHelp": "short help string", "longHelp": "long help string", @@ -46,7 +46,7 @@ func TestUnmarshaConfig(t *testing.T) { // Test unmarshalling invalid config data { - config, err := unmarshaConfig("cli/v1", map[string]any{ + config, err := unmarshalConfig("cli/v1", map[string]any{ "invalid field": "foo", }) require.Error(t, err) diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go index 71efebc67..b753dfbca 100644 --- a/internal/plugin/installer/extractor.go +++ b/internal/plugin/installer/extractor.go @@ -140,7 +140,7 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { tarReader := tar.NewReader(uncompressedStream) for { header, err := tarReader.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index 891a5fceb..4f878d79a 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -458,7 +458,7 @@ func TestExtractWithNestedDirectories(t *testing.T) { }{ {"plugin.yaml", "plugin metadata", 0600, tar.TypeReg}, {"bin/", "", 0755, tar.TypeDir}, - {"bin/plugin", "#!/bin/bash\necho plugin", 0755, tar.TypeReg}, + {"bin/plugin", "#!/usr/bin/env sh\necho plugin", 0755, tar.TypeReg}, {"docs/", "", 0755, tar.TypeDir}, {"docs/README.md", "readme content", 0644, tar.TypeReg}, {"docs/examples/", "", 0755, tar.TypeDir}, @@ -621,7 +621,7 @@ func TestExtractPluginInSubdirectory(t *testing.T) { {"my-plugin/", "", 0755, tar.TypeDir}, {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test plugin\ncommand: $HELM_PLUGIN_DIR/bin/my-plugin", 0644, tar.TypeReg}, {"my-plugin/bin/", "", 0755, tar.TypeDir}, - {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, tar.TypeReg}, + {"my-plugin/bin/my-plugin", "#!/usr/bin/env sh\necho test", 0755, tar.TypeReg}, } for _, file := range files { diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index dbb5d81b3..ec8639618 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -84,7 +84,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) return nil, err } if _, pathErr := os.Stat(i.Path()); !os.IsNotExist(pathErr) { - slog.Warn("plugin already exists", "path", i.Path(), slog.Any("error", pathErr)) + slog.Warn("plugin already exists", slog.String("path", i.Path()), slog.Any("error", pathErr)) return nil, errors.New("plugin already exists") } @@ -136,7 +136,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) // Update updates a plugin. func Update(i Installer) error { if _, pathErr := os.Stat(i.Path()); os.IsNotExist(pathErr) { - slog.Warn("plugin does not exist", "path", i.Path(), slog.Any("error", pathErr)) + slog.Warn("plugin does not exist", slog.String("path", i.Path()), slog.Any("error", pathErr)) return errors.New("plugin does not exist") } return i.Update() @@ -167,7 +167,11 @@ func NewForSource(source, version string) (installer Installer, err error) { func FindSource(location string) (Installer, error) { installer, err := existingVCSRepo(location) if err != nil && err.Error() == "Cannot detect VCS" { - slog.Warn("cannot get information about plugin source", "location", location, slog.Any("error", err)) + slog.Warn( + "cannot get information about plugin source", + slog.String("location", location), + slog.Any("error", err), + ) return installer, errors.New("cannot get information about plugin source") } return installer, err diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 894d38b0b..467f0c029 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -81,7 +81,7 @@ func TestLocalInstallerTarball(t *testing.T) { Mode int64 }{ {"test-plugin/plugin.yaml", "name: test-plugin\napiVersion: v1\ntype: cli/v1\nruntime: subprocess\nversion: 1.0.0\nconfig:\n shortHelp: test\n longHelp: test\nruntimeConfig:\n platformCommand:\n - command: echo", 0644}, - {"test-plugin/bin/test-plugin", "#!/bin/bash\necho test", 0755}, + {"test-plugin/bin/test-plugin", "#!/usr/bin/env sh\necho test", 0755}, } // Write tarball to file diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go index 4dac57021..c9258790c 100644 --- a/internal/plugin/installer/oci_installer.go +++ b/internal/plugin/installer/oci_installer.go @@ -19,6 +19,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "errors" "fmt" "io" "log/slog" @@ -215,7 +216,7 @@ func extractTar(r io.Reader, targetDir string) error { for { header, err := tarReader.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index a58a84126..2f051b99e 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -47,6 +47,7 @@ func loadMetadataLegacy(metadataData []byte) (*Metadata, error) { var ml MetadataLegacy d := yaml.NewDecoder(bytes.NewReader(metadataData)) + // NOTE: No strict unmarshalling for legacy plugins - maintain backwards compatibility if err := d.Decode(&ml); err != nil { return nil, err } @@ -66,6 +67,7 @@ func loadMetadataV1(metadataData []byte) (*Metadata, error) { var mv1 MetadataV1 d := yaml.NewDecoder(bytes.NewReader(metadataData)) + d.KnownFields(true) if err := d.Decode(&mv1); err != nil { return nil, err } diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 47c214910..e84905248 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -268,3 +268,96 @@ func TestFindPlugins(t *testing.T) { }) } } + +func TestLoadMetadataLegacy(t *testing.T) { + testCases := map[string]struct { + yaml string + expectError bool + errorContains string + expectedName string + logNote string + }{ + "capital name field": { + yaml: `Name: my-plugin +version: 1.0.0 +usage: test plugin +description: test description +command: echo test`, + expectError: true, + errorContains: `invalid plugin name "": must contain only a-z, A-Z, 0-9, _ and -`, + // Legacy plugins: No strict unmarshalling (backwards compatibility) + // YAML decoder silently ignores "Name:", then validation catches empty name + logNote: "NOTE: V1 plugins use strict unmarshalling and would get: yaml: field Name not found", + }, + "correct name field": { + yaml: `name: my-plugin +version: 1.0.0 +usage: test plugin +description: test description +command: echo test`, + expectError: false, + expectedName: "my-plugin", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + m, err := loadMetadataLegacy([]byte(tc.yaml)) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorContains) + t.Logf("Legacy error (validation catches empty name): %v", err) + if tc.logNote != "" { + t.Log(tc.logNote) + } + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedName, m.Name) + } + }) + } +} + +func TestLoadMetadataV1(t *testing.T) { + testCases := map[string]struct { + yaml string + expectError bool + errorContains string + expectedName string + }{ + "capital name field": { + yaml: `apiVersion: v1 +Name: my-plugin +type: cli/v1 +runtime: subprocess +`, + expectError: true, + errorContains: "field Name not found in type plugin.MetadataV1", + }, + "correct name field": { + yaml: `apiVersion: v1 +name: my-plugin +type: cli/v1 +runtime: subprocess +`, + expectError: false, + expectedName: "my-plugin", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + m, err := loadMetadataV1([]byte(tc.yaml)) + + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorContains) + t.Logf("V1 error (strict unmarshalling): %v", err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedName, m.Name) + } + }) + } +} diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index 111c0599f..4e019f0b3 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -54,7 +54,7 @@ func (m Metadata) Validate() error { var errs []error if !validPluginName.MatchString(m.Name) { - errs = append(errs, fmt.Errorf("invalid name")) + errs = append(errs, fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name)) } if m.APIVersion == "" { @@ -174,13 +174,12 @@ func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig { } func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) { - - config, err := unmarshaConfig(mv1.Type, mv1.Config) + config, err := unmarshalConfig(mv1.Type, mv1.Config) if err != nil { return nil, err } - runtimeConfig, err := convertMetdataRuntimeConfig(mv1.Runtime, mv1.RuntimeConfig) + runtimeConfig, err := convertMetadataRuntimeConfig(mv1.Runtime, mv1.RuntimeConfig) if err != nil { return nil, err } @@ -197,7 +196,7 @@ func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) { }, nil } -func convertMetdataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string]any) (RuntimeConfig, error) { +func convertMetadataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string]any) (RuntimeConfig, error) { var runtimeConfig RuntimeConfig var err error diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go index a7b245dc0..3cd1a50cd 100644 --- a/internal/plugin/metadata_legacy.go +++ b/internal/plugin/metadata_legacy.go @@ -69,7 +69,7 @@ type MetadataLegacy struct { func (m *MetadataLegacy) Validate() error { if !validPluginName.MatchString(m.Name) { - return fmt.Errorf("invalid plugin name") + return fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name) } m.Usage = sanitizeString(m.Usage) diff --git a/internal/plugin/metadata_legacy_test.go b/internal/plugin/metadata_legacy_test.go new file mode 100644 index 000000000..9421e98b5 --- /dev/null +++ b/internal/plugin/metadata_legacy_test.go @@ -0,0 +1,126 @@ +/* +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 plugin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMetadataLegacyValidate(t *testing.T) { + testsValid := map[string]MetadataLegacy{ + "valid metadata": { + Name: "myplugin", + }, + "valid with command": { + Name: "myplugin", + Command: "echo hello", + }, + "valid with platformCommand": { + Name: "myplugin", + PlatformCommand: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "amd64", Command: "echo hello"}, + }, + }, + "valid with hooks": { + Name: "myplugin", + Hooks: Hooks{ + "install": "echo install", + }, + }, + "valid with platformHooks": { + Name: "myplugin", + PlatformHooks: PlatformHooks{ + "install": []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "amd64", Command: "echo install"}, + }, + }, + }, + "valid with downloaders": { + Name: "myplugin", + Downloaders: []Downloaders{ + { + Protocols: []string{"myproto"}, + Command: "echo download", + }, + }, + }, + } + + for testName, metadata := range testsValid { + t.Run(testName, func(t *testing.T) { + assert.NoError(t, metadata.Validate()) + }) + } + + testsInvalid := map[string]MetadataLegacy{ + "invalid name": { + Name: "my plugin", // further tested in TestValidPluginName + }, + "both command and platformCommand": { + Name: "myplugin", + Command: "echo hello", + PlatformCommand: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "amd64", Command: "echo hello"}, + }, + }, + "both hooks and platformHooks": { + Name: "myplugin", + Hooks: Hooks{ + "install": "echo install", + }, + PlatformHooks: PlatformHooks{ + "install": []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "amd64", Command: "echo install"}, + }, + }, + }, + "downloader with empty command": { + Name: "myplugin", + Downloaders: []Downloaders{ + { + Protocols: []string{"myproto"}, + Command: "", + }, + }, + }, + "downloader with no protocols": { + Name: "myplugin", + Downloaders: []Downloaders{ + { + Protocols: []string{}, + Command: "echo download", + }, + }, + }, + "downloader with empty protocol": { + Name: "myplugin", + Downloaders: []Downloaders{ + { + Protocols: []string{""}, + Command: "echo download", + }, + }, + }, + } + + for testName, metadata := range testsInvalid { + t.Run(testName, func(t *testing.T) { + assert.Error(t, metadata.Validate()) + }) + } +} diff --git a/internal/plugin/metadata_test.go b/internal/plugin/metadata_test.go index 28bc4cf51..145ef5101 100644 --- a/internal/plugin/metadata_test.go +++ b/internal/plugin/metadata_test.go @@ -53,10 +53,10 @@ func TestValidatePluginData(t *testing.T) { }{ {true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""}, {true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""}, - {false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"}, - {false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars - {false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars - {false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline + {false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid plugin name"}, + {false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid plugin name"}, // Test leading chars + {false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid plugin name"}, // Test trailing chars + {false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid plugin name"}, // Test newline {true, mockNoCommand, ""}, // Test no command metadata works {true, mockLegacyCommand, ""}, // Test legacy command metadata works } { @@ -66,8 +66,8 @@ func TestValidatePluginData(t *testing.T) { } else if !item.pass && err == nil { t.Errorf("expected case %d to fail", i) } - if !item.pass && err.Error() != item.errString { - t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error()) + if !item.pass && !strings.Contains(err.Error(), item.errString) { + t.Errorf("index [%d]: expected error to contain: %s, but got: %s", i, item.errString, err.Error()) } } } @@ -92,7 +92,7 @@ func TestMetadataValidateMultipleErrors(t *testing.T) { // Check that all expected errors are present in the joined error expectedErrors := []string{ - "invalid name", + "invalid plugin name", "empty APIVersion", "empty type field", "empty runtime field", diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index b6c2245ff..ae0b343f3 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -21,6 +21,44 @@ import ( "helm.sh/helm/v4/internal/plugin/schema" ) +func TestValidPluginName(t *testing.T) { + validNames := map[string]string{ + "lowercase": "myplugin", + "uppercase": "MYPLUGIN", + "mixed case": "MyPlugin", + "with digits": "plugin123", + "with hyphen": "my-plugin", + "with underscore": "my_plugin", + "mixed chars": "my-awesome_plugin_123", + } + + for name, pluginName := range validNames { + t.Run("valid/"+name, func(t *testing.T) { + if !validPluginName.MatchString(pluginName) { + t.Errorf("expected %q to match validPluginName regex", pluginName) + } + }) + } + + invalidNames := map[string]string{ + "empty": "", + "space": "my plugin", + "colon": "plugin:", + "period": "my.plugin", + "slash": "my/plugin", + "dollar": "$plugin", + "unicode": "plĂĽgĂ®n", + } + + for name, pluginName := range invalidNames { + t.Run("invalid/"+name, func(t *testing.T) { + if validPluginName.MatchString(pluginName) { + t.Errorf("expected %q to not match validPluginName regex", pluginName) + } + }) + } +} + func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime { t.Helper() diff --git a/internal/plugin/sign.go b/internal/plugin/sign.go index 6b8aafd3e..6ddf113a2 100644 --- a/internal/plugin/sign.go +++ b/internal/plugin/sign.go @@ -63,7 +63,7 @@ func ExtractTgzPluginMetadata(r io.Reader) (*Metadata, error) { tr := tar.NewReader(gzr) for { header, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/internal/plugin/subprocess_commands.go b/internal/plugin/subprocess_commands.go index e21ec2bab..9a57ed891 100644 --- a/internal/plugin/subprocess_commands.go +++ b/internal/plugin/subprocess_commands.go @@ -82,15 +82,16 @@ func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string if len(cmdParts) == 0 || cmdParts[0] == "" { return "", nil, fmt.Errorf("no plugin command is applicable") } - - main := os.Expand(cmdParts[0], func(key string) string { + envMappingFunc := func(key string) string { return env[key] - }) + } + + main := os.Expand(cmdParts[0], envMappingFunc) baseArgs := []string{} if len(cmdParts) > 1 { for _, cmdPart := range cmdParts[1:] { if expandArgs { - baseArgs = append(baseArgs, os.ExpandEnv(cmdPart)) + baseArgs = append(baseArgs, os.Expand(cmdPart, envMappingFunc)) } else { baseArgs = append(baseArgs, cmdPart) } @@ -99,7 +100,7 @@ func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string for _, arg := range args { if expandArgs { - baseArgs = append(baseArgs, os.ExpandEnv(arg)) + baseArgs = append(baseArgs, os.Expand(arg, envMappingFunc)) } else { baseArgs = append(baseArgs, arg) } diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go index c1eba7a55..8e9c1663e 100644 --- a/internal/plugin/subprocess_commands_test.go +++ b/internal/plugin/subprocess_commands_test.go @@ -224,16 +224,19 @@ func TestPrepareCommandsNoCommands(t *testing.T) { } func TestPrepareCommandsExpand(t *testing.T) { - t.Setenv("TEST", "test") cmdMain := "sh" - cmdArgs := []string{"-c", "echo \"${TEST}\""} + cmdArgs := []string{"-c", "echo \"${TESTX}${TESTY}\""} cmds := []PlatformCommand{ {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs}, } - expectedArgs := []string{"-c", "echo \"test\""} + expectedArgs := []string{"-c", "echo \"testxtesty\""} + + env := map[string]string{ + "TESTX": "testx", + "TESTY": "testy", + } - env := map[string]string{} cmd, args, err := PrepareCommands(cmds, true, []string{}, env) if err != nil { t.Fatal(err) @@ -247,14 +250,16 @@ func TestPrepareCommandsExpand(t *testing.T) { } func TestPrepareCommandsNoExpand(t *testing.T) { - t.Setenv("TEST", "test") cmdMain := "sh" cmdArgs := []string{"-c", "echo \"${TEST}\""} cmds := []PlatformCommand{ {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs}, } - env := map[string]string{} + env := map[string]string{ + "TEST": "test", + } + cmd, args, err := PrepareCommands(cmds, false, []string{}, env) if err != nil { t.Fatal(err) diff --git a/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh index dcfd58876..4f20796ef 100755 --- a/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh +++ b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh @@ -1,9 +1,9 @@ -#!/bin/bash +#!/usr/bin/env sh echo "Hello from a Helm plugin" echo "PARAMS" -echo $* +echo "$@" $HELM_BIN ls --all diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh index dcfd58876..4f20796ef 100755 --- a/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh +++ b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh @@ -1,9 +1,9 @@ -#!/bin/bash +#!/usr/bin/env sh echo "Hello from a Helm plugin" echo "PARAMS" -echo $* +echo "$@" $HELM_BIN ls --all diff --git a/internal/statusreaders/pod_status_reader.go b/internal/statusreaders/pod_status_reader.go index c074c3487..bf633c0dd 100644 --- a/internal/statusreaders/pod_status_reader.go +++ b/internal/statusreaders/pod_status_reader.go @@ -86,19 +86,19 @@ func podConditions(u *unstructured.Unstructured) (*status.Result, error) { }, }, }, nil - } - - message := "Pod in progress" - return &status.Result{ - Status: status.InProgressStatus, - Message: message, - Conditions: []status.Condition{ - { - Type: status.ConditionReconciling, - Status: corev1.ConditionTrue, - Reason: "PodInProgress", - Message: message, + default: + message := "Pod in progress" + return &status.Result{ + Status: status.InProgressStatus, + Message: message, + Conditions: []status.Condition{ + { + Type: status.ConditionReconciling, + Status: corev1.ConditionTrue, + Reason: "PodInProgress", + Message: message, + }, }, - }, - }, nil + }, nil + } } diff --git a/internal/version/clientgo.go b/internal/version/clientgo.go new file mode 100644 index 000000000..ab2a38fd5 --- /dev/null +++ b/internal/version/clientgo.go @@ -0,0 +1,44 @@ +/* +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 version + +import ( + "fmt" + "runtime/debug" + "slices" + + _ "k8s.io/client-go/kubernetes" // Force k8s.io/client-go to be included in the build +) + +func K8sIOClientGoModVersion() (string, error) { + info, ok := debug.ReadBuildInfo() + if !ok { + return "", fmt.Errorf("failed to read build info") + } + + idx := slices.IndexFunc(info.Deps, func(m *debug.Module) bool { + return m.Path == "k8s.io/client-go" + }) + + if idx == -1 { + return "", fmt.Errorf("k8s.io/client-go not found in build info") + } + + m := info.Deps[idx] + + return m.Version, nil +} diff --git a/internal/version/clientgo_test.go b/internal/version/clientgo_test.go new file mode 100644 index 000000000..624c669af --- /dev/null +++ b/internal/version/clientgo_test.go @@ -0,0 +1,30 @@ +/* +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 version + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestK8sClientGoModVersion(t *testing.T) { + // Unfortunately, test builds don't include debug info / module info + // So we expect "K8sIOClientGoModVersion" to return error + _, err := K8sIOClientGoModVersion() + require.ErrorContains(t, err, "k8s.io/client-go not found in build info") +} diff --git a/internal/version/version.go b/internal/version/version.go index b7f2436a1..3daf80893 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -14,13 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -package version // import "helm.sh/helm/v4/internal/version" +package version import ( "flag" "fmt" + "log/slog" "runtime" "strings" + "testing" + + "github.com/Masterminds/semver/v3" ) var ( @@ -30,7 +34,7 @@ var ( // // Increment major number for new feature additions and behavioral changes. // Increment minor number for bug fixes and performance enhancements. - version = "v4.0" + version = "v4.1" // metadata is extra build time data metadata = "" @@ -38,11 +42,10 @@ var ( gitCommit = "" // gitTreeState is the state of the git tree gitTreeState = "" +) - // The Kubernetes version can be set by LDFLAGS. In order to do that the value - // must be a string. - kubeClientVersionMajor = "" - kubeClientVersionMinor = "" +const ( + kubeClientGoVersionTesting = "v1.20" ) // BuildInfo describes the compile time information. @@ -74,12 +77,39 @@ func GetUserAgent() string { // Get returns build info func Get() BuildInfo { + + makeKubeClientVersionString := func() string { + // Test builds don't include debug info / module info + // (And even if they did, we probably want a stable version during tests anyway) + // Return a default value for test builds + if testing.Testing() { + return kubeClientGoVersionTesting + } + + vstr, err := K8sIOClientGoModVersion() + if err != nil { + slog.Error("failed to retrieve k8s.io/client-go version", slog.Any("error", err)) + return "" + } + + v, err := semver.NewVersion(vstr) + if err != nil { + slog.Error("unable to parse k8s.io/client-go version", slog.String("version", vstr), slog.Any("error", err)) + return "" + } + + kubeClientVersionMajor := v.Major() + 1 + kubeClientVersionMinor := v.Minor() + + return fmt.Sprintf("v%d.%d", kubeClientVersionMajor, kubeClientVersionMinor) + } + v := BuildInfo{ Version: GetVersion(), GitCommit: gitCommit, GitTreeState: gitTreeState, GoVersion: runtime.Version(), - KubeClientVersion: fmt.Sprintf("v%s.%s", kubeClientVersionMajor, kubeClientVersionMinor), + KubeClientVersion: makeKubeClientVersionString(), } // HACK(bacongobbler): strip out GoVersion during a test run for consistent test output diff --git a/pkg/action/action.go b/pkg/action/action.go index 9555006be..c2a27940f 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -230,7 +230,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, if ch.Metadata.KubeVersion != "" { if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) { - return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String()) + return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.Version) } } @@ -502,7 +502,12 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet // recordRelease with an update operation in case reuse has been set. func (cfg *Configuration) recordRelease(r *release.Release) { if err := cfg.Releases.Update(r); err != nil { - cfg.Logger().Warn("failed to update release", "name", r.Name, "revision", r.Version, slog.Any("error", err)) + cfg.Logger().Warn( + "failed to update release", + slog.String("name", r.Name), + slog.Int("revision", r.Version), + slog.Any("error", err), + ) } } diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index b77a462f5..85ee42d64 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -201,6 +201,12 @@ func withMetadataDependency(dependency chart.Dependency) chartOption { } } +func withFile(file common.File) chartOption { + return func(opts *chartOptions) { + opts.Files = append(opts.Files, &file) + } +} + func withSampleTemplates() chartOption { return func(opts *chartOptions) { modTime := time.Now() diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index cd5988d8e..4caa966ab 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -121,7 +121,7 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -180,7 +180,7 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -251,7 +251,7 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -315,7 +315,7 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -370,8 +370,8 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel1) - cfg.Releases.Create(rel2) + require.NoError(t, cfg.Releases.Create(rel1)) + require.NoError(t, cfg.Releases.Create(rel2)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -424,7 +424,7 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -480,7 +480,7 @@ func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) diff --git a/pkg/action/get_test.go b/pkg/action/get_test.go new file mode 100644 index 000000000..876819ee4 --- /dev/null +++ b/pkg/action/get_test.go @@ -0,0 +1,69 @@ +/* +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 action + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "helm.sh/helm/v4/pkg/kube/fake" + + "helm.sh/helm/v4/pkg/release/common" +) + +func TestNewGet(t *testing.T) { + config := actionConfigFixture(t) + client := NewGet(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) + assert.Equal(t, 0, client.Version) +} + +func TestGetRun(t *testing.T) { + config := actionConfigFixture(t) + client := NewGet(config) + simpleRelease := namedReleaseStub("test-release", common.StatusPendingUpgrade) + require.NoError(t, config.Releases.Create(simpleRelease)) + + releaser, err := client.Run(simpleRelease.Name) + require.NoError(t, err) + + result, err := releaserToV1Release(releaser) + require.NoError(t, err) + assert.Equal(t, simpleRelease.Name, result.Name) + assert.Equal(t, simpleRelease.Version, result.Version) +} + +func TestGetRun_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 + + client := NewGet(config) + simpleRelease := namedReleaseStub("test-release", common.StatusPendingUpgrade) + require.NoError(t, config.Releases.Create(simpleRelease)) + + result, err := client.Run(simpleRelease.Name) + assert.Nil(t, result) + assert.Error(t, err) +} diff --git a/pkg/action/get_values_test.go b/pkg/action/get_values_test.go index 8e6588454..69a95a2e4 100644 --- a/pkg/action/get_values_test.go +++ b/pkg/action/get_values_test.go @@ -79,7 +79,7 @@ func TestGetValues_Run_UserConfigOnly(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -127,7 +127,7 @@ func TestGetValues_Run_AllValues(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -161,7 +161,7 @@ func TestGetValues_Run_EmptyValues(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) @@ -212,7 +212,7 @@ func TestGetValues_Run_NilConfig(t *testing.T) { Namespace: "default", } - cfg.Releases.Create(rel) + require.NoError(t, cfg.Releases.Create(rel)) result, err := client.Run(releaseName) require.NoError(t, err) diff --git a/pkg/action/history.go b/pkg/action/history.go index 90307c79b..3d561b3ad 100644 --- a/pkg/action/history.go +++ b/pkg/action/history.go @@ -20,7 +20,7 @@ import ( "fmt" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - release "helm.sh/helm/v4/pkg/release" + "helm.sh/helm/v4/pkg/release" ) // History is the action for checking the release's ledger. diff --git a/pkg/action/history_test.go b/pkg/action/history_test.go new file mode 100644 index 000000000..31fdd4a96 --- /dev/null +++ b/pkg/action/history_test.go @@ -0,0 +1,108 @@ +/* +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 action + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "helm.sh/helm/v4/pkg/kube/fake" + release "helm.sh/helm/v4/pkg/release/v1" + + "helm.sh/helm/v4/pkg/release/common" +) + +func TestNewHistory(t *testing.T) { + config := actionConfigFixture(t) + client := NewHistory(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestHistoryRun(t *testing.T) { + releaseName := "test-release" + simpleRelease := namedReleaseStub(releaseName, common.StatusPendingUpgrade) + updatedRelease := namedReleaseStub(releaseName, common.StatusDeployed) + updatedRelease.Chart.Metadata.Version = "0.1.1" + updatedRelease.Version = 2 + + config := actionConfigFixture(t) + client := NewHistory(config) + client.Max = 3 + client.cfg.Releases.MaxHistory = 3 + for _, rel := range []*release.Release{simpleRelease, updatedRelease} { + if err := client.cfg.Releases.Create(rel); err != nil { + t.Fatal(err, "Could not add releases to Config") + } + } + + releases, err := config.Releases.ListReleases() + require.NoError(t, err) + assert.Len(t, releases, 2, "expected 2 Releases in Config") + + releasers, err := client.Run(releaseName) + require.NoError(t, err) + assert.Len(t, releasers, 2, "expected 2 Releases in History result") + + release1, err := releaserToV1Release(releasers[0]) + require.NoError(t, err) + assert.Equal(t, simpleRelease.Name, release1.Name) + assert.Equal(t, simpleRelease.Version, release1.Version) + + release2, err := releaserToV1Release(releasers[1]) + require.NoError(t, err) + assert.Equal(t, updatedRelease.Name, release2.Name) + assert.Equal(t, updatedRelease.Version, release2.Version) +} + +func TestHistoryRun_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 + + client := NewHistory(config) + result, err := client.Run("release-name") + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestHistoryRun_InvalidReleaseNames(t *testing.T) { + config := actionConfigFixture(t) + client := NewHistory(config) + invalidReleaseNames := []string{ + "", + "too-long-release-name-max-53-characters-abcdefghijklmnopqrstuvwxyz", + "MyRelease", + "release_name", + "release@123", + "-badstart", + "badend-", + ".dotstart", + } + + for _, name := range invalidReleaseNames { + result, err := client.Run(name) + assert.Nil(t, result) + assert.ErrorContains(t, err, "release name is invalid") + } +} diff --git a/pkg/action/install.go b/pkg/action/install.go index 9008c06ec..b379d6873 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -62,8 +62,8 @@ import ( "helm.sh/helm/v4/pkg/storage/driver" ) -// notesFileSuffix that we want to treat special. It goes through the templating engine -// but it's not a yaml file (resource) hence can't have hooks, etc. And the user actually +// notesFileSuffix that we want to treat specially. It goes through the templating engine +// but it's not a YAML file (resource) hence can't have hooks, etc. And the user actually // wants to see this file after rendering in the status command. However, it must be a suffix // since there can be filepath in front of it. const notesFileSuffix = "NOTES.txt" @@ -116,7 +116,7 @@ type Install struct { Labels map[string]string // KubeVersion allows specifying a custom kubernetes version to use and // APIVersions allows a manual set of supported API Versions to be passed - // (for things like templating). These are ignored if ClientOnly is false + // (for things like templating). KubeVersion *common.KubeVersion APIVersions common.VersionSet // Used by helm template to render charts with .Release.IsUpgrade. Ignored if Dry-Run is false @@ -192,7 +192,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error { kube.ClientCreateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts)); err != nil { // If the error is CRD already exists, continue. if apierrors.IsAlreadyExists(err) { - crdName := res[0].Name + crdName := obj.Name i.cfg.Logger().Debug("CRD is already present. Skipping", "crd", crdName) continue } @@ -419,6 +419,7 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st if err != nil { return nil, err } + if _, err := i.cfg.KubeClient.Create( resourceList, kube.ClientCreateOptionServerSideApply(i.ServerSideApply, false)); err != nil && !apierrors.IsAlreadyExists(err) { diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 9f04f40d4..9a0ca8d22 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -35,6 +35,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kuberuntime "k8s.io/apimachinery/pkg/runtime" @@ -43,10 +44,14 @@ import ( "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest/fake" + ci "helm.sh/helm/v4/pkg/chart" + "helm.sh/helm/v4/internal/test" "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/registry" rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage/driver" @@ -500,7 +505,7 @@ func TestInstallRelease_NoHooks(t *testing.T) { instAction := installAction(t) instAction.DisableHooks = true instAction.ReleaseName = "no-hooks" - instAction.cfg.Releases.Create(releaseStub()) + require.NoError(t, instAction.cfg.Releases.Create(releaseStub())) vals := map[string]interface{}{} resi, err := instAction.Run(buildChart(), vals) @@ -540,7 +545,7 @@ func TestInstallRelease_ReplaceRelease(t *testing.T) { rel := releaseStub() rel.Info.Status = rcommon.StatusUninstalled - instAction.cfg.Releases.Create(rel) + require.NoError(t, instAction.cfg.Releases.Create(rel)) instAction.ReleaseName = rel.Name vals := map[string]interface{}{} @@ -572,7 +577,7 @@ func TestInstallRelease_KubeVersion(t *testing.T) { vals = map[string]interface{}{} _, err = instAction.Run(buildChart(withKube(">=99.0.0")), vals) is.Error(err) - is.Contains(err.Error(), "chart requires kubeVersion") + is.Contains(err.Error(), "chart requires kubeVersion: >=99.0.0 which is incompatible with Kubernetes v1.20.") } func TestInstallRelease_Wait(t *testing.T) { @@ -1068,3 +1073,116 @@ func TestInstallRun_UnreachableKubeClient(t *testing.T) { assert.Nil(t, res) assert.ErrorContains(t, err, "connection refused") } + +func TestInstallSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + instAction := NewInstall(config) + + registryClient := ®istry.Client{} + instAction.SetRegistryClient(registryClient) + + assert.Equal(t, registryClient, instAction.GetRegistryClient()) +} + +func TestInstalLCRDs(t *testing.T) { + config := actionConfigFixture(t) + instAction := NewInstall(config) + + mockFile := common.File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + } + mockChart := buildChart(withFile(mockFile)) + crdsToInstall := mockChart.CRDObjects() + assert.Len(t, crdsToInstall, 1) + assert.Equal(t, crdsToInstall[0].File.Data, mockFile.Data) + + require.NoError(t, instAction.installCRDs(crdsToInstall)) +} + +func TestInstalLCRDs_KubeClient_BuildError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildError = errors.New("build error") + config.KubeClient = &failingKubeClient + instAction := NewInstall(config) + + mockFile := common.File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + } + mockChart := buildChart(withFile(mockFile)) + crdsToInstall := mockChart.CRDObjects() + + require.Error(t, instAction.installCRDs(crdsToInstall), "failed to install CRD") +} + +func TestInstalLCRDs_KubeClient_CreateError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.CreateError = errors.New("create error") + config.KubeClient = &failingKubeClient + instAction := NewInstall(config) + + mockFile := common.File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + } + mockChart := buildChart(withFile(mockFile)) + crdsToInstall := mockChart.CRDObjects() + + require.Error(t, instAction.installCRDs(crdsToInstall), "failed to install CRD") +} + +func TestInstalLCRDs_AlreadyExist(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + mockError := &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Reason: metav1.StatusReasonAlreadyExists, + }} + failingKubeClient.CreateError = mockError + config.KubeClient = &failingKubeClient + instAction := NewInstall(config) + + mockFile := common.File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + } + mockChart := buildChart(withFile(mockFile)) + crdsToInstall := mockChart.CRDObjects() + + assert.Nil(t, instAction.installCRDs(crdsToInstall)) +} + +func TestInstalLCRDs_WaiterError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.WaitError = errors.New("wait error") + failingKubeClient.BuildDummy = true + config.KubeClient = &failingKubeClient + instAction := NewInstall(config) + + mockFile := common.File{ + Name: "crds/foo.yaml", + Data: []byte("hello"), + } + mockChart := buildChart(withFile(mockFile)) + crdsToInstall := mockChart.CRDObjects() + + require.Error(t, instAction.installCRDs(crdsToInstall), "wait error") +} + +func TestCheckDependencies(t *testing.T) { + dependency := chart.Dependency{Name: "hello"} + mockChart := buildChart(withDependency()) + + assert.Nil(t, CheckDependencies(mockChart, []ci.Dependency{&dependency})) +} + +func TestCheckDependencies_MissingDependency(t *testing.T) { + dependency := chart.Dependency{Name: "missing"} + mockChart := buildChart(withDependency()) + + assert.ErrorContains(t, CheckDependencies(mockChart, []ci.Dependency{&dependency}), "missing in charts") +} diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go index 613149a4d..4684f91f1 100644 --- a/pkg/action/lint_test.go +++ b/pkg/action/lint_test.go @@ -17,7 +17,12 @@ limitations under the License. package action import ( + "errors" "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/chart/v2/lint/support" ) var ( @@ -163,3 +168,45 @@ func TestLint_ChartWithWarnings(t *testing.T) { } }) } + +func TestHasWarningsOrErrors(t *testing.T) { + testError := errors.New("test-error") + cases := []struct { + name string + data LintResult + expected bool + }{ + { + name: "has no warning messages and no errors", + data: LintResult{TotalChartsLinted: 1, Messages: make([]support.Message, 0), Errors: make([]error, 0)}, + expected: false, + }, + { + name: "has error", + data: LintResult{TotalChartsLinted: 1, Messages: make([]support.Message, 0), Errors: []error{testError}}, + expected: true, + }, + { + name: "has info message only", + data: LintResult{TotalChartsLinted: 1, Messages: []support.Message{{Severity: support.InfoSev, Path: "", Err: testError}}, Errors: make([]error, 0)}, + expected: false, + }, + { + name: "has warning message", + data: LintResult{TotalChartsLinted: 1, Messages: []support.Message{{Severity: support.WarningSev, Path: "", Err: testError}}, Errors: make([]error, 0)}, + expected: true, + }, + { + name: "has error message", + data: LintResult{TotalChartsLinted: 1, Messages: []support.Message{{Severity: support.ErrorSev, Path: "", Err: testError}}, Errors: make([]error, 0)}, + expected: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := HasWarningsOrErrors(&tc.data) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/action/package.go b/pkg/action/package.go index 963883707..0ab49538c 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -103,7 +103,7 @@ func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { ch.Metadata.AppVersion = p.AppVersion } - if reqs := ac.MetaDependencies(); reqs != nil { + if reqs := ac.MetaDependencies(); len(reqs) > 0 { if err := CheckDependencies(ch, reqs); err != nil { return "", err } diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 12bea10dd..84dcb71c1 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/internal/test/ensure" ) @@ -90,10 +91,11 @@ func TestPassphraseFileFetcher_WithStdinAndMultipleFetches(t *testing.T) { passphrase := "secret-from-stdin" go func() { - w.Write([]byte(passphrase + "\n")) + _, err = w.Write([]byte(passphrase + "\n")) + require.NoError(t, err) }() - for i := 0; i < 4; i++ { + for range 4 { fetcher, err := testPkg.passphraseFileFetcher("-", stdin) if err != nil { t.Errorf("Expected passphraseFileFetcher to not return an error, but got %v", err) @@ -152,3 +154,18 @@ func TestValidateVersion(t *testing.T) { }) } } + +func TestRun_ErrorPath(t *testing.T) { + client := NewPackage() + _, err := client.Run("err-path", nil) + require.Error(t, err) +} + +func TestRun(t *testing.T) { + chartPath := "testdata/charts/chart-with-schema" + client := NewPackage() + filename, err := client.Run(chartPath, nil) + require.NoError(t, err) + require.Equal(t, "empty-0.1.0.tgz", filename) + require.NoError(t, os.Remove(filename)) +} diff --git a/pkg/action/pull_test.go b/pkg/action/pull_test.go new file mode 100644 index 000000000..ba212973e --- /dev/null +++ b/pkg/action/pull_test.go @@ -0,0 +1,80 @@ +/* +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 action + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/cli" + "helm.sh/helm/v4/pkg/registry" +) + +func TestNewPull(t *testing.T) { + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestPullSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + + registryClient := ®istry.Client{} + client.SetRegistryClient(registryClient) + assert.Equal(t, registryClient, client.cfg.RegistryClient) +} + +func TestPullRun_ChartNotFound(t *testing.T) { + srv, err := startLocalServerForTests(t, nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + config := actionConfigFixture(t) + client := NewPull(WithConfig(config)) + client.Settings = cli.New() + client.RepoURL = srv.URL + + chartRef := "nginx" + _, err = client.Run(chartRef) + require.ErrorContains(t, err, "404 Not Found") +} + +func startLocalServerForTests(t *testing.T, handler http.Handler) (*httptest.Server, error) { + t.Helper() + if handler == nil { + fileBytes, err := os.ReadFile("../repo/v1/testdata/local-index.yaml") + if err != nil { + return nil, err + } + handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, err = w.Write(fileBytes) + require.NoError(t, err) + }) + } + + return httptest.NewServer(handler), nil +} diff --git a/pkg/action/push_test.go b/pkg/action/push_test.go new file mode 100644 index 000000000..35c6f3efc --- /dev/null +++ b/pkg/action/push_test.go @@ -0,0 +1,66 @@ +/* +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 action + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPushWithPushConfig(t *testing.T) { + config := actionConfigFixture(t) + client := NewPushWithOpts(WithPushConfig(config)) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestNewPushWithTLSClientConfig(t *testing.T) { + certFile := "certFile" + keyFile := "keyFile" + caFile := "caFile" + client := NewPushWithOpts(WithTLSClientConfig(certFile, keyFile, caFile)) + + assert.NotNil(t, client) + assert.Equal(t, certFile, client.certFile) + assert.Equal(t, keyFile, client.keyFile) + assert.Equal(t, caFile, client.caFile) +} + +func TestNewPushWithInsecureSkipTLSVerify(t *testing.T) { + client := NewPushWithOpts(WithInsecureSkipTLSVerify(true)) + + assert.NotNil(t, client) + assert.Equal(t, true, client.insecureSkipTLSVerify) +} + +func TestNewPushWithPlainHTTP(t *testing.T) { + client := NewPushWithOpts(WithPlainHTTP(true)) + + assert.NotNil(t, client) + assert.Equal(t, true, client.plainHTTP) +} + +func TestNewPushWithPushOptWriter(t *testing.T) { + buf := new(bytes.Buffer) + client := NewPushWithOpts(WithPushOptWriter(buf)) + + assert.NotNil(t, client) + assert.Equal(t, buf, client.out) +} diff --git a/pkg/action/registry_login_test.go b/pkg/action/registry_login_test.go new file mode 100644 index 000000000..de2450d9d --- /dev/null +++ b/pkg/action/registry_login_test.go @@ -0,0 +1,84 @@ +/* +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 action + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRegistryLogin(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestWithCertFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + certFile := "testdata/cert.pem" + opt := WithCertFile(certFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, certFile, client.certFile) +} + +func TestWithInsecure(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + opt := WithInsecure(true) + + assert.Nil(t, opt(client)) + assert.Equal(t, true, client.insecure) +} + +func TestWithKeyFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + keyFile := "testdata/key.pem" + opt := WithKeyFile(keyFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, keyFile, client.keyFile) +} + +func TestWithCAFile(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + caFile := "testdata/ca.pem" + opt := WithCAFile(caFile) + + assert.Nil(t, opt(client)) + assert.Equal(t, caFile, client.caFile) +} + +func TestWithPlainHTTPLogin(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogin(config) + + opt := WithPlainHTTPLogin(true) + + assert.Nil(t, opt(client)) + assert.Equal(t, true, client.plainHTTP) +} diff --git a/pkg/action/registry_logout_test.go b/pkg/action/registry_logout_test.go new file mode 100644 index 000000000..669d9c9ba --- /dev/null +++ b/pkg/action/registry_logout_test.go @@ -0,0 +1,31 @@ +/* +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 action + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRegistryLogout(t *testing.T) { + config := actionConfigFixture(t) + client := NewRegistryLogout(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} diff --git a/pkg/action/release_testing_test.go b/pkg/action/release_testing_test.go new file mode 100644 index 000000000..1ada78a4b --- /dev/null +++ b/pkg/action/release_testing_test.go @@ -0,0 +1,91 @@ +/* +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 action + +import ( + "bytes" + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/pkg/cli" + kubefake "helm.sh/helm/v4/pkg/kube/fake" + release "helm.sh/helm/v4/pkg/release/v1" +) + +func TestNewReleaseTesting(t *testing.T) { + config := actionConfigFixture(t) + client := NewReleaseTesting(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestReleaseTestingRun_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 + + client := NewReleaseTesting(config) + result, err := client.Run("") + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestReleaseTestingGetPodLogs_FilterEvents(t *testing.T) { + config := actionConfigFixture(t) + require.NoError(t, config.Init(cli.New().RESTClientGetter(), "", os.Getenv("HELM_DRIVER"))) + client := NewReleaseTesting(config) + client.Filters[ExcludeNameFilter] = []string{"event-1"} + client.Filters[IncludeNameFilter] = []string{"event-3"} + + hooks := []*release.Hook{ + { + Name: "event-1", + Events: []release.HookEvent{release.HookTest}, + }, + { + Name: "event-2", + Events: []release.HookEvent{release.HookTest}, + }, + } + + out := &bytes.Buffer{} + require.NoError(t, client.GetPodLogs(out, &release.Release{Hooks: hooks})) + + assert.Empty(t, out.String()) +} + +func TestReleaseTestingGetPodLogs_PodRetrievalError(t *testing.T) { + config := actionConfigFixture(t) + require.NoError(t, config.Init(cli.New().RESTClientGetter(), "", os.Getenv("HELM_DRIVER"))) + client := NewReleaseTesting(config) + + hooks := []*release.Hook{ + { + Name: "event-1", + Events: []release.HookEvent{release.HookTest}, + }, + } + + require.ErrorContains(t, client.GetPodLogs(&bytes.Buffer{}, &release.Release{Hooks: hooks}), "unable to get pod logs") +} diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index cd160dfec..4cdb2d33b 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -18,8 +18,8 @@ package action import ( "bytes" + "errors" "fmt" - "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -28,6 +28,7 @@ import ( "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/storage/driver" ) // Rollback is the action for rolling back to a given release. @@ -278,7 +279,7 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas } deployed, err := r.cfg.Releases.DeployedAll(currentRelease.Name) - if err != nil && !strings.Contains(err.Error(), "has no deployed releases") { + if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { return nil, err } // Supersede all previous deployments, see issue #2941. diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go new file mode 100644 index 000000000..5158bee26 --- /dev/null +++ b/pkg/action/rollback_test.go @@ -0,0 +1,45 @@ +/* +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 action + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + + kubefake "helm.sh/helm/v4/pkg/kube/fake" +) + +func TestNewRollback(t *testing.T) { + config := actionConfigFixture(t) + client := NewRollback(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) +} + +func TestRollbackRun_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 + + client := NewRollback(config) + assert.Error(t, client.Run("")) +} diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index f3b767fca..6e270ac6d 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -20,8 +20,11 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/registry" ) func TestShow(t *testing.T) { @@ -168,3 +171,12 @@ bar t.Errorf("Expected\n%q\nGot\n%q\n", expect, output) } } + +func TestShowSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + client := NewShow(ShowAll, config) + + registryClient := ®istry.Client{} + client.SetRegistryClient(registryClient) + assert.Equal(t, registryClient, client.registryClient) +} diff --git a/pkg/action/status_test.go b/pkg/action/status_test.go new file mode 100644 index 000000000..674715aff --- /dev/null +++ b/pkg/action/status_test.go @@ -0,0 +1,143 @@ +/* +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 action + +import ( + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + kubefake "helm.sh/helm/v4/pkg/kube/fake" + rcommon "helm.sh/helm/v4/pkg/release/common" + release "helm.sh/helm/v4/pkg/release/v1" +) + +func TestNewStatus(t *testing.T) { + config := actionConfigFixture(t) + client := NewStatus(config) + + assert.NotNil(t, client) + assert.Equal(t, config, client.cfg) + assert.Equal(t, 0, client.Version) +} + +func TestStatusRun(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildDummy = true + config.KubeClient = &failingKubeClient + client := NewStatus(config) + client.ShowResourcesTable = true + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + releaser, err := client.Run(releaseName) + require.NoError(t, err) + + result, err := releaserToV1Release(releaser) + require.NoError(t, err) + assert.Equal(t, releaseName, result.Name) + assert.Equal(t, 1, result.Version) +} + +func TestStatusRun_KubeClientNotReachable(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 + + client := NewStatus(config) + + result, err := client.Run("") + assert.Nil(t, result) + assert.Error(t, err) +} + +func TestStatusRun_KubeClientBuildTableError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildTableError = errors.New("build table error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + + client := NewStatus(config) + client.ShowResourcesTable = true + + result, err := client.Run(releaseName) + + assert.Nil(t, result) + assert.ErrorContains(t, err, "build table error") +} + +func TestStatusRun_KubeClientBuildError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildError = errors.New("build error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + + client := NewStatus(config) + client.ShowResourcesTable = false + + result, err := client.Run(releaseName) + assert.Nil(t, result) + assert.ErrorContains(t, err, "build error") +} + +func TestStatusRun_KubeClientGetError(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.BuildError = errors.New("get error") + config.KubeClient = &failingKubeClient + + releaseName := "test-release" + require.NoError(t, configureReleaseContent(config, releaseName)) + client := NewStatus(config) + + result, err := client.Run(releaseName) + assert.Nil(t, result) + assert.ErrorContains(t, err, "get error") +} + +func configureReleaseContent(cfg *Configuration, releaseName string) error { + rel := &release.Release{ + Name: releaseName, + Info: &release.Info{ + Status: rcommon.StatusDeployed, + }, + Manifest: testManifest, + Version: 1, + Namespace: "default", + } + + return cfg.Releases.Create(rel) +} + +const testManifest = ` +apiVersion: v1 +kind: Pod +metadata: + namespace: default + name: test-application +` diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 0cb31be43..efbc72fef 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -188,6 +188,25 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err)) } + // Supersede all previous deployments, see issue #12556 (which is a + // variation on #2941). + deployed, err := u.cfg.Releases.DeployedAll(name) + if err != nil && !errors.Is(err, driver.ErrNoDeployedReleases) { + return nil, err + } + for _, reli := range deployed { + rel, err := releaserToV1Release(reli) + if err != nil { + return nil, err + } + + u.cfg.Logger().Debug("superseding previous deployment", "version", rel.Version) + rel.Info.Status = common.StatusSuperseded + if err := u.cfg.Releases.Update(rel); err != nil { + u.cfg.Logger().Debug("uninstall: Failed to store updated release", slog.Any("error", err)) + } + } + if len(errs) > 0 { return res, fmt.Errorf("uninstallation completed with %d error(s): %w", len(errs), joinErrors(errs, "; ")) } @@ -242,9 +261,9 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri } filesToKeep, filesToDelete := filterManifestsToKeep(files) - var kept string + var kept strings.Builder for _, f := range filesToKeep { - kept += "[" + f.Head.Kind + "] " + f.Head.Metadata.Name + "\n" + fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) } var builder strings.Builder @@ -259,7 +278,7 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri if len(resources) > 0 { _, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation)) } - return resources, kept, errs + return resources, kept.String(), errs } func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index fba1e391f..6c4ad977f 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -82,7 +82,7 @@ func TestUninstallRelease_deleteRelease(t *testing.T) { "password": "password" } }` - unAction.cfg.Releases.Create(rel) + require.NoError(t, unAction.cfg.Releases.Create(rel)) res, err := unAction.Run(rel.Name) is.NoError(err) expected := `These resources were kept due to the resource policy: @@ -112,7 +112,7 @@ func TestUninstallRelease_Wait(t *testing.T) { "password": "password" } }` - unAction.cfg.Releases.Create(rel) + require.NoError(t, unAction.cfg.Releases.Create(rel)) failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitForDeleteError = fmt.Errorf("U timed out") unAction.cfg.KubeClient = failer @@ -146,7 +146,7 @@ func TestUninstallRelease_Cascade(t *testing.T) { "password": "password" } }` - unAction.cfg.Releases.Create(rel) + require.NoError(t, unAction.cfg.Releases.Create(rel)) failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.DeleteError = fmt.Errorf("Uninstall with cascade failed") failer.BuildDummy = true diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 57a4a0272..13d28fd4d 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -515,7 +515,11 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele 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) - u.cfg.Logger().Warn("upgrade failed", "name", rel.Name, slog.Any("error", err)) + u.cfg.Logger().Warn( + "upgrade failed", + slog.String("name", rel.Name), + slog.Any("error", err), + ) rel.Info.Status = rcommon.StatusFailed rel.Info.Description = msg diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index e1eac3f9f..17c4927cc 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -25,16 +25,19 @@ import ( "testing" "time" - chart "helm.sh/helm/v4/pkg/chart/v2" - "helm.sh/helm/v4/pkg/kube" - "helm.sh/helm/v4/pkg/storage/driver" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" + chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" + "helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" + "helm.sh/helm/v4/pkg/storage/driver" ) func upgradeAction(t *testing.T) *Upgrade { @@ -85,7 +88,7 @@ func TestUpgradeRelease_Wait(t *testing.T) { rel := releaseStub() rel.Name = "come-fail-away" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitError = fmt.Errorf("I timed out") @@ -109,7 +112,7 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) { rel := releaseStub() rel.Name = "come-fail-away" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitError = fmt.Errorf("I timed out") @@ -134,7 +137,7 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) { rel := releaseStub() rel.Name = "come-fail-away" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitError = fmt.Errorf("I timed out") @@ -163,7 +166,7 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { rel := releaseStub() rel.Name = "nuketown" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) // We can't make Update error because then the rollback won't work @@ -193,7 +196,7 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { rel := releaseStub() rel.Name = "fallout" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.UpdateError = fmt.Errorf("update fail") @@ -401,12 +404,12 @@ func TestUpgradeRelease_Pending(t *testing.T) { rel := releaseStub() rel.Name = "come-fail-away" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) rel2 := releaseStub() rel2.Name = "come-fail-away" rel2.Info.Status = common.StatusPendingUpgrade rel2.Version = 2 - upAction.cfg.Releases.Create(rel2) + require.NoError(t, upAction.cfg.Releases.Create(rel2)) vals := map[string]interface{}{} @@ -422,7 +425,7 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { rel := releaseStub() rel.Name = "interrupted-release" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitDuration = 10 * time.Second @@ -451,7 +454,7 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { rel := releaseStub() rel.Name = "interrupted-release" rel.Info.Status = common.StatusDeployed - upAction.cfg.Releases.Create(rel) + require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) failer.WaitDuration = 5 * time.Second @@ -755,3 +758,20 @@ func TestUpgradeRun_UnreachableKubeClient(t *testing.T) { assert.Nil(t, result) assert.ErrorContains(t, err, "connection refused") } + +func TestUpgradeSetRegistryClient(t *testing.T) { + config := actionConfigFixture(t) + client := NewUpgrade(config) + + registryClient := ®istry.Client{} + client.SetRegistryClient(registryClient) + assert.Equal(t, registryClient, client.registryClient) +} + +func TestObjectKey(t *testing.T) { + obj := &appsv1.Deployment{} + obj.SetGroupVersionKind(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}) + info := resource.Info{Name: "name", Namespace: "namespace", Object: obj} + + assert.Equal(t, "apps/v1/Deployment/namespace/name", objectKey(&info)) +} diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 761ccba47..1bef5a742 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -55,7 +55,8 @@ func requireAdoption(resources kube.ResourceList) (kube.ResourceList, error) { return fmt.Errorf("could not get information about the resource %s: %w", resourceString(info), err) } - requireUpdate.Append(info) + infoCopy := *info + requireUpdate.Append(&infoCopy) return nil }) @@ -84,7 +85,8 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return fmt.Errorf("%s exists and cannot be imported into the current release: %s", resourceString(info), err) } - requireUpdate.Append(info) + infoCopy := *info + requireUpdate.Append(&infoCopy) return nil }) diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go index 3efecd6ff..879a5fa4f 100644 --- a/pkg/action/validate_test.go +++ b/pkg/action/validate_test.go @@ -132,6 +132,7 @@ func TestRequireAdoption(t *testing.T) { assert.NoError(t, err) assert.Len(t, found, 1) assert.Equal(t, found[0], existing) + assert.NotSame(t, found[0], existing) } func TestExistingResourceConflict(t *testing.T) { @@ -156,6 +157,7 @@ func TestExistingResourceConflict(t *testing.T) { assert.NoError(t, err) assert.Len(t, found, 1) assert.Equal(t, found[0], existing) + assert.NotSame(t, found[0], existing) // Verify that an existing resource that lacks labels/annotations results in an error resources = append(resources, conflict) diff --git a/pkg/action/verify_test.go b/pkg/action/verify_test.go new file mode 100644 index 000000000..343dacaef --- /dev/null +++ b/pkg/action/verify_test.go @@ -0,0 +1,48 @@ +/* +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 action + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewVerify(t *testing.T) { + client := NewVerify() + + assert.NotNil(t, client) +} + +func TestVerifyRun(t *testing.T) { + client := NewVerify() + + client.Keyring = "../downloader/testdata/helm-test-key.pub" + output, err := client.Run("../downloader/testdata/signtest-0.1.0.tgz") + assert.Contains(t, output, "Signed by:") + assert.Contains(t, output, "Using Key With Fingerprint:") + assert.Contains(t, output, "Chart Hash Verified:") + require.NoError(t, err) +} + +func TestVerifyRun_DownloadError(t *testing.T) { + client := NewVerify() + output, err := client.Run("invalid-chart-path") + require.Error(t, err) + assert.Empty(t, output) +} diff --git a/pkg/chart/common.go b/pkg/chart/common.go index 8080f3dc8..cd87e91e7 100644 --- a/pkg/chart/common.go +++ b/pkg/chart/common.go @@ -186,7 +186,7 @@ func structToMap(obj interface{}) (map[string]interface{}, error) { objValue := reflect.ValueOf(obj) // If the value is a pointer, dereference it - if objValue.Kind() == reflect.Ptr { + if objValue.Kind() == reflect.Pointer { objValue = objValue.Elem() } @@ -209,7 +209,7 @@ func structToMap(obj interface{}) (map[string]interface{}, error) { return nil, err } result[field.Name] = nestedMap - case reflect.Ptr: + case reflect.Pointer: // Recurse for pointers by dereferencing if value.IsNil() { result[field.Name] = nil @@ -224,7 +224,7 @@ func structToMap(obj interface{}) (map[string]interface{}, error) { sliceOfMaps := make([]interface{}, value.Len()) for j := 0; j < value.Len(); j++ { sliceElement := value.Index(j) - if sliceElement.Kind() == reflect.Struct || sliceElement.Kind() == reflect.Ptr { + if sliceElement.Kind() == reflect.Struct || sliceElement.Kind() == reflect.Pointer { nestedMap, err := structToMap(sliceElement.Interface()) if err != nil { return nil, err diff --git a/pkg/chart/common/capabilities.go b/pkg/chart/common/capabilities.go index 355c3978a..18d00de90 100644 --- a/pkg/chart/common/capabilities.go +++ b/pkg/chart/common/capabilities.go @@ -19,7 +19,10 @@ import ( "fmt" "slices" "strconv" + "strings" + "testing" + "github.com/Masterminds/semver/v3" "k8s.io/client-go/kubernetes/scheme" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -29,25 +32,23 @@ import ( helmversion "helm.sh/helm/v4/internal/version" ) -var ( - // The Kubernetes version can be set by LDFLAGS. In order to do that the value - // must be a string. - k8sVersionMajor = "1" - k8sVersionMinor = "20" +const ( + kubeVersionMajorTesting = 1 + kubeVersionMinorTesting = 20 +) +var ( // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). DefaultVersionSet = allKnownVersions() - // DefaultCapabilities is the default set of capabilities. - DefaultCapabilities = &Capabilities{ - KubeVersion: KubeVersion{ - Version: fmt.Sprintf("v%s.%s.0", k8sVersionMajor, k8sVersionMinor), - Major: k8sVersionMajor, - Minor: k8sVersionMinor, - }, - APIVersions: DefaultVersionSet, - HelmVersion: helmversion.Get(), - } + DefaultCapabilities = func() *Capabilities { + caps, err := makeDefaultCapabilities() + if err != nil { + panic(fmt.Sprintf("failed to create default capabilities: %v", err)) + } + return caps + + }() ) // Capabilities describes the capabilities of the Kubernetes cluster. @@ -70,15 +71,22 @@ func (capabilities *Capabilities) Copy() *Capabilities { // KubeVersion is the Kubernetes version. type KubeVersion struct { - Version string // Kubernetes version - Major string // Kubernetes major version - Minor string // Kubernetes minor version + Version string // Full version (e.g., v1.33.4-gke.1245000) + normalizedVersion string // Normalized for constraint checking (e.g., v1.33.4) + Major string // Kubernetes major version + Minor string // Kubernetes minor version } -// String implements fmt.Stringer -func (kv *KubeVersion) String() string { return kv.Version } +// String implements fmt.Stringer. +// Returns the normalized version used for constraint checking. +func (kv *KubeVersion) String() string { + if kv.normalizedVersion != "" { + return kv.normalizedVersion + } + return kv.Version +} -// GitVersion returns the Kubernetes version string. +// GitVersion returns the full Kubernetes version string. // // Deprecated: use KubeVersion.Version. func (kv *KubeVersion) GitVersion() string { return kv.Version } @@ -91,10 +99,21 @@ func ParseKubeVersion(version string) (*KubeVersion, error) { if err != nil { return nil, err } + + // Preserve original input (e.g., v1.33.4-gke.1245000) + gitVersion := version + if !strings.HasPrefix(version, "v") { + gitVersion = "v" + version + } + + // Normalize for constraint checking (strips all suffixes) + normalizedVer := "v" + sv.String() + return &KubeVersion{ - Version: "v" + sv.String(), - Major: strconv.FormatUint(uint64(sv.Major()), 10), - Minor: strconv.FormatUint(uint64(sv.Minor()), 10), + Version: gitVersion, + normalizedVersion: normalizedVer, + Major: strconv.FormatUint(uint64(sv.Major()), 10), + Minor: strconv.FormatUint(uint64(sv.Minor()), 10), }, nil } @@ -122,3 +141,42 @@ func allKnownVersions() VersionSet { } return vs } + +func makeDefaultCapabilities() (*Capabilities, error) { + // Test builds don't include debug info / module info + // (And even if they did, we probably want stable capabilities for tests anyway) + // Return a default value for test builds + if testing.Testing() { + return newCapabilities(kubeVersionMajorTesting, kubeVersionMinorTesting) + } + + vstr, err := helmversion.K8sIOClientGoModVersion() + if err != nil { + return nil, fmt.Errorf("failed to retrieve k8s.io/client-go version: %w", err) + } + + v, err := semver.NewVersion(vstr) + if err != nil { + return nil, fmt.Errorf("unable to parse k8s.io/client-go version %q: %v", vstr, err) + } + + kubeVersionMajor := v.Major() + 1 + kubeVersionMinor := v.Minor() + + return newCapabilities(kubeVersionMajor, kubeVersionMinor) +} + +func newCapabilities(kubeVersionMajor, kubeVersionMinor uint64) (*Capabilities, error) { + + version := fmt.Sprintf("v%d.%d.0", kubeVersionMajor, kubeVersionMinor) + return &Capabilities{ + KubeVersion: KubeVersion{ + Version: version, + normalizedVersion: version, + Major: fmt.Sprintf("%d", kubeVersionMajor), + Minor: fmt.Sprintf("%d", kubeVersionMinor), + }, + APIVersions: DefaultVersionSet, + HelmVersion: helmversion.Get(), + }, nil +} diff --git a/pkg/chart/common/capabilities_test.go b/pkg/chart/common/capabilities_test.go index bf32b1f3f..b96d7d29b 100644 --- a/pkg/chart/common/capabilities_test.go +++ b/pkg/chart/common/capabilities_test.go @@ -41,7 +41,8 @@ func TestDefaultVersionSet(t *testing.T) { } func TestDefaultCapabilities(t *testing.T) { - kv := DefaultCapabilities.KubeVersion + caps := DefaultCapabilities + kv := caps.KubeVersion if kv.String() != "v1.20.0" { t.Errorf("Expected default KubeVersion.String() to be v1.20.0, got %q", kv.String()) } @@ -57,13 +58,10 @@ func TestDefaultCapabilities(t *testing.T) { if kv.Minor != "20" { t.Errorf("Expected default KubeVersion.Minor to be 20, got %q", kv.Minor) } -} - -func TestDefaultCapabilitiesHelmVersion(t *testing.T) { - hv := DefaultCapabilities.HelmVersion - if hv.Version != "v4.0" { - t.Errorf("Expected default HelmVersion to be v4.0, got %q", hv.Version) + hv := caps.HelmVersion + if hv.Version != "v4.1" { + t.Errorf("Expected default HelmVersion to be v4.1, got %q", hv.Version) } } @@ -83,18 +81,41 @@ func TestParseKubeVersion(t *testing.T) { } } -func TestParseKubeVersionSuffix(t *testing.T) { - kv, err := ParseKubeVersion("v1.28+") - if err != nil { - t.Errorf("Expected v1.28+ to parse successfully") - } - if kv.Version != "v1.28" { - t.Errorf("Expected parsed KubeVersion.Version to be v1.28, got %q", kv.String()) +func TestParseKubeVersionWithVendorSuffixes(t *testing.T) { + tests := []struct { + name string + input string + wantVer string + wantString string + wantMajor string + wantMinor string + }{ + {"GKE vendor suffix", "v1.33.4-gke.1245000", "v1.33.4-gke.1245000", "v1.33.4", "1", "33"}, + {"GKE without v", "1.30.2-gke.1587003", "v1.30.2-gke.1587003", "v1.30.2", "1", "30"}, + {"EKS trailing +", "v1.28+", "v1.28+", "v1.28", "1", "28"}, + {"EKS + without v", "1.28+", "v1.28+", "v1.28", "1", "28"}, + {"Standard version", "v1.31.0", "v1.31.0", "v1.31.0", "1", "31"}, + {"Standard without v", "1.29.0", "v1.29.0", "v1.29.0", "1", "29"}, } - if kv.Major != "1" { - t.Errorf("Expected parsed KubeVersion.Major to be 1, got %q", kv.Major) - } - if kv.Minor != "28" { - t.Errorf("Expected parsed KubeVersion.Minor to be 28, got %q", kv.Minor) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kv, err := ParseKubeVersion(tt.input) + if err != nil { + t.Fatalf("ParseKubeVersion() error = %v", err) + } + if kv.Version != tt.wantVer { + t.Errorf("Version = %q, want %q", kv.Version, tt.wantVer) + } + if kv.String() != tt.wantString { + t.Errorf("String() = %q, want %q", kv.String(), tt.wantString) + } + if kv.Major != tt.wantMajor { + t.Errorf("Major = %q, want %q", kv.Major, tt.wantMajor) + } + if kv.Minor != tt.wantMinor { + t.Errorf("Minor = %q, want %q", kv.Minor, tt.wantMinor) + } + }) } } diff --git a/pkg/chart/common/util/coalesce.go b/pkg/chart/common/util/coalesce.go index 5bfa1c608..07794a04a 100644 --- a/pkg/chart/common/util/coalesce.go +++ b/pkg/chart/common/util/coalesce.go @@ -21,8 +21,7 @@ import ( "log" "maps" - "github.com/mitchellh/copystructure" - + "helm.sh/helm/v4/internal/copystructure" chart "helm.sh/helm/v4/pkg/chart" "helm.sh/helm/v4/pkg/chart/common" ) diff --git a/pkg/chart/common/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go index 649bbecac..6d7f32604 100644 --- a/pkg/chart/common/util/jsonschema.go +++ b/pkg/chart/common/util/jsonschema.go @@ -87,7 +87,7 @@ func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) erro sb.WriteString(err.Error()) } } - slog.Debug("number of dependencies in the chart", "dependencies", len(chrt.Dependencies())) + slog.Debug("number of dependencies in the chart", "chart", chrt.Name(), "dependencies", len(chrt.Dependencies())) // For each dependency, recursively call this function with the coalesced values for _, subchart := range chrt.Dependencies() { sub, err := chart.NewAccessor(subchart) diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index c6875db3f..e98f5c333 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -68,7 +68,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { for { b := bytes.NewBuffer(nil) hd, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/pkg/chart/loader/load.go b/pkg/chart/loader/load.go index 7a5ddbca9..3fd381825 100644 --- a/pkg/chart/loader/load.go +++ b/pkg/chart/loader/load.go @@ -20,6 +20,7 @@ import ( "compress/gzip" "errors" "fmt" + "io" "os" "path/filepath" @@ -130,8 +131,8 @@ func LoadFile(name string) (chart.Charter, error) { 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) + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) } return nil, errors.New("unable to load chart archive") } @@ -156,6 +157,38 @@ func LoadFile(name string) (chart.Charter, error) { return nil, errors.New("unable to detect chart version, no Chart.yaml found") } +// LoadArchive loads from a reader containing a compressed tar archive. +func LoadArchive(in io.Reader) (chart.Charter, error) { + // Note: This function is for use by SDK users such as Flux. + + files, err := archive.LoadArchiveFiles(in) + if err != nil { + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("stream does not appear to be a valid chart file (details: %w)", err) + } + return nil, fmt.Errorf("unable to load chart archive: %w", err) + } + + 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.LoadFiles(files) + case c3.APIVersionV3: + return c3load.LoadFiles(files) + 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 { diff --git a/pkg/chart/loader/load_test.go b/pkg/chart/loader/load_test.go new file mode 100644 index 000000000..40f46c09b --- /dev/null +++ b/pkg/chart/loader/load_test.go @@ -0,0 +1,186 @@ +/* +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" + "fmt" + "io" + "maps" + "path/filepath" + "strings" + "testing" + "time" + + c3 "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/chart" + c2 "helm.sh/helm/v4/pkg/chart/v2" +) + +// createChartArchive is a helper function to create a gzipped tar archive in memory +func createChartArchive(t *testing.T, chartName, apiVersion string, extraFiles map[string][]byte, createChartYaml bool) io.Reader { + t.Helper() + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + files := make(map[string][]byte) + maps.Copy(files, extraFiles) + + if createChartYaml { + chartYAMLContent := fmt.Sprintf(`apiVersion: %s +name: %s +version: 0.1.0 +description: A test chart +`, apiVersion, chartName) + files["Chart.yaml"] = []byte(chartYAMLContent) + } + + for name, data := range files { + header := &tar.Header{ + Name: filepath.Join(chartName, name), + Mode: 0644, + Size: int64(len(data)), + ModTime: time.Now(), + } + if err := tw.WriteHeader(header); err != nil { + t.Fatalf("Failed to write tar header for %s: %v", name, err) + } + if _, err := tw.Write(data); err != nil { + t.Fatalf("Failed to write tar data for %s: %v", name, err) + } + } + + if err := tw.Close(); err != nil { + t.Fatalf("Failed to close tar writer: %v", err) + } + if err := gw.Close(); err != nil { + t.Fatalf("Failed to close gzip writer: %v", err) + } + return &buf +} + +func TestLoadArchive(t *testing.T) { + testCases := []struct { + name string + chartName string + apiVersion string + extraFiles map[string][]byte + inputReader io.Reader + expectedChart chart.Charter + expectedError string + createChartYaml bool + }{ + { + name: "valid v2 chart archive", + chartName: "mychart-v2", + apiVersion: c2.APIVersionV2, + extraFiles: map[string][]byte{"templates/config.yaml": []byte("key: value")}, + expectedChart: &c2.Chart{ + Metadata: &c2.Metadata{APIVersion: c2.APIVersionV2, Name: "mychart-v2", Version: "0.1.0", Description: "A test chart"}, + }, + createChartYaml: true, + }, + { + name: "valid v3 chart archive", + chartName: "mychart-v3", + apiVersion: c3.APIVersionV3, + extraFiles: map[string][]byte{"templates/config.yaml": []byte("key: value")}, + expectedChart: &c3.Chart{ + Metadata: &c3.Metadata{APIVersion: c3.APIVersionV3, Name: "mychart-v3", Version: "0.1.0", Description: "A test chart"}, + }, + createChartYaml: true, + }, + { + name: "invalid gzip header", + inputReader: bytes.NewBufferString("not a gzip file"), + expectedError: "stream does not appear to be a valid chart file (details: gzip: invalid header)", + }, + { + name: "archive without Chart.yaml", + chartName: "no-chart-yaml", + apiVersion: c2.APIVersionV2, // This will be ignored as Chart.yaml is missing + extraFiles: map[string][]byte{"values.yaml": []byte("foo: bar")}, + expectedError: "unable to detect chart version, no Chart.yaml found", + createChartYaml: false, + }, + { + name: "archive with malformed Chart.yaml", + chartName: "malformed-chart-yaml", + apiVersion: c2.APIVersionV2, + extraFiles: map[string][]byte{"Chart.yaml": []byte("apiVersion: v2\nname: mychart\nversion: 0.1.0\ndescription: A test chart\ninvalid: :")}, + expectedError: "cannot load Chart.yaml: error converting YAML to JSON: yaml: line 5: mapping values are not allowed in this context", + createChartYaml: false, + }, + { + name: "unsupported API version", + chartName: "unsupported-api", + apiVersion: "v99", + expectedError: "unsupported chart version", + createChartYaml: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var reader io.Reader + if tc.inputReader != nil { + reader = tc.inputReader + } else { + reader = createChartArchive(t, tc.chartName, tc.apiVersion, tc.extraFiles, tc.createChartYaml) + } + + loadedChart, err := LoadArchive(reader) + + if tc.expectedError != "" { + if err == nil || !strings.Contains(err.Error(), tc.expectedError) { + t.Errorf("Expected error containing %q, but got %v", tc.expectedError, err) + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + lac, err := chart.NewAccessor(loadedChart) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + eac, err := chart.NewAccessor(tc.expectedChart) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if lac.Name() != eac.Name() { + t.Errorf("Expected chart name %q, got %q", eac.Name(), lac.Name()) + } + + var loadedAPIVersion string + switch lc := loadedChart.(type) { + case *c2.Chart: + loadedAPIVersion = lc.Metadata.APIVersion + case *c3.Chart: + loadedAPIVersion = lc.Metadata.APIVersion + } + if loadedAPIVersion != tc.apiVersion { + t.Errorf("Expected API version %q, got %q", tc.apiVersion, loadedAPIVersion) + } + }) + } +} diff --git a/pkg/chart/v2/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go index 6f8f137f4..80dcef932 100644 --- a/pkg/chart/v2/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -179,16 +179,6 @@ func TestHelmCreateChart(t *testing.T) { // // Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent // of the `--set` flag. -// -// Note: This test requires the following ldflags to be set per the current Kubernetes version to avoid false-positive -// results. -// 1. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor= -// 2. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor= -// or directly use '$(LDFLAGS)' in Makefile. -// -// When run without ldflags, the test passes giving a false-positive result. This is because the variables -// `k8sVersionMajor` and `k8sVersionMinor` by default are set to an older version of Kubernetes, with which, there -// might not be the deprecation warning. func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir()) if err != nil { diff --git a/pkg/chart/v2/lint/rules/crds.go b/pkg/chart/v2/lint/rules/crds.go index 49e30192a..4bb4d370b 100644 --- a/pkg/chart/v2/lint/rules/crds.go +++ b/pkg/chart/v2/lint/rules/crds.go @@ -70,7 +70,7 @@ func Crds(linter *support.Linter) { var yamlStruct *k8sYamlStruct err := decoder.Decode(&yamlStruct) - if err == io.EOF { + if errors.Is(err, io.EOF) { break } @@ -80,8 +80,10 @@ func Crds(linter *support.Linter) { return } - linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) - linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + if yamlStruct != nil { + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + } } } } diff --git a/pkg/chart/v2/lint/rules/crds_test.go b/pkg/chart/v2/lint/rules/crds_test.go index e644f182f..228f40a66 100644 --- a/pkg/chart/v2/lint/rules/crds_test.go +++ b/pkg/chart/v2/lint/rules/crds_test.go @@ -17,6 +17,8 @@ limitations under the License. package rules import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -34,3 +36,31 @@ func TestInvalidCrdsDir(t *testing.T) { assert.Len(t, res, 1) assert.ErrorContains(t, res[0].Err, "not a directory") } + +// multi-document YAML with empty documents would panic +func TestCrdWithEmptyDocument(t *testing.T) { + chartDir := t.TempDir() + + os.WriteFile(filepath.Join(chartDir, "Chart.yaml"), []byte( + `apiVersion: v1 +name: test +version: 0.1.0 +`), 0644) + + // CRD with comments before --- (creates empty document) + crdsDir := filepath.Join(chartDir, "crds") + os.Mkdir(crdsDir, 0755) + os.WriteFile(filepath.Join(crdsDir, "test.yaml"), []byte( + `# Comments create empty document +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test.example.io +`), 0644) + + linter := support.Linter{ChartDir: chartDir} + Crds(&linter) + + assert.Len(t, linter.Messages, 0) +} diff --git a/pkg/chart/v2/lint/rules/deprecations.go b/pkg/chart/v2/lint/rules/deprecations.go index 6eba316bc..7d5245869 100644 --- a/pkg/chart/v2/lint/rules/deprecations.go +++ b/pkg/chart/v2/lint/rules/deprecations.go @@ -28,14 +28,6 @@ import ( kscheme "k8s.io/client-go/kubernetes/scheme" ) -var ( - // This should be set in the Makefile based on the version of client-go being imported. - // These constants will be overwritten with LDFLAGS. The version components must be - // strings in order for LDFLAGS to set them. - k8sVersionMajor = "1" - k8sVersionMinor = "20" -) - // deprecatedAPIError indicates than an API is deprecated in Kubernetes type deprecatedAPIError struct { Deprecated string @@ -56,12 +48,8 @@ func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVer return nil } - majorVersion := k8sVersionMajor - minorVersion := k8sVersionMinor - - if kubeVersion != nil { - majorVersion = kubeVersion.Major - minorVersion = kubeVersion.Minor + if kubeVersion == nil { + kubeVersion = &common.DefaultCapabilities.KubeVersion } runtimeObject, err := resourceToRuntimeObject(resource) @@ -73,16 +61,16 @@ func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVer return err } - major, err := strconv.Atoi(majorVersion) + kubeVersionMajor, err := strconv.Atoi(kubeVersion.Major) if err != nil { return err } - minor, err := strconv.Atoi(minorVersion) + kubeVersionMinor, err := strconv.Atoi(kubeVersion.Minor) if err != nil { return err } - if !deprecation.IsDeprecated(runtimeObject, major, minor) { + if !deprecation.IsDeprecated(runtimeObject, kubeVersionMajor, kubeVersionMinor) { return nil } gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) diff --git a/pkg/chart/v2/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go index 0c633dc1a..43665aa3a 100644 --- a/pkg/chart/v2/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -180,7 +180,7 @@ func (t *templateLinter) Lint() { var yamlStruct *k8sYamlStruct err := decoder.Decode(&yamlStruct) - if err == io.EOF { + if errors.Is(err, io.EOF) { break } diff --git a/pkg/chart/v2/loader/archive.go b/pkg/chart/v2/loader/archive.go index f6ed0e84f..c6885e125 100644 --- a/pkg/chart/v2/loader/archive.go +++ b/pkg/chart/v2/loader/archive.go @@ -56,8 +56,8 @@ func LoadFile(name string) (*chart.Chart, error) { c, err := LoadArchive(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) + if errors.Is(err, gzip.ErrHeader) { + return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) } } return c, err diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index ba3a9b6bc..d466e247c 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -208,7 +208,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { // LoadValues loads values from a reader. // // The reader is expected to contain one or more YAML documents, the values of which are merged. -// And the values can be either a chart's default values or a user-supplied values. +// And the values can be either a chart's default values or user-supplied values. func LoadValues(data io.Reader) (map[string]interface{}, error) { values := map[string]interface{}{} reader := utilyaml.NewYAMLReader(bufio.NewReader(data)) @@ -216,7 +216,7 @@ func LoadValues(data io.Reader) (map[string]interface{}, error) { currentMap := map[string]interface{}{} raw, err := reader.Read() if err != nil { - if err == io.EOF { + if errors.Is(err, io.EOF) { break } return nil, fmt.Errorf("error reading yaml document: %w", err) diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index ee0be5b18..397745dd6 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -20,6 +20,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "errors" "io" "log" "os" @@ -116,7 +117,7 @@ func TestBomTestData(t *testing.T) { tr := tar.NewReader(unzipped) for { file, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index a52f09f82..c7bb6621e 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -20,8 +20,7 @@ import ( "log/slog" "strings" - "github.com/mitchellh/copystructure" - + "helm.sh/helm/v4/internal/copystructure" "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/chart/common/util" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -281,7 +280,11 @@ func processImportValues(c *chart.Chart, merge bool) error { // get child table vv, err := cvals.Table(r.Name + "." + child) if err != nil { - slog.Warn("ImportValues missing table from chart", "chart", r.Name, slog.Any("error", err)) + slog.Warn( + "ImportValues missing table from chart", + slog.String("chart", r.Name), + slog.Any("error", err), + ) continue } // create value map from child to be merged into parent diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index e317d1c09..6d4e2c8cd 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -21,6 +21,7 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "errors" "fmt" "io" "os" @@ -205,7 +206,7 @@ func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) { headers := []*tar.Header{} for { hd, err := tr.Next() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 106d24336..60cce51a9 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -99,6 +99,7 @@ func New() *EnvSettings { env := &EnvSettings{ namespace: os.Getenv("HELM_NAMESPACE"), MaxHistory: envIntOr("HELM_MAX_HISTORY", defaultMaxHistory), + KubeConfig: os.Getenv("KUBECONFIG"), KubeContext: os.Getenv("HELM_KUBECONTEXT"), KubeToken: os.Getenv("HELM_KUBETOKEN"), KubeAsUser: os.Getenv("HELM_KUBEASUSER"), diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 5836a6af9..251bfa032 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -59,7 +59,7 @@ func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) { cmd.Flags().Var( newWaitValue(kube.HookOnlyStrategy, wait), "wait", - "if specified, will wait until all resources are in the expected state before marking the operation as successful. It will wait for as long as --timeout. Valid inputs are 'watcher' and 'legacy'", + "if specified, wait until resources are ready (up to --timeout). Values: 'watcher', 'hookOnly', and 'legacy'.", ) // Sets the strategy to use the watcher strategy if `--wait` is used without an argument cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy) @@ -81,7 +81,7 @@ func (ws *waitValue) String() string { func (ws *waitValue) Set(s string) error { switch s { - case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy): + case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy), string(kube.HookOnlyStrategy): *ws = waitValue(s) return nil case "true": @@ -89,11 +89,11 @@ func (ws *waitValue) Set(s string) error { *ws = waitValue(kube.StatusWatcherStrategy) return nil case "false": - slog.Warn("--wait=false is deprecated (boolean value) and can be replaced by omitting the --wait flag") + slog.Warn("--wait=false is deprecated (boolean value) and can be replaced with --wait=hookOnly") *ws = waitValue(kube.HookOnlyStrategy) return nil default: - return fmt.Errorf("invalid wait input %q. Valid inputs are %s, and %s", s, kube.StatusWatcherStrategy, kube.LegacyStrategy) + return fmt.Errorf("invalid wait input %q. Valid inputs are %s, %s, and %s", s, kube.StatusWatcherStrategy, kube.HookOnlyStrategy, kube.LegacyStrategy) } } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index e5d311394..d36cd9e34 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -275,7 +275,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options slog.Warn("this chart is deprecated") } - if req := ac.MetaDependencies(); req != nil { + if req := ac.MetaDependencies(); len(req) > 0 { // If CheckDependencies returns an error, we have unfulfilled dependencies. // As of Helm 2.4.0, this is treated as a stopping condition: // https://github.com/helm/helm/issues/2209 diff --git a/pkg/cmd/lint_test.go b/pkg/cmd/lint_test.go index 270273116..f825e36e2 100644 --- a/pkg/cmd/lint_test.go +++ b/pkg/cmd/lint_test.go @@ -76,7 +76,7 @@ func TestLintCmdWithKubeVersionFlag(t *testing.T) { golden: "output/lint-chart-with-deprecated-api-strict.txt", wantError: true, }, { - // the test builds will use the default k8sVersionMinor const in deprecations.go and capabilities.go + // the test builds will use the kubeVersionMinorTesting const in capabilities.go // which is "20" name: "lint chart with deprecated api version without kube version", cmd: fmt.Sprintf("lint %s", testChart), diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index 534113bde..ddda5fde6 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -20,7 +20,7 @@ import ( "context" "fmt" "io" - "log" + "log/slog" "os" "path/filepath" "slices" @@ -63,7 +63,7 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { } found, err := plugin.FindPlugins(dirs, descriptor) if err != nil { - fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err) + slog.Error("failed to load plugins", slog.String("error", err.Error())) return } @@ -217,9 +217,7 @@ func loadCompletionForPlugin(pluginCmd *cobra.Command, plug plugin.Plugin) { if err != nil { // The file could be missing or invalid. No static completion for this plugin. - if settings.Debug { - log.Output(2, fmt.Sprintf("[info] %s\n", err.Error())) - } + slog.Debug("plugin completion file loading", slog.String("error", err.Error())) // Continue to setup dynamic completion. cmds = &pluginCommand{} } @@ -238,10 +236,7 @@ func addPluginCommands(plug plugin.Plugin, baseCmd *cobra.Command, cmds *pluginC } if len(cmds.Name) == 0 { - // Missing name for a command - if settings.Debug { - log.Output(2, fmt.Sprintf("[info] sub-command name field missing for %s", baseCmd.CommandPath())) - } + slog.Debug("sub-command name field missing", slog.String("commandPath", baseCmd.CommandPath())) return } diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 36a7dff73..96c0c47b2 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -81,7 +81,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { return fmt.Errorf("missing registry client: %w", err) } - for i := 0; i < len(args); i++ { + for i := range args { path, err := filepath.Abs(args[i]) if err != nil { return err diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index a45c4a3de..efa9b466c 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -119,8 +119,6 @@ func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, err } func (o *pluginInstallOptions) run(out io.Writer) error { - installer.Debug = settings.Debug - i, err := o.newInstallerForSource() if err != nil { return err diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index f7a418569..05cfe46f1 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -199,7 +199,7 @@ func TestLoadPluginsWithSpace(t *testing.T) { t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins)) } - for i := 0; i < len(plugins); i++ { + for i := range plugins { out.Reset() tt := tests[i] pp := plugins[i] diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index c6d4b8530..6cc2729fc 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -61,7 +61,6 @@ func (o *pluginUpdateOptions) complete(args []string) error { } func (o *pluginUpdateOptions) run(out io.Writer) error { - installer.Debug = settings.Debug slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) plugins, err := plugin.LoadAll(settings.PluginsDirectory) if err != nil { diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index 922698c6c..bb7a8d1c0 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -72,7 +72,7 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } client.SetRegistryClient(registryClient) - for i := 0; i < len(args); i++ { + for i := range args { output, err := client.Run(args[i]) if err != nil { return err diff --git a/pkg/cmd/pull_test.go b/pkg/cmd/pull_test.go index c24bf33b7..96631fe05 100644 --- a/pkg/cmd/pull_test.go +++ b/pkg/cmd/pull_test.go @@ -106,16 +106,16 @@ func TestPullCmd(t *testing.T) { { name: "Fetch untar when file with same name existed", args: "test/test1 --untar --untardir test1", - existFile: "test1", + existFile: "test1/test1", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test1")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test1", "test1")), }, { name: "Fetch untar when dir with same name existed", - args: "test/test2 --untar --untardir test2", - existDir: "test2", + args: "test/test --untar --untardir test2", + existDir: "test2/test", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test2")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "test2", "test")), }, { name: "Fetch, verify, untar", @@ -178,9 +178,10 @@ func TestPullCmd(t *testing.T) { }, { name: "OCI Fetch untar when dir with same name existed", - args: fmt.Sprintf("oci-test-chart oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2 --untar --untardir ocitest2", ociSrv.RegistryURL), + args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0 --untar --untardir ocitest2", ociSrv.RegistryURL), + existDir: "ocitest2/oci-dependent-chart", wantError: true, - wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "ocitest2")), + wantErrorMsg: fmt.Sprintf("failed to untar: a file or directory with the name %s already exists", filepath.Join(srv.Root(), "ocitest2", "oci-dependent-chart")), }, { name: "Fail fetching non-existent OCI chart", @@ -189,10 +190,9 @@ func TestPullCmd(t *testing.T) { wantError: true, }, { - name: "Fail fetching OCI chart without version specified", - args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing", ociSrv.RegistryURL), - wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", - wantError: true, + name: "Fail fetching OCI chart without version specified", + args: fmt.Sprintf("oci://%s/u/ocitestuser/nosuchthing", ociSrv.RegistryURL), + wantError: true, }, { name: "Fetching OCI chart without version option specified", @@ -207,7 +207,7 @@ func TestPullCmd(t *testing.T) { { name: "Fail fetching OCI chart with version mismatch", args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.2.0 --version 0.1.0", ociSrv.RegistryURL), - wantErrorMsg: "Error: chart reference and version mismatch: 0.2.0 is not 0.1.0", + wantErrorMsg: "chart reference and version mismatch: 0.1.0 is not 0.2.0", wantError: true, }, } @@ -228,6 +228,9 @@ func TestPullCmd(t *testing.T) { // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 if tt.existFile != "" { file := filepath.Join(outdir, tt.existFile) + if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil { + t.Fatal(err) + } _, err := os.Create(file) if err != nil { t.Fatal(err) @@ -235,7 +238,7 @@ func TestPullCmd(t *testing.T) { } if tt.existDir != "" { file := filepath.Join(outdir, tt.existDir) - err := os.Mkdir(file, 0755) + err := os.MkdirAll(file, 0755) if err != nil { t.Fatal(err) } @@ -243,8 +246,8 @@ func TestPullCmd(t *testing.T) { _, out, err := executeActionCommand(cmd) if err != nil { if tt.wantError { - if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + if tt.wantErrorMsg != "" && tt.wantErrorMsg != err.Error() { + t.Fatalf("Actual error '%s', not equal to expected error '%s'", err, tt.wantErrorMsg) } return } @@ -303,16 +306,19 @@ func runPullTests(t *testing.T, tests []struct { } if tt.existDir != "" { file := filepath.Join(outdir, tt.existDir) - err := os.Mkdir(file, 0755) + err := os.MkdirAll(file, 0755) if err != nil { t.Fatal(err) } } _, _, err := executeActionCommand(cmd) + if tt.wantError && err == nil { + t.Fatalf("%q: expected error but got none", tt.name) + } if err != nil { if tt.wantError { - if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + if tt.wantErrorMsg != "" && tt.wantErrorMsg != err.Error() { + t.Fatalf("Actual error '%s', not equal to expected error '%s'", err, tt.wantErrorMsg) } return } @@ -487,10 +493,9 @@ func TestPullWithCredentialsCmdOCIRegistry(t *testing.T) { wantError: true, }, { - name: "Fail fetching OCI chart without version specified", - args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "", ociSrv.TestUsername, ociSrv.TestPassword), - wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", - wantError: true, + name: "Fail fetching OCI chart without version specified", + args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "", ociSrv.TestUsername, ociSrv.TestPassword), + wantError: true, }, } diff --git a/pkg/cmd/repo_add_test.go b/pkg/cmd/repo_add_test.go index 6d3696f52..df9451d34 100644 --- a/pkg/cmd/repo_add_test.go +++ b/pkg/cmd/repo_add_test.go @@ -200,7 +200,7 @@ func repoAddConcurrent(t *testing.T, testName, repoFile string) { var wg sync.WaitGroup wg.Add(3) - for i := 0; i < 3; i++ { + for i := range 3 { go func(name string) { defer wg.Done() o := &repoAddOptions{ @@ -227,7 +227,7 @@ func repoAddConcurrent(t *testing.T, testName, repoFile string) { } var name string - for i := 0; i < 3; i++ { + for i := range 3 { name = fmt.Sprintf("%s-%d", testName, i) if !f.Has(name) { t.Errorf("%s was not successfully inserted into %s: %s", name, repoFile, f.Repositories[0]) diff --git a/pkg/cmd/repo_list.go b/pkg/cmd/repo_list.go index 10b4442a0..450294948 100644 --- a/pkg/cmd/repo_list.go +++ b/pkg/cmd/repo_list.go @@ -30,6 +30,7 @@ import ( func newRepoListCmd(out io.Writer) *cobra.Command { var outfmt output.Format + var noHeaders bool cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -46,12 +47,17 @@ func newRepoListCmd(out io.Writer) *cobra.Command { return nil } - return outfmt.Write(out, &repoListWriter{f.Repositories}) + w := &repoListWriter{ + repos: f.Repositories, + noHeaders: noHeaders, + } + + return outfmt.Write(out, w) }, } + cmd.Flags().BoolVar(&noHeaders, "no-headers", false, "suppress headers in the output") bindOutputFlag(cmd, &outfmt) - return cmd } @@ -61,12 +67,15 @@ type repositoryElement struct { } type repoListWriter struct { - repos []*repo.Entry + repos []*repo.Entry + noHeaders bool } func (r *repoListWriter) WriteTable(out io.Writer) error { table := uitable.New() - table.AddRow("NAME", "URL") + if !r.noHeaders { + table.AddRow("NAME", "URL") + } for _, re := range r.repos { table.AddRow(re.Name, re.URL) } @@ -94,11 +103,11 @@ func (r *repoListWriter) encodeByFormat(out io.Writer, format output.Format) err return output.EncodeJSON(out, repolist) case output.YAML: return output.EncodeYAML(out, repolist) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil } - - // Because this is a non-exported function and only called internally by - // WriteJSON and WriteYAML, we shouldn't get invalid types - return nil } // Returns all repos from repos, except those with names matching ignoredRepoNames diff --git a/pkg/cmd/repo_list_test.go b/pkg/cmd/repo_list_test.go index 2f6a9e4ad..94cdf3969 100644 --- a/pkg/cmd/repo_list_test.go +++ b/pkg/cmd/repo_list_test.go @@ -48,6 +48,12 @@ func TestRepoList(t *testing.T) { golden: "output/repo-list.txt", wantError: false, }, + { + name: "list without headers", + cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s --no-headers", repoFile2, rootDir), + golden: "output/repo-list-no-headers.txt", + wantError: false, + }, } runTestCmd(t, tests) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index b64118a50..04ba91c1f 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -179,6 +179,16 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg logSetup(settings.Debug) + // newRootCmdWithConfig is only called from NewRootCmd. NewRootCmd sets up + // NewConfiguration without a custom logger. So, the slog default is used. logSetup + // can change the default logger to the one in the logger package. This happens for + // the Helm client. This means the actionConfig logger is different from the slog + // default logger. If they are different we sync the actionConfig logger to the slog + // current default one. + if actionConfig.Logger() != slog.Default() { + actionConfig.SetLogger(slog.Default().Handler()) + } + // Validate color mode setting switch settings.ColorMode { case "never", "auto", "always": diff --git a/pkg/cmd/root_test.go b/pkg/cmd/root_test.go index 84e3d9ed2..316e6bd2e 100644 --- a/pkg/cmd/root_test.go +++ b/pkg/cmd/root_test.go @@ -17,11 +17,14 @@ limitations under the License. package cmd import ( + "bytes" + "log/slog" "os" "path/filepath" "testing" "helm.sh/helm/v4/internal/test/ensure" + "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath/xdg" ) @@ -129,3 +132,20 @@ func TestUnknownSubCmd(t *testing.T) { // func TestRootFileCompletion(t *testing.T) { // checkFileCompletion(t, "", false) // } + +func TestRootCmdLogger(t *testing.T) { + args := []string{} + buf := new(bytes.Buffer) + actionConfig := action.NewConfiguration() + _, err := newRootCmdWithConfig(actionConfig, buf, args, SetupLogging) + if err != nil { + t.Errorf("expected no error, got: '%v'", err) + } + + l1 := actionConfig.Logger() + l2 := slog.Default() + + if l1.Handler() != l2.Handler() { + t.Error("expected actionConfig logger to be the slog default logger") + } +} diff --git a/pkg/cmd/search/search_test.go b/pkg/cmd/search/search_test.go index a24eb1f64..b3220394f 100644 --- a/pkg/cmd/search/search_test.go +++ b/pkg/cmd/search/search_test.go @@ -39,13 +39,13 @@ func TestSortScore(t *testing.T) { SortScore(in) // Test Score - for i := 0; i < len(expectScore); i++ { + for i := range expectScore { if expectScore[i] != in[i].Score { t.Errorf("Sort error on index %d: expected %d, got %d", i, expectScore[i], in[i].Score) } } // Test Name - for i := 0; i < len(expect); i++ { + for i := range expect { if expect[i] != in[i].Name { t.Errorf("Sort error: expected %s, got %s", expect[i], in[i].Name) } diff --git a/pkg/cmd/search_hub.go b/pkg/cmd/search_hub.go index cfeeec59b..bb2ff6038 100644 --- a/pkg/cmd/search_hub.go +++ b/pkg/cmd/search_hub.go @@ -190,9 +190,10 @@ func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) er return output.EncodeJSON(out, chartList) case output.YAML: return output.EncodeYAML(out, chartList) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil } - // Because this is a non-exported function and only called internally by - // WriteJSON and WriteYAML, we shouldn't get invalid types - return nil } diff --git a/pkg/cmd/search_repo.go b/pkg/cmd/search_repo.go index 07345a48f..febb138e2 100644 --- a/pkg/cmd/search_repo.go +++ b/pkg/cmd/search_repo.go @@ -190,7 +190,7 @@ func (o *searchRepoOptions) buildIndex() (*search.Index, error) { f := filepath.Join(o.repoCacheDir, helmpath.CacheIndexFile(n)) ind, err := repo.LoadIndexFile(f) if err != nil { - slog.Warn("repo is corrupt or missing", "repo", n, slog.Any("error", err)) + slog.Warn("repo is corrupt or missing", slog.String("repo", n), slog.Any("error", err)) continue } @@ -260,11 +260,11 @@ func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) e return output.EncodeJSON(out, chartList) case output.YAML: return output.EncodeYAML(out, chartList) + default: + // Because this is a non-exported function and only called internally by + // WriteJSON and WriteYAML, we shouldn't get invalid types + return nil } - - // Because this is a non-exported function and only called internally by - // WriteJSON and WriteYAML, we shouldn't get invalid types - return nil } // Provides the list of charts that are part of the specified repo, and that starts with 'prefix'. diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index cf68c6c46..14f85042b 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -46,6 +46,16 @@ Render chart templates locally and display the output. Any values that would normally be looked up or retrieved in-cluster will be faked locally. Additionally, none of the server-side testing of chart validity (e.g. whether an API is supported) is done. + +To specify the Kubernetes API versions used for Capabilities.APIVersions, use +the '--api-versions' flag. This flag can be specified multiple times or as a +comma-separated list: + + $ helm template --api-versions networking.k8s.io/v1 --api-versions cert-manager.io/v1 mychart ./mychart + +or + + $ helm template --api-versions networking.k8s.io/v1,cert-manager.io/v1 mychart ./mychart ` func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh b/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh index 678b4eff5..6c62be8b9 100755 --- a/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh +++ b/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh @@ -1,2 +1,2 @@ -#!/bin/bash -echo $* +#!/usr/bin/env sh +echo "$@" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh index ec8469657..9cf68da68 100755 --- a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh +++ b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh @@ -1,2 +1,2 @@ -#!/bin/bash -exit $* +#!/usr/bin/env sh +exit "$1" diff --git a/pkg/cmd/testdata/output/repo-list-no-headers.txt b/pkg/cmd/testdata/output/repo-list-no-headers.txt new file mode 100644 index 000000000..13491aeb2 --- /dev/null +++ b/pkg/cmd/testdata/output/repo-list-no-headers.txt @@ -0,0 +1,3 @@ +charts https://charts.helm.sh/stable +firstexample http://firstexample.com +secondexample http://secondexample.com diff --git a/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt b/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt new file mode 100644 index 000000000..f5454b88d --- /dev/null +++ b/pkg/cmd/testdata/output/uninstall-keep-history-earlier-deployed.txt @@ -0,0 +1 @@ +release "aeneas" uninstalled diff --git a/pkg/cmd/testdata/output/version-short.txt b/pkg/cmd/testdata/output/version-short.txt index 1961bcc21..8cf4318fb 100644 --- a/pkg/cmd/testdata/output/version-short.txt +++ b/pkg/cmd/testdata/output/version-short.txt @@ -1 +1 @@ -v4.0 +v4.1 diff --git a/pkg/cmd/testdata/output/version-template.txt b/pkg/cmd/testdata/output/version-template.txt index 1c3c8f5d7..8fd8b4962 100644 --- a/pkg/cmd/testdata/output/version-template.txt +++ b/pkg/cmd/testdata/output/version-template.txt @@ -1 +1 @@ -Version: v4.0 \ No newline at end of file +Version: v4.1 \ No newline at end of file diff --git a/pkg/cmd/testdata/output/version.txt b/pkg/cmd/testdata/output/version.txt index 2d50053f2..1f4cf4d4a 100644 --- a/pkg/cmd/testdata/output/version.txt +++ b/pkg/cmd/testdata/output/version.txt @@ -1 +1 @@ -version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v."} +version.BuildInfo{Version:"v4.1", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} diff --git a/pkg/cmd/testdata/testcharts/test-0.1.0.tgz b/pkg/cmd/testdata/testcharts/test-0.1.0.tgz new file mode 100644 index 000000000..9ed772a7f Binary files /dev/null and b/pkg/cmd/testdata/testcharts/test-0.1.0.tgz differ diff --git a/pkg/cmd/testdata/testcharts/test/Chart.yaml b/pkg/cmd/testdata/testcharts/test/Chart.yaml new file mode 100644 index 000000000..53e47c820 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/test/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Test chart for untar conflict testing +name: test +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/test/values.yaml b/pkg/cmd/testdata/testcharts/test/values.yaml new file mode 100644 index 000000000..2f01ba536 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/test/values.yaml @@ -0,0 +1 @@ +# Default values for test diff --git a/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz b/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz new file mode 100644 index 000000000..60e00324c Binary files /dev/null and b/pkg/cmd/testdata/testcharts/test1-0.1.0.tgz differ diff --git a/pkg/cmd/testdata/testcharts/test1/Chart.yaml b/pkg/cmd/testdata/testcharts/test1/Chart.yaml new file mode 100644 index 000000000..3dc8fbbf2 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/test1/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Test chart for untar conflict testing +name: test1 +version: 0.1.0 diff --git a/pkg/cmd/testdata/testcharts/test1/values.yaml b/pkg/cmd/testdata/testcharts/test1/values.yaml new file mode 100644 index 000000000..823016ffc --- /dev/null +++ b/pkg/cmd/testdata/testcharts/test1/values.yaml @@ -0,0 +1,2 @@ +# Default values for test1# Default values for test1 + diff --git a/pkg/cmd/uninstall.go b/pkg/cmd/uninstall.go index 4680c324a..4cc14ae1e 100644 --- a/pkg/cmd/uninstall.go +++ b/pkg/cmd/uninstall.go @@ -55,7 +55,7 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if validationErr != nil { return validationErr } - for i := 0; i < len(args); i++ { + for i := range args { res, err := client.Run(args[i]) if err != nil { diff --git a/pkg/cmd/uninstall_test.go b/pkg/cmd/uninstall_test.go index 1123f449b..ce436e68c 100644 --- a/pkg/cmd/uninstall_test.go +++ b/pkg/cmd/uninstall_test.go @@ -19,6 +19,7 @@ package cmd import ( "testing" + "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -57,6 +58,15 @@ func TestUninstall(t *testing.T) { golden: "output/uninstall-keep-history.txt", rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "aeneas"})}, }, + { + name: "keep history with earlier deployed release", + cmd: "uninstall aeneas --keep-history", + golden: "output/uninstall-keep-history-earlier-deployed.txt", + rels: []*release.Release{ + release.Mock(&release.MockReleaseOptions{Name: "aeneas", Version: 1, Status: common.StatusDeployed}), + release.Mock(&release.MockReleaseOptions{Name: "aeneas", Version: 2, Status: common.StatusFailed}), + }, + }, { name: "wait", cmd: "uninstall aeneas --wait", diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 92f130f60..918d6f5b8 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -153,6 +153,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.EnableDNS = client.EnableDNS instClient.HideSecret = client.HideSecret instClient.TakeOwnership = client.TakeOwnership + instClient.ForceConflicts = client.ForceConflicts + instClient.ServerSideApply = client.ServerSideApply != "false" if isReleaseUninstalled(versions) { instClient.Replace = true @@ -200,7 +202,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { if err != nil { return err } - if req := ac.MetaDependencies(); req != nil { + if req := ac.MetaDependencies(); len(req) > 0 { if err := action.CheckDependencies(ch, req); err != nil { err = fmt.Errorf("an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: %w", err) if client.DependencyUpdate { diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index 8729be0ec..0ae1e3561 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -605,3 +605,58 @@ func TestUpgradeWithDryRun(t *testing.T) { t.Error("expected error when --hide-secret used without --dry-run") } } + +func TestUpgradeInstallServerSideApply(t *testing.T) { + _, _, chartPath := prepareMockRelease(t, "ssa-test") + + defer resetEnv()() + + tests := []struct { + name string + serverSideFlag string + expectedApplyMethod string + }{ + { + name: "upgrade --install with --server-side=false uses client-side apply", + serverSideFlag: "--server-side=false", + expectedApplyMethod: "csa", + }, + { + name: "upgrade --install with --server-side=true uses server-side apply", + serverSideFlag: "--server-side=true", + expectedApplyMethod: "ssa", + }, + { + name: "upgrade --install with --server-side=auto uses server-side apply (default for new install)", + serverSideFlag: "--server-side=auto", + expectedApplyMethod: "ssa", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := storageFixture() + releaseName := fmt.Sprintf("ssa-test-%s", tt.expectedApplyMethod) + + cmd := fmt.Sprintf("upgrade %s --install %s '%s'", releaseName, tt.serverSideFlag, chartPath) + _, _, err := executeActionCommandC(store, cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rel, err := store.Get(releaseName, 1) + if err != nil { + t.Fatalf("unexpected error getting release: %v", err) + } + + relV1, err := releaserToV1Release(rel) + if err != nil { + t.Fatalf("unexpected error converting release: %v", err) + } + + if relV1.ApplyMethod != tt.expectedApplyMethod { + t.Errorf("expected ApplyMethod %q, got %q", tt.expectedApplyMethod, relV1.ApplyMethod) + } + }) + } +} diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index 00c8c56e8..ee4f8abe3 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -80,7 +80,7 @@ type ChartDownloader struct { // ContentCache is the location where Cache stores its files by default // In previous versions of Helm the charts were put in the RepositoryCache. The - // repositories and charts are stored in 2 difference caches. + // repositories and charts are stored in 2 different caches. ContentCache string // Cache specifies the cache implementation to use. @@ -104,7 +104,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven return "", nil, errors.New("content cache must be set") } c.Cache = &DiskCache{Root: c.ContentCache} - slog.Debug("setup up default downloader cache") + slog.Debug("set up default downloader cache") } hash, u, err := c.ResolveChartVersion(ref, version) if err != nil { @@ -209,7 +209,7 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena return "", nil, errors.New("content cache must be set") } c.Cache = &DiskCache{Root: c.ContentCache} - slog.Debug("setup up default downloader cache") + slog.Debug("set up default downloader cache") } digestString, u, err := c.ResolveChartVersion(ref, version) @@ -227,13 +227,10 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena // Check the cache for the file digest, err := hex.DecodeString(digestString) if err != nil { - return "", nil, err + return "", nil, fmt.Errorf("unable to decode digest: %w", err) } var digest32 [32]byte copy(digest32[:], digest) - if err != nil { - return "", nil, fmt.Errorf("unable to decode digest: %w", err) - } var pth string // only fetch from the cache if we have a digest diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index e541ef9d7..c9cdf79c3 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -169,7 +169,7 @@ func TestRenderRefsOrdering(t *testing.T) { "parent/templates/test.yaml": "parent value", } - for i := 0; i < 100; i++ { + for i := range 100 { out, err := Render(parentChart, common.Values{}) if err != nil { t.Fatalf("Failed to render templates: %s", err) @@ -431,7 +431,7 @@ func TestParallelRenderInternals(t *testing.T) { // Make sure that we can use one Engine to run parallel template renders. e := new(Engine) var wg sync.WaitGroup - for i := 0; i < 20; i++ { + for i := range 20 { wg.Add(1) go func(i int) { tt := fmt.Sprintf("expect-%d", i) @@ -1041,7 +1041,7 @@ func TestRenderRecursionLimit(t *testing.T) { phrase := "All work and no play makes Jack a dull boy" printFunc := `{{define "overlook"}}{{printf "` + phrase + `\n"}}{{end}}` var repeatedIncl strings.Builder - for i := 0; i < times; i++ { + for range times { repeatedIncl.WriteString(`{{include "overlook" . }}`) } @@ -1059,7 +1059,7 @@ func TestRenderRecursionLimit(t *testing.T) { } var expect string - for i := 0; i < times; i++ { + for range times { expect += phrase + "\n" } if got := out["overlook/templates/quote"]; got != expect { diff --git a/pkg/engine/lookup_func.go b/pkg/engine/lookup_func.go index 18ed2b63b..c6ad8d252 100644 --- a/pkg/engine/lookup_func.go +++ b/pkg/engine/lookup_func.go @@ -98,7 +98,11 @@ func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config) gvk := schema.FromAPIVersionAndKind(apiversion, kind) apiRes, err := getAPIResourceForGVK(gvk, config) if err != nil { - slog.Error("unable to get apiresource", "groupVersionKind", gvk.String(), slog.Any("error", err)) + slog.Error( + "unable to get apiresource", + slog.String("groupVersionKind", gvk.String()), + slog.Any("error", err), + ) return nil, false, fmt.Errorf("unable to get apiresource from unstructured: %s: %w", gvk.String(), err) } gvr := schema.GroupVersionResource{ @@ -124,7 +128,11 @@ func getAPIResourceForGVK(gvk schema.GroupVersionKind, config *rest.Config) (met } resList, err := discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) if err != nil { - slog.Error("unable to retrieve resource list", "GroupVersion", gvk.GroupVersion().String(), slog.Any("error", err)) + slog.Error( + "unable to retrieve resource list", + slog.String("GroupVersion", gvk.GroupVersion().String()), + slog.Any("error", err), + ) return res, err } for _, resource := range resList.APIResources { diff --git a/pkg/ignore/rules.go b/pkg/ignore/rules.go index 3511c2d40..a8160da2a 100644 --- a/pkg/ignore/rules.go +++ b/pkg/ignore/rules.go @@ -176,7 +176,7 @@ func (r *Rules) parseRule(rule string) error { rule = after ok, err := filepath.Match(rule, n) if err != nil { - slog.Error("failed to compile", "rule", rule, slog.Any("error", err)) + slog.Error("failed to compile", slog.String("rule", rule), slog.Any("error", err)) return false } return ok @@ -186,7 +186,11 @@ func (r *Rules) parseRule(rule string) error { p.match = func(n string, _ os.FileInfo) bool { ok, err := filepath.Match(rule, n) if err != nil { - slog.Error("failed to compile", "rule", rule, slog.Any("error", err)) + slog.Error( + "failed to compile", + slog.String("rule", rule), + slog.Any("error", err), + ) return false } return ok @@ -198,7 +202,7 @@ func (r *Rules) parseRule(rule string) error { n = filepath.Base(n) ok, err := filepath.Match(rule, n) if err != nil { - slog.Error("failed to compile", "rule", rule, slog.Any("error", err)) + slog.Error("failed to compile", slog.String("rule", rule), slog.Any("error", err)) return false } return ok diff --git a/pkg/kube/client.go b/pkg/kube/client.go index 68f1e6475..6e09fceac 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -98,12 +98,23 @@ type Client struct { var _ Interface = (*Client)(nil) +// WaitStrategy represents the algorithm used to wait for Kubernetes +// resources to reach their desired state. type WaitStrategy string const ( + // StatusWatcherStrategy: event-driven waits using kstatus (watches + aggregated readers). + // Default for --wait. More accurate and responsive; waits CRs and full reconciliation. + // Requires: reachable API server, list+watch RBAC on deployed resources, and a non-zero timeout. StatusWatcherStrategy WaitStrategy = "watcher" - LegacyStrategy WaitStrategy = "legacy" - HookOnlyStrategy WaitStrategy = "hookOnly" + + // LegacyStrategy: Helm 3-style periodic polling until ready or timeout. + // Use when watches aren’t available/reliable, or for compatibility/simple CI. + // Requires only list RBAC for polled resources. + LegacyStrategy WaitStrategy = "legacy" + + // HookOnlyStrategy: wait only for hook Pods/Jobs to complete; does not wait for general chart resources. + HookOnlyStrategy WaitStrategy = "hookOnly" ) type FieldValidationDirective string @@ -114,6 +125,9 @@ const ( FieldValidationDirectiveStrict FieldValidationDirective = "Strict" ) +type CreateApplyFunc func(target *resource.Info) error +type UpdateApplyFunc func(original, target *resource.Info) error + func init() { // Add CRDs to the scheme. They are missing by default. if err := apiextv1.AddToScheme(scheme.Scheme); err != nil { @@ -165,8 +179,10 @@ func (c *Client) GetWaiter(strategy WaitStrategy) (Waiter, error) { return nil, err } return &hookOnlyWaiter{sw: sw}, nil + case "": + return nil, errors.New("wait strategy not set. Choose one of: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) default: - return nil, errors.New("unknown wait strategy") + return nil, errors.New("unknown wait strategy (s" + string(strategy) + "). Valid values are: " + string(StatusWatcherStrategy) + ", " + string(HookOnlyStrategy) + ", " + string(LegacyStrategy)) } } @@ -255,7 +271,7 @@ func ClientCreateOptionDryRun(dryRun bool) ClientCreateOption { } } -// ClientCreateOptionFieldValidationDirective specifies show API operations validate object's schema +// ClientCreateOptionFieldValidationDirective specifies how API operations validate object's schema // - For client-side apply: this is ignored // - For server-side apply: the directive is sent to the server to perform the validation // @@ -268,6 +284,36 @@ func ClientCreateOptionFieldValidationDirective(fieldValidationDirective FieldVa } } +func (c *Client) makeCreateApplyFunc(serverSideApply, forceConflicts, dryRun bool, fieldValidationDirective FieldValidationDirective) CreateApplyFunc { + if serverSideApply { + c.Logger().Debug( + "using server-side apply for resource creation", + slog.Bool("forceConflicts", forceConflicts), + slog.Bool("dryRun", dryRun), + slog.String("fieldValidationDirective", string(fieldValidationDirective))) + + return func(target *resource.Info) error { + err := patchResourceServerSide(target, dryRun, forceConflicts, fieldValidationDirective) + + logger := c.Logger().With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String())) + if err != nil { + logger.Debug("Error creating resource via patch", slog.Any("error", err)) + return err + } + + logger.Debug("Created resource via patch") + + return nil + } + } + + c.Logger().Debug("using client-side apply for resource creation") + return createResource +} + // Create creates Kubernetes resources specified in the resource list. func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) { c.Logger().Debug("creating resource(s)", "resources", len(resources)) @@ -285,32 +331,12 @@ func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) ( return nil, fmt.Errorf("invalid client create option(s): %w", err) } - makeCreateApplyFunc := func() func(target *resource.Info) error { - if createOptions.serverSideApply { - c.Logger().Debug("using server-side apply for resource creation", slog.Bool("forceConflicts", createOptions.forceConflicts), slog.Bool("dryRun", createOptions.dryRun), slog.String("fieldValidationDirective", string(createOptions.fieldValidationDirective))) - return func(target *resource.Info) error { - err := patchResourceServerSide(target, createOptions.dryRun, createOptions.forceConflicts, createOptions.fieldValidationDirective) - - logger := c.Logger().With( - slog.String("namespace", target.Namespace), - slog.String("name", target.Name), - slog.String("gvk", target.Mapping.GroupVersionKind.String())) - if err != nil { - logger.Debug("Error patching resource", slog.Any("error", err)) - return err - } - - logger.Debug("Patched resource") - - return nil - } - } - - c.Logger().Debug("using client-side apply for resource creation") - return createResource - } - - if err := perform(resources, makeCreateApplyFunc()); err != nil { + createApplyFunc := c.makeCreateApplyFunc( + createOptions.serverSideApply, + createOptions.forceConflicts, + createOptions.dryRun, + createOptions.fieldValidationDirective) + if err := perform(resources, createApplyFunc); err != nil { return nil, err } return &Result{Created: resources}, nil @@ -512,7 +538,7 @@ func (c *Client) BuildTable(reader io.Reader, validate bool) (ResourceList, erro transformRequests) } -func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateApplyFunc) (*Result, error) { +func (c *Client) update(originals, targets ResourceList, createApplyFunc CreateApplyFunc, updateApplyFunc UpdateApplyFunc) (*Result, error) { updateErrors := []error{} res := &Result{} @@ -532,12 +558,17 @@ func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateA res.Created = append(res.Created, target) // Since the resource does not exist, create it. - if err := createResource(target); err != nil { + if err := createApplyFunc(target); err != nil { return fmt.Errorf("failed to create resource: %w", err) } kind := target.Mapping.GroupVersionKind.Kind - c.Logger().Debug("created a new resource", "namespace", target.Namespace, "name", target.Name, "kind", kind) + c.Logger().Debug( + "created a new resource", + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("kind", kind), + ) return nil } @@ -568,19 +599,37 @@ func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateA c.Logger().Debug("deleting resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind) if err := info.Get(); err != nil { - c.Logger().Debug("unable to get object", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + c.Logger().Debug( + "unable to get object", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) continue } annotations, err := metadataAccessor.Annotations(info.Object) if err != nil { - c.Logger().Debug("unable to get annotations", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + c.Logger().Debug( + "unable to get annotations", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) } if annotations != nil && annotations[ResourcePolicyAnno] == KeepPolicy { c.Logger().Debug("skipping delete due to annotation", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, "annotation", ResourcePolicyAnno, "value", KeepPolicy) continue } if err := deleteResource(info, metav1.DeletePropagationBackground); err != nil { - c.Logger().Debug("failed to delete resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + c.Logger().Debug( + "failed to delete resource", + slog.String("namespace", info.Namespace), + slog.String("name", info.Name), + slog.String("kind", info.Mapping.GroupVersionKind.Kind), + slog.Any("error", err), + ) if !apierrors.IsNotFound(err) { updateErrors = append(updateErrors, fmt.Errorf("failed to delete resource %s: %w", info.Name, err)) } @@ -655,7 +704,7 @@ func ClientUpdateOptionDryRun(dryRun bool) ClientUpdateOption { } } -// ClientUpdateOptionFieldValidationDirective specifies show API operations validate object's schema +// ClientUpdateOptionFieldValidationDirective specifies how API operations validate object's schema // - For client-side apply: this is ignored // - For server-side apply: the directive is sent to the server to perform the validation // @@ -686,8 +735,6 @@ func ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManag } } -type UpdateApplyFunc func(original, target *resource.Info) error - // Update takes the current list of objects and target list of objects and // creates resources that don't already exist, updates resources that have been // modified in the target configuration, and deletes resources from the current @@ -723,6 +770,12 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate return &Result{}, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") } + createApplyFunc := c.makeCreateApplyFunc( + updateOptions.serverSideApply, + updateOptions.forceConflicts, + updateOptions.dryRun, + updateOptions.fieldValidationDirective) + makeUpdateApplyFunc := func() UpdateApplyFunc { if updateOptions.forceReplace { c.Logger().Debug( @@ -730,7 +783,13 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective))) return func(original, target *resource.Info) error { if err := replaceResource(target, updateOptions.fieldValidationDirective); err != nil { - c.Logger().Debug("error replacing the resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + c.Logger().With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String()), + ).Debug( + "error replacing the resource", slog.Any("error", err), + ) return err } @@ -783,7 +842,7 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate } } - return c.update(originals, targets, makeUpdateApplyFunc()) + return c.update(originals, targets, createApplyFunc, makeUpdateApplyFunc()) } // Delete deletes Kubernetes resources specified in the resources list with @@ -799,7 +858,12 @@ func (c *Client) Delete(resources ResourceList, policy metav1.DeletionPropagatio err := deleteResource(target, policy) if err == nil || apierrors.IsNotFound(err) { if err != nil { - c.Logger().Debug("ignoring delete failure", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + c.Logger().Debug( + "ignoring delete failure", + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("kind", target.Mapping.GroupVersionKind.Kind), + slog.Any("error", err)) } mtx.Lock() defer mtx.Unlock() diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index d49e179e0..d442864f8 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -350,9 +350,7 @@ func TestUpdate(t *testing.T) { "/namespaces/default/pods/otter:GET", "/namespaces/default/pods/otter:PATCH", "/namespaces/default/pods/dolphin:GET", - "/namespaces/default/pods:POST", // create dolphin - "/namespaces/default/pods:POST", // retry due to 409 - "/namespaces/default/pods:POST", // retry due to 409 + "/namespaces/default/pods/dolphin:PATCH", // create dolphin "/namespaces/default/pods/squid:GET", "/namespaces/default/pods/squid:DELETE", "/namespaces/default/pods/notfound:GET", @@ -464,6 +462,8 @@ func TestUpdate(t *testing.T) { return newResponseJSON(http.StatusConflict, resourceQuotaConflict) } + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/dolphin" && m == http.MethodPatch: return newResponse(http.StatusOK, &listTarget.Items[1]) case p == "/namespaces/default/pods/squid" && m == http.MethodDelete: return newResponse(http.StatusOK, &listTarget.Items[1]) @@ -485,10 +485,9 @@ func TestUpdate(t *testing.T) { Reason: metav1.StatusReasonForbidden, Code: http.StatusForbidden, }) - default: } - t.Fail() + t.FailNow() return nil, nil } @@ -924,7 +923,7 @@ func TestGetPodList(t *testing.T) { responsePodList.Items = append(responsePodList.Items, newPodWithStatus(name, v1.PodStatus{}, namespace)) } - kubeClient := k8sfake.NewSimpleClientset(&responsePodList) + kubeClient := k8sfake.NewClientset(&responsePodList) c := Client{Namespace: namespace, kubeClient: kubeClient} podList, err := c.GetPodList(namespace, metav1.ListOptions{}) @@ -937,7 +936,7 @@ func TestOutputContainerLogsForPodList(t *testing.T) { namespace := "some-namespace" somePodList := newPodList("jimmy", "three", "structs") - kubeClient := k8sfake.NewSimpleClientset(&somePodList) + kubeClient := k8sfake.NewClientset(&somePodList) c := Client{Namespace: namespace, kubeClient: kubeClient} outBuffer := &bytes.Buffer{} outBufferFunc := func(_, _, _ string) io.Writer { return outBuffer } @@ -1315,7 +1314,7 @@ func TestIsReachable(t *testing.T) { setupClient: func(t *testing.T) *Client { t.Helper() client := newTestClient(t) - client.kubeClient = k8sfake.NewSimpleClientset() + client.kubeClient = k8sfake.NewClientset() return client }, expectError: false, diff --git a/pkg/kube/fake/failing_kube_client.go b/pkg/kube/fake/failing_kube_client.go index f340c045f..31d0082cc 100644 --- a/pkg/kube/fake/failing_kube_client.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -146,6 +146,9 @@ func (f *FailingKubeClient) BuildTable(r io.Reader, _ bool) (kube.ResourceList, if f.BuildTableError != nil { return []*resource.Info{}, f.BuildTableError } + if f.BuildDummy { + return createDummyResourceList(), nil + } return f.PrintingKubeClient.BuildTable(r, false) } diff --git a/pkg/kube/ready.go b/pkg/kube/ready.go index 42e327bdd..bfa98504c 100644 --- a/pkg/kube/ready.go +++ b/pkg/kube/ready.go @@ -354,6 +354,8 @@ func (c *ReadyChecker) crdBetaReady(crd apiextv1beta1.CustomResourceDefinition) // continue. return true } + default: + // intentionally left empty } } return false @@ -374,6 +376,8 @@ func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool { // continue. return true } + default: + // intentionally left empty } } return false diff --git a/pkg/kube/statuswait.go b/pkg/kube/statuswait.go index cd9722eda..a518f0c04 100644 --- a/pkg/kube/statuswait.go +++ b/pkg/kube/statuswait.go @@ -47,6 +47,13 @@ type statusWaiter struct { ctx context.Context } +// DefaultStatusWatcherTimeout is the timeout used by the status waiter when a +// zero timeout is provided. This prevents callers from accidentally passing a +// zero value (which would immediately cancel the context) and getting +// "context deadline exceeded" errors. SDK callers can rely on this default +// when they don't set a timeout. +var DefaultStatusWatcherTimeout = 30 * time.Second + func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) { return &status.Result{ Status: status.CurrentStatus, @@ -55,6 +62,9 @@ func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) { } func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } ctx, cancel := w.contextWithTimeout(timeout) defer cancel() slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) @@ -76,6 +86,9 @@ func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.D } func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } ctx, cancel := w.contextWithTimeout(timeout) defer cancel() slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) @@ -84,6 +97,9 @@ func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) er } func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } ctx, cancel := w.contextWithTimeout(timeout) defer cancel() slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) @@ -95,6 +111,9 @@ func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Dura } func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Duration) error { + if timeout == 0 { + timeout = DefaultStatusWatcherTimeout + } ctx, cancel := w.contextWithTimeout(timeout) defer cancel() slog.Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout) @@ -113,7 +132,9 @@ func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceL } resources = append(resources, obj) } - eventCh := sw.Watch(cancelCtx, resources, watcher.Options{}) + eventCh := sw.Watch(cancelCtx, resources, watcher.Options{ + RESTScopeStrategy: watcher.RESTScopeNamespace, + }) statusCollector := collector.NewResourceStatusCollector(resources) done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.NotFoundStatus)) <-done @@ -156,7 +177,9 @@ func (w *statusWaiter) wait(ctx context.Context, resourceList ResourceList, sw w resources = append(resources, obj) } - eventCh := sw.Watch(cancelCtx, resources, watcher.Options{}) + eventCh := sw.Watch(cancelCtx, resources, watcher.Options{ + RESTScopeStrategy: watcher.RESTScopeNamespace, + }) statusCollector := collector.NewResourceStatusCollector(resources) done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.CurrentStatus)) <-done @@ -212,6 +235,7 @@ func statusObserver(cancel context.CancelFunc, desired status.Status) collector. } if aggregator.AggregateStatus(rss, desired) == desired { + slog.Debug("all resources achieved desired status", "desiredStatus", desired, "resourceCount", len(rss)) cancel() return } @@ -222,7 +246,7 @@ func statusObserver(cancel context.CancelFunc, desired status.Status) collector. return nonDesiredResources[i].Identifier.Name < nonDesiredResources[j].Identifier.Name }) first := nonDesiredResources[0] - slog.Debug("waiting for resource", "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status) + slog.Debug("waiting for resource", "namespace", first.Identifier.Namespace, "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status) } } } diff --git a/pkg/kube/statuswait_test.go b/pkg/kube/statuswait_test.go index 4b06da896..a8ff4e0e6 100644 --- a/pkg/kube/statuswait_test.go +++ b/pkg/kube/statuswait_test.go @@ -18,6 +18,8 @@ package kube // import "helm.sh/helm/v3/pkg/kube" import ( "errors" + "fmt" + "strings" "testing" "time" @@ -27,12 +29,15 @@ import ( appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apimachinery/pkg/watch" dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" "k8s.io/kubectl/pkg/scheme" ) @@ -153,6 +158,83 @@ spec: - containerPort: 80 ` +var podNamespace1Manifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns1 + namespace: namespace-1 +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podNamespace2Manifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns2 + namespace: namespace-2 +status: + conditions: + - type: Ready + status: "True" + phase: Running +` + +var podNamespace1NoStatusManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns1 + namespace: namespace-1 +` + +var jobNamespace1CompleteManifest = ` +apiVersion: batch/v1 +kind: Job +metadata: + name: job-ns1 + namespace: namespace-1 + generation: 1 +status: + succeeded: 1 + active: 0 + conditions: + - type: Complete + status: "True" +` + +var podNamespace2SucceededManifest = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod-ns2 + namespace: namespace-2 +status: + phase: Succeeded +` + +var clusterRoleManifest = ` +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: test-cluster-role +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +` + +var namespaceManifest = ` +apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace +` + func getGVR(t *testing.T, mapper meta.RESTMapper, obj *unstructured.Unstructured) schema.GroupVersionResource { t.Helper() gvk := obj.GroupVersionKind() @@ -232,11 +314,11 @@ func TestStatusWaitForDelete(t *testing.T) { for _, objToDelete := range objsToDelete { u := objToDelete.(*unstructured.Unstructured) gvr := getGVR(t, fakeMapper, u) - go func() { + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { time.Sleep(timeUntilPodDelete) err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) assert.NoError(t, err) - }() + }(gvr, u) } resourceList := getResourceListFromRuntimeObjs(t, c, objsToCreate) err := statusWaiter.WaitForDelete(resourceList, timeout) @@ -448,3 +530,406 @@ func TestWatchForReady(t *testing.T) { }) } } + +func TestStatusWaitMultipleNamespaces(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + expectErrs []error + testFunc func(statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "pods in multiple namespaces", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "hooks in multiple namespaces", + objManifests: []string{jobNamespace1CompleteManifest, podNamespace2SucceededManifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + { + name: "error when resource not ready in one namespace", + objManifests: []string{podNamespace1NoStatusManifest, podNamespace2Manifest}, + expectErrs: []error{errors.New("resource not ready, name: pod-ns1, kind: Pod, status: InProgress"), errors.New("context deadline exceeded")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete resources in multiple namespaces", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "cluster-scoped resources work correctly with unrestricted permissions", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "namespace-scoped and cluster-scoped resources work together", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest, clusterRoleManifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete cluster-scoped resources works correctly", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "watch cluster-scoped resources works correctly", + objManifests: []string{clusterRoleManifest}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + sw := statusWaiter{ + client: fakeClient, + restMapper: fakeMapper, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delete") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(sw, resourceList, time.Second*3) + if tt.expectErrs != nil { + assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error()) + return + } + assert.NoError(t, err) + }) + } +} + +// restrictedClientConfig holds the configuration for RBAC simulation on a fake dynamic client +type restrictedClientConfig struct { + allowedNamespaces map[string]bool + clusterScopedListAttempted bool +} + +// setupRestrictedClient configures a fake dynamic client to simulate RBAC restrictions +// by using PrependReactor and PrependWatchReactor to intercept list/watch operations. +func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNamespaces []string) *restrictedClientConfig { + allowed := make(map[string]bool) + for _, ns := range allowedNamespaces { + allowed[ns] = true + } + config := &restrictedClientConfig{ + allowedNamespaces: allowed, + } + + // Intercept list operations + fakeClient.PrependReactor("list", "*", func(action clienttesting.Action) (bool, runtime.Object, error) { + listAction := action.(clienttesting.ListAction) + ns := listAction.GetNamespace() + if ns == "" { + // Cluster-scoped list + config.clusterScopedListAttempted = true + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources"), + ) + } + if !config.allowedNamespaces[ns] { + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have LIST permissions in namespace %q", ns), + ) + } + // Fall through to the default handler + return false, nil, nil + }) + + // Intercept watch operations + fakeClient.PrependWatchReactor("*", func(action clienttesting.Action) (bool, watch.Interface, error) { + watchAction := action.(clienttesting.WatchAction) + ns := watchAction.GetNamespace() + if ns == "" { + // Cluster-scoped watch + config.clusterScopedListAttempted = true + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), + ) + } + if !config.allowedNamespaces[ns] { + return true, nil, apierrors.NewForbidden( + action.GetResource().GroupResource(), + "", + fmt.Errorf("user does not have WATCH permissions in namespace %q", ns), + ) + } + // Fall through to the default handler + return false, nil, nil + }) + + return config +} + +func TestStatusWaitRestrictedRBAC(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + allowedNamespaces []string + expectErrs []error + testFunc func(statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "pods in multiple namespaces with namespace permissions", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "delete pods in multiple namespaces with namespace permissions", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "hooks in multiple namespaces with namespace permissions", + objManifests: []string{jobNamespace1CompleteManifest, podNamespace2SucceededManifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WatchUntilReady(rl, timeout) + }, + }, + { + name: "error when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "error when deleting cluster-scoped resource", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "error when accessing disallowed namespace", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have LIST permissions in namespace %q", "namespace-2")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + baseFakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + restrictedConfig := setupRestrictedClient(baseFakeClient, tt.allowedNamespaces) + sw := statusWaiter{ + client: baseFakeClient, + restMapper: fakeMapper, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := baseFakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delet") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := baseFakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(sw, resourceList, time.Second*3) + if tt.expectErrs != nil { + require.Error(t, err) + for _, expectedErr := range tt.expectErrs { + assert.Contains(t, err.Error(), expectedErr.Error()) + } + return + } + assert.NoError(t, err) + assert.False(t, restrictedConfig.clusterScopedListAttempted) + }) + } +} + +func TestStatusWaitMixedResources(t *testing.T) { + t.Parallel() + tests := []struct { + name string + objManifests []string + allowedNamespaces []string + expectErrs []error + testFunc func(statusWaiter, ResourceList, time.Duration) error + }{ + { + name: "wait succeeds with namespace-scoped resources only", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1", "namespace-2"}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "wait fails when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "waitForDelete fails when cluster-scoped resource included", + objManifests: []string{podNamespace1Manifest, clusterRoleManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.WaitForDelete(rl, timeout) + }, + }, + { + name: "wait fails when namespace resource included", + objManifests: []string{podNamespace1Manifest, namespaceManifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + { + name: "error when accessing disallowed namespace", + objManifests: []string{podNamespace1Manifest, podNamespace2Manifest}, + allowedNamespaces: []string{"namespace-1"}, + expectErrs: []error{fmt.Errorf("user does not have LIST permissions in namespace %q", "namespace-2")}, + testFunc: func(sw statusWaiter, rl ResourceList, timeout time.Duration) error { + return sw.Wait(rl, timeout) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := newTestClient(t) + baseFakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + fakeMapper := testutil.NewFakeRESTMapper( + v1.SchemeGroupVersion.WithKind("Pod"), + batchv1.SchemeGroupVersion.WithKind("Job"), + schema.GroupVersion{Group: "rbac.authorization.k8s.io", Version: "v1"}.WithKind("ClusterRole"), + v1.SchemeGroupVersion.WithKind("Namespace"), + ) + restrictedConfig := setupRestrictedClient(baseFakeClient, tt.allowedNamespaces) + sw := statusWaiter{ + client: baseFakeClient, + restMapper: fakeMapper, + } + objs := getRuntimeObjFromManifests(t, tt.objManifests) + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + err := baseFakeClient.Tracker().Create(gvr, u, u.GetNamespace()) + assert.NoError(t, err) + } + + if strings.Contains(tt.name, "delet") { + timeUntilDelete := time.Millisecond * 500 + for _, obj := range objs { + u := obj.(*unstructured.Unstructured) + gvr := getGVR(t, fakeMapper, u) + go func(gvr schema.GroupVersionResource, u *unstructured.Unstructured) { + time.Sleep(timeUntilDelete) + err := baseFakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName()) + assert.NoError(t, err) + }(gvr, u) + } + } + + resourceList := getResourceListFromRuntimeObjs(t, c, objs) + err := tt.testFunc(sw, resourceList, time.Second*3) + if tt.expectErrs != nil { + require.Error(t, err) + for _, expectedErr := range tt.expectErrs { + assert.Contains(t, err.Error(), expectedErr.Error()) + } + return + } + assert.NoError(t, err) + assert.False(t, restrictedConfig.clusterScopedListAttempted) + }) + } +} diff --git a/pkg/kube/wait.go b/pkg/kube/wait.go index f776ae471..9a276a459 100644 --- a/pkg/kube/wait.go +++ b/pkg/kube/wait.go @@ -102,11 +102,20 @@ func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) boo if err == nil { return false } - slog.Debug("error received when checking resource status", "resource", resource.Name, slog.Any("error", err)) + slog.Debug( + "error received when checking resource status", + slog.String("resource", resource.Name), + slog.Any("error", err), + ) if ev, ok := err.(*apierrors.StatusError); ok { statusCode := ev.Status().Code retryable := hw.isRetryableHTTPStatusCode(statusCode) - slog.Debug("status code received", "resource", resource.Name, "statusCode", statusCode, "retryable", retryable) + slog.Debug( + "status code received", + slog.String("resource", resource.Name), + slog.Int("statusCode", int(statusCode)), + slog.Bool("retryable", retryable), + ) return retryable } slog.Debug("retryable error assumed", "resource", resource.Name) @@ -137,9 +146,9 @@ func (hw *legacyWaiter) WaitForDelete(deleted ResourceList, timeout time.Duratio elapsed := time.Since(startTime).Round(time.Second) if err != nil { - slog.Debug("wait for resources failed", "elapsed", elapsed, slog.Any("error", err)) + slog.Debug("wait for resources failed", slog.Duration("elapsed", elapsed), slog.Any("error", err)) } else { - slog.Debug("wait for resources succeeded", "elapsed", elapsed) + slog.Debug("wait for resources succeeded", slog.Duration("elapsed", elapsed)) } return err @@ -324,6 +333,8 @@ func (hw *legacyWaiter) waitForPodSuccess(obj runtime.Object, name string) (bool slog.Debug("pod pending", "pod", o.Name) case corev1.PodRunning: slog.Debug("pod running", "pod", o.Name) + case corev1.PodUnknown: + slog.Debug("pod unknown", "pod", o.Name) } return false, nil diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 9eb189216..750bb9715 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "os" @@ -224,12 +225,25 @@ type ( } ) +// warnIfHostHasPath checks if the host contains a repository path and logs a warning if it does. +// Returns true if the host contains a path component (i.e., contains a '/'). +func warnIfHostHasPath(host string) bool { + if strings.Contains(host, "/") { + registryHost := strings.Split(host, "/")[0] + slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost) + return true + } + return false +} + // Login logs into a registry func (c *Client) Login(host string, options ...LoginOption) error { for _, option := range options { option(&loginOperation{host, c}) } + warnIfHostHasPath(host) + reg, err := remote.NewRegistry(host) if err != nil { return err diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 6ae32e342..98a8b2ea3 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -120,3 +120,49 @@ func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { t.Errorf("ForceAttemptOAuth2 should be false after failed Login") } } + +// TestWarnIfHostHasPath verifies that warnIfHostHasPath correctly detects path components. +func TestWarnIfHostHasPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + wantWarn bool + }{ + { + name: "domain only", + host: "ghcr.io", + wantWarn: false, + }, + { + name: "domain with port", + host: "localhost:8000", + wantWarn: false, + }, + { + name: "domain with repository path", + host: "ghcr.io/terryhowe", + wantWarn: true, + }, + { + name: "domain with nested path", + host: "ghcr.io/terryhowe/myrepo", + wantWarn: true, + }, + { + name: "localhost with port and path", + host: "localhost:8000/myrepo", + wantWarn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := warnIfHostHasPath(tt.host) + if got != tt.wantWarn { + t.Errorf("warnIfHostHasPath(%q) = %v, want %v", tt.host, got, tt.wantWarn) + } + }) + } +} diff --git a/pkg/registry/generic.go b/pkg/registry/generic.go index fb7e80d10..b46133d91 100644 --- a/pkg/registry/generic.go +++ b/pkg/registry/generic.go @@ -20,6 +20,7 @@ import ( "context" "io" "net/http" + "slices" "sort" "sync" @@ -124,10 +125,8 @@ func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*Ge mediaType := desc.MediaType // Skip media types if specified - for _, skipType := range options.SkipMediaTypes { - if mediaType == skipType { - return oras.SkipNode - } + if slices.Contains(options.SkipMediaTypes, mediaType) { + return oras.SkipNode } // Filter by allowed media types if specified diff --git a/pkg/repo/v1/chartrepo.go b/pkg/repo/v1/chartrepo.go index 1db913322..deef7474e 100644 --- a/pkg/repo/v1/chartrepo.go +++ b/pkg/repo/v1/chartrepo.go @@ -17,6 +17,7 @@ limitations under the License. package repo // import "helm.sh/helm/v4/pkg/repo/v1" import ( + "bytes" "crypto/rand" "encoding/base64" "encoding/json" @@ -28,6 +29,7 @@ import ( "path/filepath" "strings" + "helm.sh/helm/v4/internal/fileutil" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/helmpath" ) @@ -108,12 +110,13 @@ func (r *ChartRepository) DownloadIndexFile() (string, error) { } chartsFile := filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)) os.MkdirAll(filepath.Dir(chartsFile), 0755) - os.WriteFile(chartsFile, []byte(charts.String()), 0644) + + fileutil.AtomicWriteFile(chartsFile, bytes.NewReader([]byte(charts.String())), 0644) // Create the index file in the cache directory fname := filepath.Join(r.CachePath, helmpath.CacheIndexFile(r.Config.Name)) os.MkdirAll(filepath.Dir(fname), 0755) - return fname, os.WriteFile(fname, index, 0644) + return fname, fileutil.AtomicWriteFile(fname, bytes.NewReader(index), 0644) } type findChartInRepoURLOptions struct { diff --git a/pkg/repo/v1/chartrepo_test.go b/pkg/repo/v1/chartrepo_test.go index e4e76646a..353ab62d6 100644 --- a/pkg/repo/v1/chartrepo_test.go +++ b/pkg/repo/v1/chartrepo_test.go @@ -22,8 +22,10 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "runtime" "strings" + "sync" "testing" "time" @@ -31,6 +33,7 @@ import ( "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" + "helm.sh/helm/v4/pkg/helmpath" ) type CustomGetter struct { @@ -91,6 +94,60 @@ func TestIndexCustomSchemeDownload(t *testing.T) { } } +func TestConcurrencyDownloadIndex(t *testing.T) { + srv, err := startLocalServerForTests(nil) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + + repo, err := NewChartRepository(&Entry{ + Name: "nginx", + URL: srv.URL, + }, getter.All(&cli.EnvSettings{})) + + if err != nil { + t.Fatalf("Problem loading chart repository from %s: %v", srv.URL, err) + } + repo.CachePath = t.TempDir() + + // initial download index + idx, err := repo.DownloadIndexFile() + if err != nil { + t.Fatalf("Failed to download index file to %s: %v", idx, err) + } + + indexFName := filepath.Join(repo.CachePath, helmpath.CacheIndexFile(repo.Config.Name)) + + var wg sync.WaitGroup + + // Simultaneously start multiple goroutines that: + // 1) download index.yaml via DownloadIndexFile (write operation), + // 2) read index.yaml via LoadIndexFile (read operation). + // This checks for race conditions and ensures correct behavior under concurrent read/write access. + for range 150 { + wg.Add(1) + + go func() { + defer wg.Done() + idx, err := repo.DownloadIndexFile() + if err != nil { + t.Errorf("Failed to download index file to %s: %v", idx, err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + _, err := LoadIndexFile(indexFName) + if err != nil { + t.Errorf("Failed to load index file: %v", err) + } + }() + } + wg.Wait() +} + // startLocalServerForTests Start the local helm server func startLocalServerForTests(handler http.Handler) (*httptest.Server, error) { if handler == nil { diff --git a/pkg/repo/v1/index.go b/pkg/repo/v1/index.go index d77d70a7f..7969d64e9 100644 --- a/pkg/repo/v1/index.go +++ b/pkg/repo/v1/index.go @@ -215,7 +215,9 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) { } if constraint.Check(test) { - slog.Warn("unable to find exact version; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) + if len(version) != 0 { + slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) + } return ver, nil } } diff --git a/pkg/repo/v1/index_test.go b/pkg/repo/v1/index_test.go index a8aadadec..517457dc4 100644 --- a/pkg/repo/v1/index_test.go +++ b/pkg/repo/v1/index_test.go @@ -708,9 +708,7 @@ func TestLoadIndex_DuplicateChartDeps(t *testing.T) { } cvs := idx.Entries["nginx"] if cvs == nil { - if err != nil { - t.Error("expected one chart version not to be filtered out") - } + t.Error("expected one chart version not to be filtered out") } for _, v := range cvs { if v.Name == "alpine" { diff --git a/pkg/storage/driver/cfgmaps.go b/pkg/storage/driver/cfgmaps.go index 5af432d8a..f82ade5e9 100644 --- a/pkg/storage/driver/cfgmaps.go +++ b/pkg/storage/driver/cfgmaps.go @@ -75,13 +75,13 @@ func (cfgmaps *ConfigMaps) Get(key string) (release.Releaser, error) { return nil, ErrReleaseNotFound } - cfgmaps.Logger().Debug("failed to get release", "key", key, slog.Any("error", err)) + cfgmaps.Logger().Debug("failed to get release", slog.String("key", key), slog.Any("error", err)) return nil, err } // found the configmap, decode the base64 data string r, err := decodeRelease(obj.Data["release"]) if err != nil { - cfgmaps.Logger().Debug("failed to decode data", "key", key, slog.Any("error", err)) + cfgmaps.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) return nil, err } r.Labels = filterSystemLabels(obj.Labels) @@ -109,7 +109,7 @@ func (cfgmaps *ConfigMaps) List(filter func(release.Releaser) bool) ([]release.R for _, item := range list.Items { rls, err := decodeRelease(item.Data["release"]) if err != nil { - cfgmaps.Logger().Debug("failed to decode release", "item", item, slog.Any("error", err)) + cfgmaps.Logger().Debug("failed to decode release", slog.Any("item", item), slog.Any("error", err)) continue } @@ -181,7 +181,7 @@ func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { // create a new configmap to hold the release obj, err := newConfigMapsObject(key, rel, lbs) if err != nil { - cfgmaps.Logger().Debug("failed to encode release", "name", rac.Name(), slog.Any("error", err)) + cfgmaps.Logger().Debug("failed to encode release", slog.String("name", rac.Name()), slog.Any("error", err)) return err } // push the configmap object out into the kubiverse @@ -214,7 +214,11 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { // create a new configmap object to hold the release obj, err := newConfigMapsObject(key, rls, lbs) if err != nil { - cfgmaps.Logger().Debug("failed to encode release", "name", rls.Name, slog.Any("error", err)) + cfgmaps.Logger().Debug( + "failed to encode release", + slog.String("name", rls.Name), + slog.Any("error", err), + ) return err } // push the configmap object out into the kubiverse diff --git a/pkg/storage/driver/secrets.go b/pkg/storage/driver/secrets.go index 85f3497e7..a73f3cf05 100644 --- a/pkg/storage/driver/secrets.go +++ b/pkg/storage/driver/secrets.go @@ -103,7 +103,10 @@ func (secrets *Secrets) List(filter func(release.Releaser) bool) ([]release.Rele for _, item := range list.Items { rls, err := decodeRelease(string(item.Data["release"])) if err != nil { - secrets.Logger().Debug("list failed to decode release", "key", item.Name, slog.Any("error", err)) + secrets.Logger().Debug( + "list failed to decode release", slog.String("key", item.Name), + slog.Any("error", err), + ) continue } @@ -142,7 +145,11 @@ func (secrets *Secrets) Query(labels map[string]string) ([]release.Releaser, err for _, item := range list.Items { rls, err := decodeRelease(string(item.Data["release"])) if err != nil { - secrets.Logger().Debug("failed to decode release", "key", item.Name, slog.Any("error", err)) + secrets.Logger().Debug( + "failed to decode release", + slog.String("key", item.Name), + slog.Any("error", err), + ) continue } rls.Labels = item.Labels diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go index b6ea3916d..21d9f6679 100644 --- a/pkg/storage/driver/sql.go +++ b/pkg/storage/driver/sql.go @@ -319,18 +319,23 @@ func (s *SQL) Get(key string) (release.Releaser, error) { // Get will return an error if the result is empty if err := s.db.Get(&record, query, args...); err != nil { - s.Logger().Debug("got SQL error when getting release", "key", key, slog.Any("error", err)) + s.Logger().Debug("got SQL error when getting release", slog.String("key", key), slog.Any("error", err)) return nil, ErrReleaseNotFound } release, err := decodeRelease(record.Body) if err != nil { - s.Logger().Debug("failed to decode data", "key", key, slog.Any("error", err)) + s.Logger().Debug("failed to decode data", slog.String("key", key), slog.Any("error", err)) return nil, err } if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { - s.Logger().Debug("failed to get release custom labels", "namespace", s.namespace, "key", key, slog.Any("error", err)) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err), + ) return nil, err } @@ -365,12 +370,17 @@ func (s *SQL) List(filter func(release.Releaser) bool) ([]release.Releaser, erro for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { - s.Logger().Debug("failed to decode release", "record", record, slog.Any("error", err)) + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) continue } if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { - s.Logger().Debug("failed to get release custom labels", "namespace", record.Namespace, "key", record.Key, slog.Any("error", err)) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) return nil, err } maps.Copy(release.Labels, getReleaseSystemLabels(release)) @@ -429,12 +439,17 @@ func (s *SQL) Query(labels map[string]string) ([]release.Releaser, error) { for _, record := range records { release, err := decodeRelease(record.Body) if err != nil { - s.Logger().Debug("failed to decode release", "record", record, slog.Any("error", err)) + s.Logger().Debug("failed to decode release", slog.Any("record", record), slog.Any("error", err)) continue } if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil { - s.Logger().Debug("failed to get release custom labels", "namespace", record.Namespace, "key", record.Key, slog.Any("error", err)) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", record.Namespace), + slog.String("key", record.Key), + slog.Any("error", err), + ) return nil, err } @@ -518,11 +533,11 @@ func (s *SQL) Create(key string, rel release.Releaser) error { var record SQLReleaseWrapper if err := transaction.Get(&record, selectQuery, args...); err == nil { - s.Logger().Debug("release already exists", "key", key) + s.Logger().Debug("release already exists", slog.String("key", key)) return ErrReleaseExists } - s.Logger().Debug("failed to store release in SQL database", "key", key, slog.Any("error", err)) + s.Logger().Debug("failed to store release in SQL database", slog.String("key", key), slog.Any("error", err)) return err } @@ -596,7 +611,7 @@ func (s *SQL) Update(key string, rel release.Releaser) error { } if _, err := s.db.Exec(query, args...); err != nil { - s.Logger().Debug("failed to update release in SQL database", "key", key, slog.Any("error", err)) + s.Logger().Debug("failed to update release in SQL database", slog.String("key", key), slog.Any("error", err)) return err } @@ -625,13 +640,13 @@ func (s *SQL) Delete(key string) (release.Releaser, error) { var record SQLReleaseWrapper err = transaction.Get(&record, selectQuery, args...) if err != nil { - s.Logger().Debug("release not found", "key", key, slog.Any("error", err)) + s.Logger().Debug("release not found", slog.String("key", key), slog.Any("error", err)) return nil, ErrReleaseNotFound } release, err := decodeRelease(record.Body) if err != nil { - s.Logger().Debug("failed to decode release", "key", key, slog.Any("error", err)) + s.Logger().Debug("failed to decode release", slog.String("key", key), slog.Any("error", err)) transaction.Rollback() return nil, err } @@ -654,7 +669,11 @@ func (s *SQL) Delete(key string) (release.Releaser, error) { } if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil { - s.Logger().Debug("failed to get release custom labels", "namespace", s.namespace, "key", key, slog.Any("error", err)) + s.Logger().Debug( + "failed to get release custom labels", + slog.String("namespace", s.namespace), + slog.String("key", key), + slog.Any("error", err)) return nil, err } diff --git a/pkg/storage/driver/sql_test.go b/pkg/storage/driver/sql_test.go index d85691a6f..f7c29033c 100644 --- a/pkg/storage/driver/sql_test.go +++ b/pkg/storage/driver/sql_test.go @@ -120,7 +120,7 @@ func TestSQLList(t *testing.T) { sqlDriver, mock := newTestFixtureSQL(t) - for i := 0; i < 3; i++ { + for range 3 { query := fmt.Sprintf( "SELECT %s, %s, %s FROM %s WHERE %s = $1 AND %s = $2", sqlReleaseTableKeyColumn, diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 07dc12c7b..d6c41635b 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -293,7 +293,7 @@ func (s *Storage) deleteReleaseVersion(name string, version int) error { key := makeKey(name, version) _, err := s.Delete(name, version) if err != nil { - s.Logger().Debug("error pruning release", "key", key, slog.Any("error", err)) + s.Logger().Debug("error pruning release", slog.String("key", key), slog.Any("error", err)) return err } return nil diff --git a/pkg/strvals/literal_parser.go b/pkg/strvals/literal_parser.go index d34e5e854..d5d4c25b4 100644 --- a/pkg/strvals/literal_parser.go +++ b/pkg/strvals/literal_parser.go @@ -17,6 +17,7 @@ package strvals import ( "bytes" + "errors" "fmt" "io" "strconv" @@ -66,7 +67,7 @@ func (t *literalParser) parse() error { if err == nil { continue } - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } return err @@ -183,7 +184,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] case lastRune == '=': value, err := t.val() - if err != nil && err != io.EOF { + if err != nil && !errors.Is(err, io.EOF) { return list, err } return setIndex(list, i, string(value)) diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go index 86e349f37..8eb761dce 100644 --- a/pkg/strvals/parser.go +++ b/pkg/strvals/parser.go @@ -161,7 +161,7 @@ func (t *parser) parse() error { if err == nil { continue } - if err == io.EOF { + if errors.Is(err, io.EOF) { return nil } return err