diff --git a/.github/env b/.github/env index 9d79b174f..43ebdd36e 100644 --- a/.github/env +++ b/.github/env @@ -1,2 +1,2 @@ -GOLANG_VERSION=1.25 -GOLANGCI_LINT_VERSION=v2.5.0 +GOLANG_VERSION=1.26 +GOLANGCI_LINT_VERSION=v2.11.3 diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index f2c472faa..b4ce89f26 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # pin@6.2.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@6.5.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 b66bbc29d..323b8ec5b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,14 +24,15 @@ on: schedule: - cron: '29 6 * * 6' -permissions: - contents: read - security-events: write +permissions: {} jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + contents: read + security-events: write strategy: fail-fast: false @@ -43,11 +44,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # pinv4.30.7 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # pinv4.36.2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +59,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # pinv4.30.7 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # pinv4.36.2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -72,4 +73,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # pinv4.30.7 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # pinv4.36.2 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 142cb3533..6355db230 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # pin@6.2.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@6.5.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 #pin@9.2.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a #pin@9.3.0 with: version: ${{ env.GOLANGCI_LINT_VERSION }} diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 1bfc81b66..93ab559fc 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: persist-credentials: false - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # pin@6.2.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@6.5.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 017687cc1..ee2612917 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest-16-cores steps: - name: Checkout source code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 with: fetch-depth: 0 @@ -28,7 +28,7 @@ jobs: run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # pin@6.2.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@6.5.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true @@ -85,13 +85,15 @@ jobs: if: github.ref == 'refs/heads/main' && github.repository == 'helm/helm' steps: - name: Checkout source code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # pin@v7.0.0 + with: + fetch-depth: 0 - name: Add variables to environment file run: cat ".github/env" >> "$GITHUB_ENV" - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # pin@6.2.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # pin@6.5.0 with: go-version: '${{ env.GOLANG_VERSION }}' check-latest: true diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index dc2d3b3d9..03419cbda 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -28,7 +28,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif @@ -64,6 +64,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 7d41280ad..bbe339e79 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -3,11 +3,16 @@ on: schedule: - cron: "0 0 * * *" +permissions: {} + jobs: stale: runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.' diff --git a/.gitignore b/.gitignore index 0fd2c6bda..2209e9809 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .DS_Store .coverage/ .idea +.claude .vimrc .vscode/ .devcontainer/ diff --git a/.golangci.yml b/.golangci.yml index 7eca135e5..b3ced6f1a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -17,22 +17,34 @@ formatters: linters: default: none + # Keep sorted alphabetically enable: + - bidichk + - contextcheck - depguard - dupl + - errorlint + - exhaustive + - fatcontext + - gocritic - gomodguard - govet - ineffassign - misspell + - modernize - nakedret + - noctx + - nolintlint + - perfsprint - revive - sloglint - staticcheck + - testifylint - thelper - unused - usestdlibvars - usetesting - - exhaustive + - whitespace exclusions: @@ -44,16 +56,9 @@ linters: - legacy - std-error-handling - rules: - # This rule is triggered for packages like 'util'. When changes to those packages - # occur it triggers this rule. This exclusion enables making changes to existing - # packages. - - linters: - - revive - text: 'var-naming: avoid meaningless package names' - warn-unused: true + # Keep sorted alphabetically settings: depguard: rules: @@ -67,6 +72,57 @@ linters: dupl: threshold: 400 + errorlint: + # Check for plain type assertions and type switches. + asserts: false + # Check for plain error comparisons. + comparison: false + + exhaustive: + default-signifies-exhaustive: true + + gocritic: + disabled-checks: + - appendAssign + - appendCombine + - assignOp + - badCall + - commentedOutCode + - commentFormatting + - deferInLoop + - elseif + - emptyStringTest + - exposedSyncMutex + - filepathJoin + - httpNoBody + - hugeParam + - ifElseChain + - importShadow + - nestingReduce + - nilValReturn + - octalLiteral + - paramTypeCombine + - preferStringWriter + - ptrToRefParam + - rangeValCopy + - regexpSimplify + - singleCaseSwitch + - sloppyReassign + - sloppyLen + - sprintfQuotedString + - stringConcatSimplify + - stringsCompare + - stringXbytes + - todoCommentWithoutDetail + - tooManyResultsChecker + - typeAssertChain + - uncheckedInlineErr + - unnamedResult + - unnecessaryDefer + - unslice + - whyNoLint + enable-all: true + gomodguard: blocked: modules: @@ -74,8 +130,40 @@ linters: recommendations: - github.com/evanphx/json-patch/v5 - exhaustive: - default-signifies-exhaustive: true + nolintlint: + require-specific: true + + revive: + enable-all-rules: false + enable-default-rules: true + max-open-files: 2048 + rules: + - name: unnecessary-format + - name: var-naming + arguments: + - ["ID"] # AllowList + - ["VM"] # DenyList + - - skip-initialism-name-checks: true + upper-case-const: true + skip-package-name-checks: true + skip-package-name-collision-with-go-std: true + extra-bad-package-names: + - helpers + - models + + testifylint: + disable: + - encoded-compare + - equal-values + - expected-actual + - float-compare + - go-require + - nil-compare + - require-error + - suite-dont-use-pkg + - suite-extra-assert-call + # Intentionally enable all testifylint rules so new checks are adopted automatically. + enable-all: true run: timeout: 10m diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 000000000..ab2089882 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,74 @@ +version: 2 + +project_name: helm + +dist: _dist +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + - arm + - "386" + - ppc64le + - s390x + - riscv64 + - loong64 + goamd64: + - v1 + goarm: + - "7" + goarm64: + - v8.0 + go386: + - sse2 + goriscv64: + - rva20u64 + ignore: + - goos: darwin + goarch: "386" + - goos: darwin + goarch: arm + - goos: darwin + goarch: ppc64le + - goos: darwin + goarch: s390x + - goos: darwin + goarch: riscv64 + - goos: darwin + goarch: loong64 + - goos: windows + goarch: "386" + - goos: windows + goarch: arm + - goos: windows + goarch: ppc64le + - goos: windows + goarch: s390x + - goos: windows + goarch: riscv64 + - goos: windows + goarch: loong64 + main: ./cmd/helm + no_unique_dist_dir: true + binary: "{{ .Os }}-{{ .Arch }}/helm" + ldflags: + - "{{ .Env.LDFLAGS }}" + flags: + - -trimpath + dir: . + +snapshot: + version_template: "{{ if index .Env \"GORELEASER_CURRENT_TAG\" }}{{ .Env.GORELEASER_CURRENT_TAG }}{{ else }}{{ incpatch .Version }}-next{{ end }}" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' diff --git a/AGENTS.md b/AGENTS.md index d2904a9da..a2e192f74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,88 @@ # AGENTS.md ## Overview -Helm is a package manager for Kubernetes written in Go, supporting v3 (stable) and v4 (unstable) APIs. -## Build & Test +Helm is a package manager for Kubernetes written in Go. It enables users to define, install, and upgrade complex Kubernetes applications using charts. +This document provides an overview of the codebase structure, development guidelines, and key patterns for contributors. + +The codebase supports both an SDK for advanced users, and a CLI for direct end user usage. + +The project currently supports Helm v3 and Helm v4 versions, based on the `dev-v3` and `main` branches respectively. + +## Build and test + ```bash make build # Build binary make test # Run all tests (style + unit) make test-unit # Unit tests only make test-coverage # With coverage -make test-style # Linting -golangci-lint run # Direct linting +make test-style # Linting (wraps golangci-lint) go test -run TestName # Specific test ``` -## Code Structure -- `/cmd/helm/` - CLI entry point (Cobra-based) -- `/pkg/` - Public API +## Code structure + +Major packages: + +- `cmd/helm/` - CLI entry point, wires CLI flags to `pkg/cmd/` commands +- `pkg/` - Public API - `action/` - Core operations (install, upgrade, rollback) + - `cmd/` - Cobra command implementations bridging CLI flags to `pkg/action/` - `chart/v2/` - Stable chart format - `engine/` - Template rendering (Go templates + Sprig) + - `kube/` - Kubernetes client abstraction layer - `registry/` - OCI support + - `release/` - Release types and interfaces (`v1/`, `common/`) + - `repo/` - Chart repository indexing and interaction - `storage/` - Release backends (Secrets/ConfigMaps/SQL) -- `/internal/` - Private implementation +- `internal/` - Private implementations - `chart/v3/` - Next-gen chart format + - `release/v2/` - Release package for chart v3 support + +## Development + +### Compatibility + +Changes are required to maintain backward compatibility as described in [HIP-0004: Document backwards-compatibility rules](https://github.com/helm/community/blob/main/hips/hip-0004.md). + +Typically this means that: -## Development Guidelines +- the signatures of public APIs, i.e., those in the `pkg/` directory should not change +- CLI commands and parameters should not be removed or changed in a way that would break existing scripts or workflows +- functional behaviour (as implied or documented) must not be modified in a way that would break existing users' expectations + +An exception to the above is where incompatible changes are needed to fix a security vulnerability, where minimal breaking changes may be made to address the issue. + +### Code standards -### Code Standards - Use table-driven tests with testify - Golden files in `testdata/` for complex output - Mock Kubernetes clients for action tests - All commits must include DCO sign-off: `git commit -s` ### Branching -- `main` - Helm v4 development -- `dev-v3` - Helm v3 stable (backport from main) -### Dependencies +Standard workflow is for PR development changes to the `main` branch. Minor release branches are cut from `main`, then maintained for critical fixes via patch releases. +Bug and security fixes are also backported to `dev-v3` where applicable. + +Development branches: + +- `main` - Helm v4 +- `dev-v3` - Helm v3 (backport security and bugfixes from main) + +Release branches: + +- `release-v3.X` - Release branches for v3.X versions +- `release-v4.X` - Release branches for v4.X versions + +### Major dependencies + - `k8s.io/client-go` - Kubernetes interaction - `github.com/spf13/cobra` - CLI framework - `github.com/Masterminds/sprig` - Template functions -### Key Patterns -- **Actions**: Operations in `/pkg/action/` use shared Configuration -- **Dual Chart Support**: v2 (stable) in `/pkg/`, v3 (dev) in `/internal/` -- **Storage Abstraction**: Pluggable release storage backends +### Key patterns + +- **Actions**: High-level operations live in `pkg/action/`, typically using a shared Configuration +- **Chart versions**: Charts v2 (stable) in `pkg/chart/v2`, v3 (under development) in `internal/chart/v3` +- **Plugins and extensibility**: Enabling additional functionality via plugins and extension points, such as custom template functions or storage backends is preferred over incorporating into Helm's codebase diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b704aa9a..0d7bc4f4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing Guidelines The Helm project accepts contributions via GitHub pull requests. This document outlines the process -to help get your contribution accepted. +to help you get your contribution accepted. ## Reporting a Security Issue @@ -15,7 +15,7 @@ chance to try to fix the issue before it is exploited in the wild. Helm v4 development takes place on the `main` branch while Helm v3 is on the `dev-v3` branch. -Helm v3 will continue to receive bug fixes and updates for new Kubernetes releases until July 8th 2026. Security enhancement will still be applied until November 11th 2026. See the blog for more details. +Helm v3 will continue to receive bug fixes and updates for new Kubernetes releases until July 8th 2026. Security enhancements will still be applied until November 11th 2026. See the blog for more details. Bugs should first be fixed on Helm v4 and then backported to Helm v3. Helm v3 (and the `dev-v3` branch) is no longer accepting new features. @@ -110,9 +110,9 @@ Whether you are a user or contributor, official support channels include: - User: [#helm-users](https://kubernetes.slack.com/messages/C0NH30761/details/) - Contributor: [#helm-dev](https://kubernetes.slack.com/messages/C51E88VDG/) -Before opening a new issue or submitting a new pull request, it's helpful to search the project - -it's likely that another user has already reported the issue you're facing, or it's a known issue -that we're already aware of. It is also worth asking on the Slack channels. +Before opening a new issue or submitting a new pull request, it helps to search the project first. +Another user may have already reported the issue you are facing, or it may already be a known issue. +It is also worth asking on the Slack channels. ## Milestones @@ -137,7 +137,7 @@ are removed or substantially modified (unless we need to fix a security issue). We also remain committed to not changing publicly accessible Go library definitions inside of the `pkg/` directory of our source code in a non-backwards-compatible way. -For more details on Helm’s minor and patch release backwards-compatibility rules, please read [HIP-0004](https://github.com/helm/community/blob/main/hips/hip-0004.md) +For more details on Helm’s minor and patch release backwards-compatibility rules, please read [HIP-0004](https://github.com/helm/community/blob/main/hips/hip-0004.md). For a quick summary of our backward compatibility guidelines for releases between 3.0 and 4.0: @@ -162,9 +162,9 @@ There are 5 types of issues (each with their own corresponding [label](#labels)) for future reference. Generally these are questions that are too complex or large to store in the Slack channel or have particular interest to the community as a whole. Depending on the 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 +- `proposal`: Used for items (like this one) that propose 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 + 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. diff --git a/Makefile b/Makefile index a18b83f0d..81b149a68 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ BINDIR := $(CURDIR)/bin INSTALL_PATH ?= /usr/local/bin DIST_DIRS := find * -type d -exec -TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/loong64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64 TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-loong64.tar.gz linux-loong64.tar.gz.sha256 linux-loong64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum BINNAME ?= helm @@ -9,7 +8,7 @@ GOBIN = $(shell go env GOBIN) ifeq ($(GOBIN),) GOBIN = $(shell go env GOPATH)/bin endif -GOX = $(GOBIN)/gox +GORELEASER = $(GOBIN)/goreleaser GOIMPORTS = $(GOBIN)/goimports ARCH = $(shell go env GOARCH) @@ -130,8 +129,7 @@ test-source-headers: @scripts/validate-license.sh .PHONY: test-acceptance -test-acceptance: TARGETS = linux/amd64 -test-acceptance: build build-cross +test-acceptance: build @if [ -d "${ACCEPTANCE_DIR}" ]; then \ cd ${ACCEPTANCE_DIR} && \ ROBOT_RUN_TESTS=$(ACCEPTANCE_RUN_TESTS) ROBOT_HELM_PATH='$(BINDIR)' make acceptance; \ @@ -162,8 +160,8 @@ gen-test-golden: test-unit # dependencies to the go.mod file. To avoid that we change to a directory # without a go.mod file when downloading the following dependencies -$(GOX): - (cd /; go install github.com/mitchellh/gox@v1.0.2-0.20220701044238-9f712387e2d2) +$(GORELEASER): + (cd /; go install github.com/goreleaser/goreleaser/v2@latest) $(GOIMPORTS): (cd /; go install golang.org/x/tools/cmd/goimports@latest) @@ -173,8 +171,8 @@ $(GOIMPORTS): .PHONY: build-cross build-cross: LDFLAGS += -extldflags "-static" -build-cross: $(GOX) - GOFLAGS="-trimpath" CGO_ENABLED=0 $(GOX) -parallel=3 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' ./cmd/helm +build-cross: $(GORELEASER) + LDFLAGS='$(LDFLAGS)' $(GORELEASER) build --snapshot --clean .PHONY: dist dist: diff --git a/README.md b/README.md index 37bc8abaa..d581ceac3 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ Think of it like apt/yum/homebrew for Kubernetes. ## Helm Development and Stable Versions -Helm v4 is currently under development on the `main` branch. This is unstable and the APIs within the Go SDK and at the command line are changing. -Helm v3 (current stable) is maintained on the `dev-v3` branch. APIs there follow semantic versioning. +Helm v4 is the current stable release, developed on the `main` branch. +Helm v3 is in support mode on the `dev-v3` branch: bug fixes until July 8th 2026, security fixes until November 11th 2026. ## Install @@ -64,7 +64,7 @@ Get started with the [Quick Start guide](https://helm.sh/docs/intro/quickstart/) The [Helm roadmap uses GitHub milestones](https://github.com/helm/helm/milestones) to track the progress of the project. -The development of Helm v4 is currently happening on the `main` branch while the development of Helm v3, the stable branch, is happening on the `dev-v3` branch. Changes should be made to the `main` branch prior to being added to the `dev-v3` branch so that all changes are carried along to Helm v4. +Helm v4 development happens on the `main` branch. Helm v3 is in support mode on the `dev-v3` branch and receives only bug and security fixes. ## Community, discussion, contribution, and support diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 60addadb1..1e4c81d35 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -18,13 +18,13 @@ package main import ( "bytes" - "errors" "os" "os/exec" "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCliPluginExitCode(t *testing.T) { @@ -46,7 +46,7 @@ func TestCliPluginExitCode(t *testing.T) { // So that the second run is able to run main() and this first run can verify the exit status returned by that. // // This technique originates from https://talks.golang.org/2014/testing.slide#23. - cmd := exec.Command(os.Args[0], "-test.run=TestCliPluginExitCode") + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=TestCliPluginExitCode") cmd.Env = append( os.Environ(), "RUN_MAIN_FOR_TESTING=1", @@ -62,20 +62,13 @@ func TestCliPluginExitCode(t *testing.T) { err := cmd.Run() exiterr := &exec.ExitError{} - ok := errors.As(err, &exiterr) - if !ok { - t.Fatalf("Unexpected error type returned by os.Exit: %T", err) - } + require.ErrorAs(t, err, &exiterr) assert.Empty(t, stdout.String()) - expectedStderr := "Error: plugin \"exitwith\" exited with error\n" - if stderr.String() != expectedStderr { - t.Errorf("Expected %q written to stderr: Got %q", expectedStderr, stderr.String()) - } + expectedStderr := "level=WARN msg=\"failed to load plugin (ignoring)\" plugin_yaml=../../pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml error=\"failed to load plugin \\\"../../pkg/cmd/testdata/helmhome/helm/plugins/noversion\\\": plugin `version` is required\"\nError: plugin \"exitwith\" exited with error\n" + assert.Equal(t, expectedStderr, stderr.String()) - if exiterr.ExitCode() != 43 { - t.Errorf("Expected exit code 43: Got %d", exiterr.ExitCode()) - } + assert.Equal(t, 43, exiterr.ExitCode()) } } diff --git a/go.mod b/go.mod index 6ab73f33a..d85d0387a 100644 --- a/go.mod +++ b/go.mod @@ -1,30 +1,30 @@ module helm.sh/helm/v4 -go 1.25.0 +go 1.26.0 require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 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/semver/v3 v3.5.0 github.com/Masterminds/sprig/v3 v3.3.0 github.com/Masterminds/squirrel v1.5.4 github.com/Masterminds/vcs v1.13.3 - github.com/ProtonMail/go-crypto v1.3.0 + github.com/ProtonMail/go-crypto v1.4.1 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/cyphar/filepath-securejoin v0.6.1 - github.com/distribution/distribution/v3 v3.0.0 + github.com/cyphar/filepath-securejoin v0.7.0 + github.com/distribution/distribution/v3 v3.1.1 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.37.1-flux.1 + github.com/fatih/color v1.19.0 + github.com/fluxcd/cli-utils v1.2.2 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.11.2 - github.com/mattn/go-shellwords v1.0.12 + github.com/lib/pq v1.12.3 + github.com/mattn/go-shellwords v1.0.13 github.com/moby/term v0.5.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 @@ -33,22 +33,22 @@ require ( 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.11.0 + github.com/tetratelabs/wazero v1.12.0 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/crypto v0.48.0 - golang.org/x/term v0.40.0 - golang.org/x/text v0.34.0 + golang.org/x/crypto v0.53.0 + golang.org/x/term v0.44.0 + golang.org/x/text v0.38.0 gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.35.1 - k8s.io/apiextensions-apiserver v0.35.1 - k8s.io/apimachinery v0.35.1 - k8s.io/apiserver v0.35.1 - k8s.io/cli-runtime v0.35.1 - k8s.io/client-go v0.35.1 - k8s.io/klog/v2 v2.130.1 - k8s.io/kubectl v0.35.1 - oras.land/oras-go/v2 v2.6.0 - sigs.k8s.io/controller-runtime v0.23.1 + k8s.io/api v0.36.2 + k8s.io/apiextensions-apiserver v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/apiserver v0.36.2 + k8s.io/cli-runtime v0.36.2 + k8s.io/client-go v0.36.2 + k8s.io/klog/v2 v2.140.0 + k8s.io/kubectl v0.36.2 + oras.land/oras-go/v2 v2.6.1 + sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/kustomize/kyaml v0.21.1 sigs.k8s.io/yaml v1.6.0 ) @@ -60,21 +60,21 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/bshuster-repo/logrus-logstash-hook v1.1.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/cloudflare/circl v1.6.1 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker-credential-helpers v0.8.2 // indirect - github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-events v0.0.0-20250808211157-605354379745 // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -91,8 +91,7 @@ require ( 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/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -100,12 +99,12 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect 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 @@ -116,66 +115,67 @@ require ( 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/onsi/gomega v1.38.2 // indirect + github.com/onsi/gomega v1.42.1 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // 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 github.com/redis/go-redis/v9 v9.7.3 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect - 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.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 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect - 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.36.0 // indirect - go.opentelemetry.io/otel/sdk/log v0.8.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.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // 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 + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // 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.35.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/component-base v0.36.2 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index e512b6147..a92d35dfe 100644 --- a/go.sum +++ b/go.sum @@ -14,18 +14,20 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= -github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= -github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 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/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= 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= @@ -34,65 +36,65 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4= +github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0= github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= +github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= 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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= -github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= +github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0= +github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= -github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4= +github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= -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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= 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.37.1-flux.1 h1:WnG2mHxCPZMj/soIq/S/1zvbrGCJN3GJGbNfG06X55M= -github.com/fluxcd/cli-utils v0.37.1-flux.1/go.mod h1:aND5wX3LuTFtB7eUT7vsWr8mmxRVSPR2Wkvbn0SqPfw= +github.com/fluxcd/cli-utils v1.2.2 h1:adDOmwE+LSwTzmYUaoEFPblruOuaQEKAg1ZNTmPJObE= +github.com/fluxcd/cli-utils v1.2.2/go.mod h1:FsghNGY+3Sr70c0FOB7I5So0kzoYVdvQ8GTid3XXVWM= 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= @@ -126,7 +128,6 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.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= @@ -143,8 +144,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ= -github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -153,10 +154,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= @@ -177,8 +176,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr 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/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= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -192,21 +191,20 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtB github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= -github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= +github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= -github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-shellwords v1.0.13 h1:DC0OMEpGjm6LfNFU4ckYcvbQKyp2vE8atyFGXNtDcf4= +github.com/mattn/go-shellwords v1.0.13/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -233,10 +231,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/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/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= 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= @@ -260,13 +258,15 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw 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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= 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= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= @@ -287,8 +287,9 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -308,65 +309,69 @@ 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.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= 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.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= -go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= -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.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= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= -go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= -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.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.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= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk= +go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= 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.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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= @@ -377,14 +382,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 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= @@ -395,10 +400,10 @@ 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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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= @@ -407,27 +412,26 @@ 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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= @@ -435,8 +439,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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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= @@ -444,27 +448,29 @@ 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-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= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= @@ -479,41 +485,41 @@ 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.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= -k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= -k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= -k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= -k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= -k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= -k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= -k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= -k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= -k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= -k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= -k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= -k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= -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-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/kubectl v0.35.1 h1:zP3Er8C5i1dcAFUMh9Eva0kVvZHptXIn/+8NtRWMxwg= -k8s.io/kubectl v0.35.1/go.mod h1:cQ2uAPs5IO/kx8R5s5J3Ihv3VCYwrx0obCXum0CvnXo= -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.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= -sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4= +k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E= +k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k= +k8s.io/cli-runtime v0.36.2 h1:CconTvEeV4DJs4ZX3HQKCFbFRGsm6OtuBM9yjmMP2VM= +k8s.io/cli-runtime v0.36.2/go.mod h1:LddcjiMf4YlnHO7c1Y7rEtDqL84FyiYVLco7V679GUU= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w= +k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/kubectl v0.36.2 h1:rpUGGpeL09XVOLep2yle5jrtk//JA1L6ZHfkQQtVEwk= +k8s.io/kubectl v0.36.2/go.mod h1:gVbQ3B/yb4bSR2ggQ7rd0W6icUSWs7sduH4e16Vii+0= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= +k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs= +oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= 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/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/chart/v3/chart.go b/internal/chart/v3/chart.go index 217594df9..71a90c855 100644 --- a/internal/chart/v3/chart.go +++ b/internal/chart/v3/chart.go @@ -49,7 +49,7 @@ type Chart struct { // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // SchemaModTime the schema was last modified - SchemaModTime time.Time `json:"schemamodtime,omitempty"` + SchemaModTime time.Time `json:"schemamodtime"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` @@ -140,6 +140,7 @@ func (ch *Chart) AppVersion() string { } // CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. +// // Deprecated: use CRDObjects() func (ch *Chart) CRDs() []*common.File { files := []*common.File{} diff --git a/internal/chart/v3/chart_test.go b/internal/chart/v3/chart_test.go index 07cbf4b39..d5d066d9b 100644 --- a/internal/chart/v3/chart_test.go +++ b/internal/chart/v3/chart_test.go @@ -59,7 +59,7 @@ func TestCRDs(t *testing.T) { is := assert.New(t) crds := chrt.CRDs() - is.Equal(2, len(crds)) + is.Len(crds, 2) is.Equal("crds/foo.yaml", crds[0].Name) is.Equal("crds/foo/bar/baz.yaml", crds[1].Name) } @@ -104,7 +104,7 @@ func TestMetadata(t *testing.T) { is.Equal("foo.yaml", chrt.Name()) is.Equal("1.0.0", chrt.AppVersion()) - is.Equal(nil, chrt.Validate()) + is.NoError(chrt.Validate()) } func TestIsRoot(t *testing.T) { @@ -124,8 +124,8 @@ func TestIsRoot(t *testing.T) { is := assert.New(t) - is.Equal(false, chrt1.IsRoot()) - is.Equal(true, chrt2.IsRoot()) + is.False(chrt1.IsRoot()) + is.True(chrt2.IsRoot()) } func TestChartPath(t *testing.T) { diff --git a/internal/chart/v3/lint/lint.go b/internal/chart/v3/lint/lint.go index e98edfabe..ea8ca3d60 100644 --- a/internal/chart/v3/lint/lint.go +++ b/internal/chart/v3/lint/lint.go @@ -44,7 +44,6 @@ func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { } func RunAll(baseDir string, values map[string]any, namespace string, options ...LinterOption) support.Linter { - chartDir, _ := filepath.Abs(baseDir) lo := linterOptions{} diff --git a/internal/chart/v3/lint/lint_test.go b/internal/chart/v3/lint/lint_test.go index 221de8572..afacb8052 100644 --- a/internal/chart/v3/lint/lint_test.go +++ b/internal/chart/v3/lint/lint_test.go @@ -95,7 +95,7 @@ func TestInvalidYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { - t.Errorf("All didn't have the error for deliberateSyntaxError") + t.Error("All didn't have the error for deliberateSyntaxError") } } @@ -107,7 +107,7 @@ func TestInvalidChartYamlV3(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { - t.Errorf("All didn't have the error for duplicate YAML keys") + t.Error("All didn't have the error for duplicate YAML keys") } } @@ -231,13 +231,13 @@ func TestMalformedTemplate(t *testing.T) { }() select { case <-c: - t.Fatalf("lint malformed template timeout") + t.Fatal("lint malformed template timeout") case <-ch: if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { - t.Errorf("All didn't have the error for invalid character '{'") + t.Error("All didn't have the error for invalid character '{'") } } } diff --git a/internal/chart/v3/lint/rules/chartfile.go b/internal/chart/v3/lint/rules/chartfile.go index f171b56b9..29991a8d5 100644 --- a/internal/chart/v3/lint/rules/chartfile.go +++ b/internal/chart/v3/lint/rules/chartfile.go @@ -152,7 +152,7 @@ func validateChartVersion(cf *chart.Metadata) error { valid, msg := c.Validate(version) if !valid && len(msg) > 0 { - return fmt.Errorf("version %v", msg[0]) + return fmt.Errorf("version %w", msg[0]) } return nil diff --git a/internal/chart/v3/lint/rules/chartfile_test.go b/internal/chart/v3/lint/rules/chartfile_test.go index 57893e151..a7669a0aa 100644 --- a/internal/chart/v3/lint/rules/chartfile_test.go +++ b/internal/chart/v3/lint/rules/chartfile_test.go @@ -50,26 +50,26 @@ func TestValidateChartYamlNotDirectory(t *testing.T) { err := validateChartYamlNotDirectory(nonExistingChartFilePath) if err == nil { - t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + t.Error("validateChartYamlNotDirectory to return a linter error, got no error") } } func TestValidateChartYamlFormat(t *testing.T) { err := validateChartYamlFormat(errors.New("Read error")) if err == nil { - t.Errorf("validateChartYamlFormat to return a linter error, got no error") + t.Error("validateChartYamlFormat to return a linter error, got no error") } err = validateChartYamlFormat(nil) if err != nil { - t.Errorf("validateChartYamlFormat to return no error, got a linter error") + t.Error("validateChartYamlFormat to return no error, got a linter error") } } func TestValidateChartName(t *testing.T) { err := validateChartName(badChart) if err == nil { - t.Errorf("validateChartName to return a linter error, got no error") + t.Error("validateChartName to return a linter error, got no error") } err = validateChartName(badChartName) @@ -149,7 +149,7 @@ func TestValidateChartMaintainer(t *testing.T) { badChart.Maintainers = []*chart.Maintainer{nil} err := validateChartMaintainer(badChart) if err == nil { - t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + t.Error("validateChartMaintainer did not return error for nil maintainer as expected") } if err.Error() != "a maintainer entry is empty" { t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) @@ -185,7 +185,7 @@ func TestValidateChartIconPresence(t *testing.T) { err := validateChartIconPresence(testChart) if err == nil { - t.Errorf("validateChartIconPresence to return a linter error, got no error") + t.Error("validateChartIconPresence to return a linter error, got no error") } else if !strings.Contains(err.Error(), "icon is recommended") { t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) } diff --git a/internal/chart/v3/lint/rules/crds.go b/internal/chart/v3/lint/rules/crds.go index deedeb0f2..0a479d214 100644 --- a/internal/chart/v3/lint/rules/crds.go +++ b/internal/chart/v3/lint/rules/crds.go @@ -19,7 +19,6 @@ package rules import ( "bytes" "errors" - "fmt" "io" "io/fs" "os" @@ -102,14 +101,14 @@ func validateCrdsDir(crdsPath string) error { func validateCrdAPIVersion(obj *k8sYamlStruct) error { if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { - return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + return errors.New("apiVersion is not in 'apiextensions.k8s.io'") } return nil } func validateCrdKind(obj *k8sYamlStruct) error { if obj.Kind != "CustomResourceDefinition" { - return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + return errors.New("object kind is not 'CustomResourceDefinition'") } return nil } diff --git a/internal/chart/v3/lint/rules/crds_test.go b/internal/chart/v3/lint/rules/crds_test.go index e435b8ea3..b80339d7b 100644 --- a/internal/chart/v3/lint/rules/crds_test.go +++ b/internal/chart/v3/lint/rules/crds_test.go @@ -62,5 +62,5 @@ metadata: linter := support.Linter{ChartDir: chartDir} Crds(&linter) - assert.Len(t, linter.Messages, 0) + assert.Empty(t, linter.Messages) } diff --git a/internal/chart/v3/lint/rules/dependencies_test.go b/internal/chart/v3/lint/rules/dependencies_test.go index b80e4b8a9..31fd0ef33 100644 --- a/internal/chart/v3/lint/rules/dependencies_test.go +++ b/internal/chart/v3/lint/rules/dependencies_test.go @@ -72,7 +72,7 @@ func TestValidateDependencyInMetadata(t *testing.T) { c := chartWithBadDependencies() if err := validateDependencyInMetadata(&c); err == nil { - t.Errorf("chart should have been flagged for missing deps in chart metadata") + t.Error("chart should have been flagged for missing deps in chart metadata") } } @@ -132,7 +132,7 @@ func TestValidateDependenciesUnique(t *testing.T) { for _, tt := range tests { if err := validateDependenciesUnique(&tt.chart); err == nil { - t.Errorf("chart should have been flagged for dependency shadowing") + t.Error("chart should have been flagged for dependency shadowing") } } } diff --git a/internal/chart/v3/lint/rules/deprecations.go b/internal/chart/v3/lint/rules/deprecations.go index a607a5fb4..9c73268af 100644 --- a/internal/chart/v3/lint/rules/deprecations.go +++ b/internal/chart/v3/lint/rules/deprecations.go @@ -28,7 +28,7 @@ import ( kscheme "k8s.io/client-go/kubernetes/scheme" ) -// deprecatedAPIError indicates than an API is deprecated in Kubernetes +// deprecatedAPIError indicates that an API is deprecated in Kubernetes type deprecatedAPIError struct { Deprecated string Message string diff --git a/internal/chart/v3/lint/rules/deprecations_test.go b/internal/chart/v3/lint/rules/deprecations_test.go index 35e541e5c..2b12a88f9 100644 --- a/internal/chart/v3/lint/rules/deprecations_test.go +++ b/internal/chart/v3/lint/rules/deprecations_test.go @@ -36,6 +36,6 @@ func TestValidateNoDeprecations(t *testing.T) { APIVersion: "v1", Kind: "Pod", }, nil); err != nil { - t.Errorf("Expected a v1 Pod to not be deprecated") + t.Error("Expected a v1 Pod to not be deprecated") } } diff --git a/internal/chart/v3/lint/rules/template.go b/internal/chart/v3/lint/rules/template.go index 35e4940ab..464acc769 100644 --- a/internal/chart/v3/lint/rules/template.go +++ b/internal/chart/v3/lint/rules/template.go @@ -19,6 +19,7 @@ package rules import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -28,8 +29,8 @@ import ( "slices" "strings" + "k8s.io/apimachinery/pkg/api/validate/content" "k8s.io/apimachinery/pkg/api/validation" - apipath "k8s.io/apimachinery/pkg/api/validation/path" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/yaml" @@ -104,7 +105,7 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string } var e engine.Engine e.LintMode = true - renderedContentMap, err := e.Render(chart, valuesToRender) + renderedContentMap, err := e.RenderWithContext(context.Background(), chart, valuesToRender) renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) @@ -292,7 +293,7 @@ func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc { case "role", "clusterrole", "rolebinding", "clusterrolebinding": // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 return func(name string, _ bool) []string { - return apipath.IsValidPathSegmentName(name) + return content.IsPathSegmentName(name) } default: return validation.NameIsDNSSubdomain diff --git a/internal/chart/v3/lint/rules/template_test.go b/internal/chart/v3/lint/rules/template_test.go index b1371659f..88343b330 100644 --- a/internal/chart/v3/lint/rules/template_test.go +++ b/internal/chart/v3/lint/rules/template_test.go @@ -242,7 +242,6 @@ data: // // See https://github.com/helm/helm/issues/7483 func TestStrictTemplateParsingMapError(t *testing.T) { - ch := chart.Chart{ Metadata: &chart.Metadata{ Name: "regression7483", @@ -371,7 +370,6 @@ func TestValidateTopIndentLevel(t *testing.T) { t.Errorf("Expected %t for %q", shouldFail, doc) } } - } // TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments @@ -463,5 +461,4 @@ func TestIsYamlFileExtension(t *testing.T) { t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) } } - } diff --git a/internal/chart/v3/lint/rules/values.go b/internal/chart/v3/lint/rules/values.go index ba371cbe2..b4a2edb0c 100644 --- a/internal/chart/v3/lint/rules/values.go +++ b/internal/chart/v3/lint/rules/values.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "errors" "fmt" "os" "path/filepath" @@ -47,7 +48,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, func validateValuesFileExistence(valuesPath string) error { _, err := os.Stat(valuesPath) if err != nil { - return fmt.Errorf("file does not exist") + return errors.New("file does not exist") } return nil } diff --git a/internal/chart/v3/lint/rules/values_test.go b/internal/chart/v3/lint/rules/values_test.go index afc544ebd..54c7e6457 100644 --- a/internal/chart/v3/lint/rules/values_test.go +++ b/internal/chart/v3/lint/rules/values_test.go @@ -57,7 +57,7 @@ func TestValidateValuesYamlNotDirectory(t *testing.T) { err := validateValuesFileExistence(nonExistingValuesFilePath) if err == nil { - t.Errorf("validateValuesFileExistence to return a linter error, got no error") + t.Error("validateValuesFileExistence to return a linter error, got no error") } } diff --git a/internal/chart/v3/loader/archive.go b/internal/chart/v3/loader/archive.go index a9d4faf8f..442b8871d 100644 --- a/internal/chart/v3/loader/archive.go +++ b/internal/chart/v3/loader/archive.go @@ -57,7 +57,7 @@ func LoadFile(name string) (*chart.Chart, error) { c, err := LoadArchive(raw) if err != nil { 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) + 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/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index c32f79aff..de9219a1b 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -61,7 +61,7 @@ func TestLoadDirWithDevNull(t *testing.T) { t.Fatalf("Failed to load testdata: %s", err) } if _, err := l.Load(); err == nil { - t.Errorf("packages with an irregular file (/dev/null) should not load") + t.Error("packages with an irregular file (/dev/null) should not load") } } @@ -337,7 +337,6 @@ icon: https://example.com/64x64.png if text.String() != "" { t.Errorf("Expected no message to Stderr, got %s", text.String()) } - } // Packaging the chart on a Windows machine will produce an @@ -607,7 +606,6 @@ func verifyChart(t *testing.T, c *chart.Chart) { t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) } } - } func verifyDependencies(t *testing.T, c *chart.Chart) { diff --git a/internal/chart/v3/metadata.go b/internal/chart/v3/metadata.go index 4629d571b..5f7cea897 100644 --- a/internal/chart/v3/metadata.go +++ b/internal/chart/v3/metadata.go @@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) } diff --git a/internal/chart/v3/metadata_test.go b/internal/chart/v3/metadata_test.go index 596a03695..5f88552e9 100644 --- a/internal/chart/v3/metadata_test.go +++ b/internal/chart/v3/metadata_test.go @@ -16,6 +16,7 @@ limitations under the License. package v3 import ( + "errors" "testing" ) @@ -40,6 +41,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v3", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v3", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v3", Version: "1.0"}, @@ -181,7 +192,7 @@ func TestValidate(t *testing.T) { for _, tt := range tests { result := tt.md.Validate() - if result != tt.err { + if !errors.Is(result, tt.err) { t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) } } diff --git a/internal/chart/v3/util/chartfile_test.go b/internal/chart/v3/util/chartfile_test.go index c3d19c381..65cb27923 100644 --- a/internal/chart/v3/util/chartfile_test.go +++ b/internal/chart/v3/util/chartfile_test.go @@ -35,7 +35,7 @@ func TestLoadChartfile(t *testing.T) { func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { t.Helper() - if f == nil { //nolint:staticcheck + if f == nil { t.Fatal("Failed verifyChartfile because f is nil") } @@ -56,15 +56,15 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if f.Maintainers[0].Name != "The Helm Team" { - t.Errorf("Unexpected maintainer name.") + t.Error("Unexpected maintainer name.") } if f.Maintainers[1].Email != "nobody@example.com" { - t.Errorf("Unexpected maintainer email.") + t.Error("Unexpected maintainer email.") } if len(f.Sources) != 1 { - t.Fatalf("Unexpected number of sources") + t.Fatal("Unexpected number of sources") } if f.Sources[0] != "https://example.com/foo/bar" { @@ -84,7 +84,7 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if len(f.Annotations) != 2 { - t.Fatalf("Unexpected annotations") + t.Fatal("Unexpected annotations") } if want, got := "extravalue", f.Annotations["extrakey"]; want != got { @@ -111,7 +111,7 @@ func TestIsChartDir(t *testing.T) { } validChartDir, err = IsChartDir("testdata") if validChartDir || err == nil { - t.Errorf("expected error but did not get any") + t.Error("expected error but did not get any") return } } diff --git a/internal/chart/v3/util/create.go b/internal/chart/v3/util/create.go index 48d2120e5..b3d75ac2d 100644 --- a/internal/chart/v3/util/create.go +++ b/internal/chart/v3/util/create.go @@ -702,7 +702,6 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { // error. In such a case, this will attempt to clean up by removing the // new chart directory. func Create(name, dir string) (string, error) { - // Sanity-check the name of a chart so user doesn't create one that causes problems. if err := validateChartName(name); err != nil { return "", err diff --git a/internal/chart/v3/util/create_test.go b/internal/chart/v3/util/create_test.go index b3b58cc5a..abdd52a82 100644 --- a/internal/chart/v3/util/create_test.go +++ b/internal/chart/v3/util/create_test.go @@ -140,7 +140,7 @@ func TestCreate_Overwrite(t *testing.T) { } if errlog.Len() == 0 { - t.Errorf("Expected warnings about overwriting files.") + t.Error("Expected warnings about overwriting files.") } } diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 063e4fab1..e28288663 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" chart "helm.sh/helm/v4/internal/chart/v3" @@ -260,8 +261,8 @@ func set(path []string, data map[string]any) map[string]any { return nil } cur := data - for i := len(path) - 1; i >= 0; i-- { - cur = map[string]any{path[i]: cur} + for _, v := range slices.Backward(path) { + cur = map[string]any{v: cur} } return cur } diff --git a/internal/chart/v3/util/dependencies_test.go b/internal/chart/v3/util/dependencies_test.go index 6ca87cf4d..92621487c 100644 --- a/internal/chart/v3/util/dependencies_test.go +++ b/internal/chart/v3/util/dependencies_test.go @@ -63,7 +63,7 @@ func TestLoadDependency(t *testing.T) { } func TestDependencyEnabled(t *testing.T) { - type M = map[string]interface{} + type M = map[string]any tests := []struct { name string v M @@ -385,7 +385,7 @@ func TestGetAliasDependency(t *testing.T) { req := c.Metadata.Dependencies if len(req) == 0 { - t.Fatalf("there are no dependencies to test") + t.Fatal("there are no dependencies to test") } // Success case @@ -403,7 +403,7 @@ func TestGetAliasDependency(t *testing.T) { if req[0].Version != "" { if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version is not in the compatible range") + t.Fatal("dependency chart version is not in the compatible range") } } @@ -415,7 +415,7 @@ func TestGetAliasDependency(t *testing.T) { req[0].Version = "something else which is not in the compatible range" if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + t.Fatal("dependency chart version outside the compatible range should not be considered compatible") } } @@ -459,7 +459,6 @@ func TestDependentChartAliases(t *testing.T) { if aliasChart := getAliasDependency(c.Dependencies(), req[2]); aliasChart != nil { t.Fatalf("expected no chart but got %s", aliasChart.Name()) } - } func TestDependentChartWithSubChartsAbsentInDependency(t *testing.T) { diff --git a/internal/chart/v3/util/doc.go b/internal/chart/v3/util/doc.go index 002d5babc..dc5a07462 100644 --- a/internal/chart/v3/util/doc.go +++ b/internal/chart/v3/util/doc.go @@ -42,4 +42,4 @@ into a Chart. When creating charts in memory, use the 'helm.sh/helm/pkg/chart' package directly. */ -package util // import chartutil "helm.sh/helm/v4/internal/chart/v3/util" +package util // import "helm.sh/helm/v4/internal/chart/v3/util" diff --git a/internal/chart/v3/util/expand.go b/internal/chart/v3/util/expand.go index 1a10fce3c..5b057b8e0 100644 --- a/internal/chart/v3/util/expand.go +++ b/internal/chart/v3/util/expand.go @@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files { diff --git a/internal/chart/v3/util/expand_test.go b/internal/chart/v3/util/expand_test.go index 280995f7e..e9e298b81 100644 --- a/internal/chart/v3/util/expand_test.go +++ b/internal/chart/v3/util/expand_test.go @@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir() diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 7a42a76af..34e7d898e 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -21,8 +21,8 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "encoding/hex" "errors" - "fmt" "io" "os" "path" @@ -87,7 +87,7 @@ func TestSave(t *testing.T) { t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) } if _, err := Save(&chartWithInvalidJSON, dest); err == nil { - t.Fatalf("Invalid JSON was not caught while saving chart") + t.Fatal("Invalid JSON was not caught while saving chart") } c.Metadata.APIVersion = chart.APIVersionV3 @@ -153,7 +153,7 @@ func TestSavePreservesTimestamps(t *testing.T) { Version: "1.2.3", }, ModTime: initialCreateTime, - Values: map[string]interface{}{ + Values: map[string]any{ "imageName": "testimage", "imageId": 42, }, @@ -353,5 +353,5 @@ func sha256Sum(filePath string) (string, error) { return "", err } - return fmt.Sprintf("%x", h.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/internal/chart/v3/util/testdata/dotdotname/Chart.yaml b/internal/chart/v3/util/testdata/dotdotname/Chart.yaml new file mode 100644 index 000000000..9b081f27b --- /dev/null +++ b/internal/chart/v3/util/testdata/dotdotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/internal/chart/v3/util/testdata/dotname/Chart.yaml b/internal/chart/v3/util/testdata/dotname/Chart.yaml new file mode 100644 index 000000000..597c16290 --- /dev/null +++ b/internal/chart/v3/util/testdata/dotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/internal/chart/v3/util/testdata/slashinname/Chart.yaml b/internal/chart/v3/util/testdata/slashinname/Chart.yaml new file mode 100644 index 000000000..0c522a4b6 --- /dev/null +++ b/internal/chart/v3/util/testdata/slashinname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/internal/chart/v3/util/validate_name.go b/internal/chart/v3/util/validate_name.go index 6595e085d..7d85a5b88 100644 --- a/internal/chart/v3/util/validate_name.go +++ b/internal/chart/v3/util/validate_name.go @@ -79,7 +79,6 @@ func ValidateReleaseName(name string) error { // This case is preserved for backwards compatibility if name == "" { return errMissingName - } if len(name) > maxReleaseNameLen || !validName.MatchString(name) { return errInvalidName diff --git a/internal/cli/output/color_test.go b/internal/cli/output/color_test.go index 3b8de39e8..807086c62 100644 --- a/internal/cli/output/color_test.go +++ b/internal/cli/output/color_test.go @@ -20,11 +20,12 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/release/common" ) func TestColorizeStatus(t *testing.T) { - tests := []struct { name string status common.Status @@ -94,20 +95,17 @@ func TestColorizeStatus(t *testing.T) { // In test environment, term.IsTerminal will be false, so we won't get color // unless we're testing the logic without terminal detection - if hasColor && !tt.wantColor { - t.Errorf("ColorizeStatus() returned color when none expected: %q", result) + if hasColor { + assert.True(t, tt.wantColor, "ColorizeStatus() returned color when none expected: %q", result) } // Always check the status text is present - if !strings.Contains(result, tt.status.String()) { - t.Errorf("ColorizeStatus() = %q, want to contain %q", result, tt.status.String()) - } + assert.Contains(t, result, tt.status.String()) }) } } func TestColorizeHeader(t *testing.T) { - tests := []struct { name string header string @@ -141,15 +139,12 @@ func TestColorizeHeader(t *testing.T) { result := ColorizeHeader(tt.header, tt.noColor) // Always check the header text is present - if !strings.Contains(result, tt.header) { - t.Errorf("ColorizeHeader() = %q, want to contain %q", result, tt.header) - } + assert.Contains(t, result, tt.header) }) } } func TestColorizeNamespace(t *testing.T) { - tests := []struct { name string namespace string @@ -183,9 +178,7 @@ func TestColorizeNamespace(t *testing.T) { result := ColorizeNamespace(tt.namespace, tt.noColor) // Always check the namespace text is present - if !strings.Contains(result, tt.namespace) { - t.Errorf("ColorizeNamespace() = %q, want to contain %q", result, tt.namespace) - } + assert.Contains(t, result, tt.namespace) }) } } diff --git a/internal/fileutil/fileutil_test.go b/internal/fileutil/fileutil_test.go index 71fcae177..07cbe7695 100644 --- a/internal/fileutil/fileutil_test.go +++ b/internal/fileutil/fileutil_test.go @@ -22,6 +22,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestAtomicWriteFile tests the happy path of AtomicWriteFile function. @@ -35,28 +38,17 @@ func TestAtomicWriteFile(t *testing.T) { mode := os.FileMode(0644) err := AtomicWriteFile(testpath, reader, mode) - if err != nil { - t.Errorf("AtomicWriteFile error: %s", err) - } + assert.NoError(t, err) got, err := os.ReadFile(testpath) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) - if stringContent != string(got) { - t.Fatalf("expected: %s, got: %s", stringContent, string(got)) - } + require.Equal(t, stringContent, string(got)) gotinfo, err := os.Stat(testpath) - if err != nil { - t.Fatal(err) - } - - if mode != gotinfo.Mode() { - t.Fatalf("expected %s: to be the same mode as %s", - mode, gotinfo.Mode()) - } + require.NoError(t, err) + + require.Equal(t, mode, gotinfo.Mode()) } // TestAtomicWriteFile_CreateTempError tests the error path when os.CreateTemp fails @@ -67,9 +59,7 @@ func TestAtomicWriteFile_CreateTempError(t *testing.T) { mode := os.FileMode(0644) err := AtomicWriteFile(invalidPath, reader, mode) - if err == nil { - t.Error("Expected error when CreateTemp fails, but got nil") - } + assert.Error(t, err, "Expected error when CreateTemp fails") } // TestAtomicWriteFile_EmptyContent tests with empty content @@ -81,18 +71,12 @@ func TestAtomicWriteFile_EmptyContent(t *testing.T) { mode := os.FileMode(0644) err := AtomicWriteFile(testpath, reader, mode) - if err != nil { - t.Errorf("AtomicWriteFile error with empty content: %s", err) - } + assert.NoError(t, err, "AtomicWriteFile error with empty content") got, err := os.ReadFile(testpath) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) - if len(got) != 0 { - t.Fatalf("expected empty content, got: %s", string(got)) - } + require.Empty(t, got) } // TestAtomicWriteFile_LargeContent tests with large content @@ -106,18 +90,12 @@ func TestAtomicWriteFile_LargeContent(t *testing.T) { mode := os.FileMode(0644) err := AtomicWriteFile(testpath, reader, mode) - if err != nil { - t.Errorf("AtomicWriteFile error with large content: %s", err) - } + assert.NoError(t, err, "AtomicWriteFile error with large content") got, err := os.ReadFile(testpath) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) - if largeContent != string(got) { - t.Fatalf("expected large content to match, got different length: %d vs %d", len(largeContent), len(got)) - } + require.Equal(t, largeContent, string(got)) } // TestPlatformAtomicWriteFile_OverwritesExisting verifies that the platform @@ -127,21 +105,13 @@ func TestPlatformAtomicWriteFile_OverwritesExisting(t *testing.T) { path := filepath.Join(dir, "overwrite_test") first := bytes.NewReader([]byte("first")) - if err := PlatformAtomicWriteFile(path, first, 0644); err != nil { - t.Fatalf("first write failed: %v", err) - } + require.NoError(t, PlatformAtomicWriteFile(path, first, 0644), "first write failed") second := bytes.NewReader([]byte("second")) - if err := PlatformAtomicWriteFile(path, second, 0644); err != nil { - t.Fatalf("second write failed: %v", err) - } + require.NoError(t, PlatformAtomicWriteFile(path, second, 0644), "second write failed") contents, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed reading result: %v", err) - } + require.NoError(t, err, "failed reading result") - if string(contents) != "second" { - t.Fatalf("expected file to be overwritten, got %q", string(contents)) - } + require.Equal(t, "second", string(contents)) } diff --git a/internal/monocular/client.go b/internal/monocular/client.go index f4ef5d647..cd1a0d666 100644 --- a/internal/monocular/client.go +++ b/internal/monocular/client.go @@ -33,7 +33,6 @@ type Client struct { // New creates a new client func New(u string) (*Client, error) { - // Validate we have a URL if err := validate(u); err != nil { return nil, err @@ -46,7 +45,6 @@ func New(u string) (*Client, error) { // Validate if the base URL for monocular is valid. func validate(u string) error { - // Check if it is parsable p, err := url.Parse(u) if err != nil { diff --git a/internal/monocular/client_test.go b/internal/monocular/client_test.go index abf914ef5..6456557d8 100644 --- a/internal/monocular/client_test.go +++ b/internal/monocular/client_test.go @@ -18,14 +18,13 @@ package monocular import ( "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNew(t *testing.T) { c, err := New("https://hub.helm.sh") - if err != nil { - t.Errorf("error creating client: %s", err) - } - if c.BaseURL != "https://hub.helm.sh" { - t.Errorf("incorrect BaseURL. Expected \"https://hub.helm.sh\" but got %q", c.BaseURL) - } + require.NoError(t, err) + assert.Equal(t, "https://hub.helm.sh", c.BaseURL) } diff --git a/internal/monocular/search.go b/internal/monocular/search.go index fcf04b7a4..29cc7f1a4 100644 --- a/internal/monocular/search.go +++ b/internal/monocular/search.go @@ -17,6 +17,7 @@ limitations under the License. package monocular import ( + "context" "encoding/json" "fmt" "net/http" @@ -98,8 +99,14 @@ type ChartVersion struct { } // Search performs a search against the monocular search API +// +// Deprecated: Use SearchWithContext instead. func (c *Client) Search(term string) ([]SearchResult, error) { + return c.SearchWithContext(context.Background(), term) +} +// SearchWithContext performs a search against the monocular search API +func (c *Client) SearchWithContext(ctx context.Context, term string) ([]SearchResult, error) { // Create the URL to the search endpoint // Note, this is currently an internal API for the Hub. This should be // formatted without showing how monocular operates. @@ -114,7 +121,7 @@ func (c *Client) Search(term string) ([]SearchResult, error) { p.RawQuery = "q=" + url.QueryEscape(term) // Create request - req, err := http.NewRequest(http.MethodGet, p.String(), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.String(), http.NoBody) if err != nil { return nil, err } diff --git a/internal/monocular/search_test.go b/internal/monocular/search_test.go index fc82ef4b4..85327cba7 100644 --- a/internal/monocular/search_test.go +++ b/internal/monocular/search_test.go @@ -21,29 +21,25 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // A search response for phpmyadmin containing 2 results var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` func TestSearch(t *testing.T) { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintln(w, searchResult) })) defer ts.Close() c, err := New(ts.URL) - if err != nil { - t.Errorf("unable to create monocular client: %s", err) - } - - results, err := c.Search("phpmyadmin") - if err != nil { - t.Errorf("unable to search monocular: %s", err) - } - - if len(results) != 2 { - t.Error("Did not receive the expected number of results") - } + require.NoError(t, err, "unable to create monocular client") + + results, err := c.SearchWithContext(t.Context(), "phpmyadmin") + require.NoError(t, err, "unable to search monocular") + + assert.Len(t, results, 2) } diff --git a/internal/plugin/installer/base_test.go b/internal/plugin/installer/base_test.go index 62b77bde5..6df8ec8a1 100644 --- a/internal/plugin/installer/base_test.go +++ b/internal/plugin/installer/base_test.go @@ -35,7 +35,6 @@ func TestPath(t *testing.T) { } for _, tt := range tests { - t.Setenv("HELM_PLUGINS", tt.helmPluginsDir) baseIns := newBase(tt.source) baseInsPath := baseIns.Path() diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go index b753dfbca..3af56ab56 100644 --- a/internal/plugin/installer/extractor.go +++ b/internal/plugin/installer/extractor.go @@ -90,7 +90,6 @@ func NewExtractor(source string) (Extractor, error) { // - Beginning a path with a path separator is illegal // - Rudimentary symlink protections are offered by SecureJoin. func cleanJoin(root, dest string) (string, error) { - // On Windows, this is a drive separator. On UNIX-like, this is the path list separator. // In neither case do we want to trust a TAR that contains these. if strings.Contains(dest, ":") { diff --git a/internal/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go index bb96314f4..5a2912d2e 100644 --- a/internal/plugin/installer/http_installer.go +++ b/internal/plugin/installer/http_installer.go @@ -17,6 +17,7 @@ package installer // import "helm.sh/helm/v4/internal/plugin/installer" import ( "bytes" + "errors" "fmt" "log/slog" "os" @@ -143,7 +144,7 @@ func (i *HTTPInstaller) Install() error { // Update updates a local repository // Not implemented for now since tarball most likely will be packaged by version func (i *HTTPInstaller) Update() error { - return fmt.Errorf("method Update() not implemented for HttpInstaller") + return errors.New("method Update() not implemented for HttpInstaller") } // Path is overridden because we want to join on the plugin name not the file name @@ -163,7 +164,7 @@ func (i *HTTPInstaller) SupportsVerification() bool { // GetVerificationData returns cached plugin and provenance data for verification func (i *HTTPInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) { if !i.SupportsVerification() { - return nil, nil, "", fmt.Errorf("verification not supported for this source") + return nil, nil, "", errors.New("verification not supported for this source") } // Download plugin data once and cache it diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index 7f7e6cef6..efbca90c9 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -53,16 +53,16 @@ var fakePluginB64 = "H4sIAAAAAAAAA+3SQUvDMBgG4Jz7K0LwapdvSxrwJig6mCKC5xHabBaXdDS func TestStripName(t *testing.T) { if stripPluginName("fake-plugin-0.0.1.tar.gz") != "fake-plugin" { - t.Errorf("name does not match expected value") + t.Error("name does not match expected value") } if stripPluginName("fake-plugin-0.0.1.tgz") != "fake-plugin" { - t.Errorf("name does not match expected value") + t.Error("name does not match expected value") } if stripPluginName("fake-plugin.tgz") != "fake-plugin" { - t.Errorf("name does not match expected value") + t.Error("name does not match expected value") } if stripPluginName("fake-plugin.tar.gz") != "fake-plugin" { - t.Errorf("name does not match expected value") + t.Error("name does not match expected value") } } @@ -124,7 +124,6 @@ func TestHTTPInstaller(t *testing.T) { } else if err.Error() != "plugin already exists" { t.Fatalf("expected error for plugin exists, got (%v)", err) } - } func TestHTTPInstallerNonExistentVersion(t *testing.T) { @@ -150,14 +149,13 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) { // inject fake http client responding with error httpInstaller.getter = &TestHTTPGetter{ - MockError: fmt.Errorf("failed to download plugin for some reason"), + MockError: errors.New("failed to download plugin for some reason"), } // attempt to install the plugin if err := Install(i); err == nil { t.Fatal("expected error from http client") } - } func TestHTTPInstallerUpdate(t *testing.T) { @@ -297,7 +295,6 @@ func TestExtract(t *testing.T) { t.Fatalf("Expected %s to have %o mode but has %o (umask: %o)", readmeFullPath, expectedReadmePerm, info.Mode().Perm(), currentUmask) } - } func TestCleanJoin(t *testing.T) { @@ -327,11 +324,9 @@ func TestCleanJoin(t *testing.T) { t.Errorf("Test %d: Expected %q but got %q", i, fixture.expect, out) } } - } func TestMediaTypeToExtension(t *testing.T) { - for mt, shouldPass := range map[string]bool{ "": false, "application/gzip": true, @@ -345,7 +340,7 @@ func TestMediaTypeToExtension(t *testing.T) { t.Errorf("Media type %q failed test", mt) } if shouldPass && ext == "" { - t.Errorf("Expected an extension but got empty string") + t.Error("Expected an extension but got empty string") } if !shouldPass && len(ext) != 0 { t.Error("Expected extension to be empty for unrecognized type") diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index e3975c2d7..a7ca35ad4 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -16,6 +16,7 @@ limitations under the License. package installer import ( + "context" "errors" "fmt" "log/slog" @@ -72,7 +73,6 @@ type VerificationResult struct { // InstallWithOptions installs a plugin with options. func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) { - if err := os.MkdirAll(filepath.Dir(i.Path()), 0755); err != nil { return nil, err } @@ -87,7 +87,7 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) if opts.Verify { verifier, ok := i.(Verifier) if !ok || !verifier.SupportsVerification() { - return nil, fmt.Errorf("--verify is only supported for plugin tarballs (.tgz files)") + return nil, errors.New("--verify is only supported for plugin tarballs (.tgz files)") } // Get verification data (works for both memory and file-based installers) @@ -98,24 +98,23 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) // Check if provenance data exists if len(provData) == 0 { - // No .prov file found - emit warning but continue installation - fmt.Fprintf(os.Stderr, "WARNING: No provenance file found for plugin. Plugin is not signed and cannot be verified.\n") - } else { - // Provenance data exists - verify the plugin - verification, err := plugin.VerifyPlugin(archiveData, provData, filename, opts.Keyring) - if err != nil { - return nil, fmt.Errorf("plugin verification failed: %w", err) - } + return nil, errors.New("plugin verification failed: no provenance file (.prov) found") + } - // Collect verification info - result = &VerificationResult{ - SignedBy: make([]string, 0), - Fingerprint: fmt.Sprintf("%X", verification.SignedBy.PrimaryKey.Fingerprint), - FileHash: verification.FileHash, - } - for name := range verification.SignedBy.Identities { - result.SignedBy = append(result.SignedBy, name) - } + // Provenance data exists - verify the plugin + verification, err := plugin.VerifyPlugin(archiveData, provData, filename, opts.Keyring) + if err != nil { + return nil, fmt.Errorf("plugin verification failed: %w", err) + } + + // Collect verification info + result = &VerificationResult{ + SignedBy: make([]string, 0), + Fingerprint: fmt.Sprintf("%X", verification.SignedBy.PrimaryKey.Fingerprint), + FileHash: verification.FileHash, + } + for name := range verification.SignedBy.Identities { + result.SignedBy = append(result.SignedBy, name) } } @@ -137,7 +136,7 @@ func Update(i Installer) error { // NewForSource determines the correct Installer for the given source. func NewForSource(source, version string) (installer Installer, err error) { - if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) { + if strings.HasPrefix(source, registry.OCIScheme+"://") { // Source is an OCI registry reference installer, err = NewOCIInstaller(source) } else if isLocalReference(source) { @@ -191,12 +190,17 @@ func isRemoteHTTPArchive(source string) bool { } // If no suffix match, try HEAD request to check content type - res, err := http.Head(source) + req, err := http.NewRequestWithContext(context.Background(), http.MethodHead, source, http.NoBody) if err != nil { // If we get an error at the network layer, we can't install it. So // we return false. return false } + res, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer res.Body.Close() // Next, we look for the content type or content disposition headers to see // if they have matching extractors. diff --git a/internal/plugin/installer/installer_test.go b/internal/plugin/installer/installer_test.go index dcd76fe9c..50e8e1ee2 100644 --- a/internal/plugin/installer/installer_test.go +++ b/internal/plugin/installer/installer_test.go @@ -23,18 +23,18 @@ func TestIsRemoteHTTPArchive(t *testing.T) { source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz" if isRemoteHTTPArchive("/not/a/URL") { - t.Errorf("Expected non-URL to return false") + t.Error("Expected non-URL to return false") } // URLs with valid archive extensions are considered valid archives // even if the server is unreachable (optimization to avoid unnecessary HTTP requests) if !isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { - t.Errorf("URL with .tgz extension should be considered a valid archive") + t.Error("URL with .tgz extension should be considered a valid archive") } // Test with invalid extension and unreachable server if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.notanarchive") { - t.Errorf("Bad URL without valid extension should not succeed") + t.Error("Bad URL without valid extension should not succeed") } if !isRemoteHTTPArchive(source) { diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go index 1c8314282..71407380f 100644 --- a/internal/plugin/installer/local_installer.go +++ b/internal/plugin/installer/local_installer.go @@ -188,7 +188,7 @@ func (i *LocalInstaller) SupportsVerification() bool { // GetVerificationData loads plugin and provenance data from local files for verification func (i *LocalInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) { if !i.SupportsVerification() { - return nil, nil, "", fmt.Errorf("verification not supported for directories") + return nil, nil, "", errors.New("verification not supported for directories") } // Read and cache the plugin archive file diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 3ee8ab6d0..510c2880b 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -19,6 +19,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "errors" "os" "path/filepath" "testing" @@ -64,7 +65,7 @@ func TestLocalInstallerNotAFolder(t *testing.T) { if err == nil { t.Fatal("expected error") } - if err != ErrPluginNotADirectory { + if !errors.Is(err, ErrPluginNotADirectory) { t.Fatalf("expected error to equal: %q", err) } } diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go index 67f99b6f8..50d01522a 100644 --- a/internal/plugin/installer/oci_installer.go +++ b/internal/plugin/installer/oci_installer.go @@ -130,7 +130,7 @@ func (i *OCIInstaller) Install() error { // Check if this is a gzip compressed file if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b { - return fmt.Errorf("plugin data is not a gzip compressed archive") + return errors.New("plugin data is not a gzip compressed archive") } // Create cache directory diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go index 1280cf97d..1f25f4e76 100644 --- a/internal/plugin/installer/oci_installer_test.go +++ b/internal/plugin/installer/oci_installer_test.go @@ -82,7 +82,7 @@ command: "$HELM_PLUGIN_DIR/bin/%s" // Add executable execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName) execHeader := &tar.Header{ - Name: fmt.Sprintf("bin/%s", pluginName), + Name: "bin/" + pluginName, Mode: 0755, Size: int64(len(execContent)), Typeflag: tar.TypeReg, @@ -239,7 +239,7 @@ func TestNewOCIInstaller(t *testing.T) { if tt.expectError { if err == nil { - t.Errorf("expected error but got none") + t.Error("expected error but got none") } return } @@ -416,7 +416,7 @@ func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) { err = Install(installer) if tc.wantErr { if err == nil { - t.Errorf("Expected installation to fail, but it succeeded") + t.Error("Expected installation to fail, but it succeeded") } } else { if err != nil { @@ -526,13 +526,13 @@ func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) { // Verify plugin.yaml exists pluginYAMLPath := filepath.Join(tempDir, "plugin.yaml") if _, err := os.Stat(pluginYAMLPath); os.IsNotExist(err) { - t.Errorf("plugin.yaml not found after extraction") + t.Error("plugin.yaml not found after extraction") } // Verify bin directory exists binPath := filepath.Join(tempDir, "bin") if _, err := os.Stat(binPath); os.IsNotExist(err) { - t.Errorf("bin directory not found after extraction") + t.Error("bin directory not found after extraction") } // Verify executable exists and has correct permissions @@ -540,12 +540,12 @@ func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) { if info, err := os.Stat(execPath); err != nil { t.Errorf("executable not found: %v", err) } else if info.Mode()&0111 == 0 { - t.Errorf("file is not executable") + t.Error("file is not executable") } // Verify this would be recognized as a plugin if !isPlugin(tempDir) { - t.Errorf("extracted directory is not a valid plugin") + t.Error("extracted directory is not a valid plugin") } } @@ -798,7 +798,7 @@ func TestOCIInstaller_Install_ValidationErrors(t *testing.T) { t.Error("expected valid gzip data") } if !strings.Contains(tt.errorMsg, "is not a gzip compressed archive") { - t.Errorf("expected error message to contain 'is not a gzip compressed archive'") + t.Error("expected error message to contain 'is not a gzip compressed archive'") } } }) diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index d542a0f75..54f94c724 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -90,14 +90,14 @@ func TestVCSInstaller(t *testing.T) { // Install again to test plugin exists error if err := Install(i); err == nil { - t.Fatalf("expected error for plugin exists, got none") + t.Fatal("expected error for plugin exists, got none") } else if err.Error() != "plugin already exists" { t.Fatalf("expected error for plugin exists, got (%v)", err) } // Testing FindSource method, expect error because plugin code is not a cloned repository if _, err := FindSource(i.Path()); err == nil { - t.Fatalf("expected error for inability to find plugin source, got none") + t.Fatal("expected error for inability to find plugin source, got none") } else if err.Error() != "cannot get information about plugin source" { t.Fatalf("expected error for inability to find plugin source, got (%v)", err) } @@ -120,7 +120,7 @@ func TestVCSInstallerNonExistentVersion(t *testing.T) { } if err := Install(i); err == nil { - t.Fatalf("expected error for version does not exists, got none") + t.Fatal("expected error for version does not exists, got none") } else if strings.Contains(err.Error(), "Could not resolve host: github.com") { t.Skip("Unable to run test without Internet access") } else if err.Error() != fmt.Sprintf("requested version %q does not exist for plugin %q", version, source) { @@ -181,9 +181,8 @@ func TestVCSInstallerUpdate(t *testing.T) { } // Testing update for error if err := Update(vcsInstaller); err == nil { - t.Fatalf("expected error for plugin modified, got none") + t.Fatal("expected error for plugin modified, got none") } else if err.Error() != "plugin repo was modified" { t.Fatalf("expected error for plugin modified, got (%v)", err) } - } diff --git a/internal/plugin/installer/verification_test.go b/internal/plugin/installer/verification_test.go index 22f0a8308..e05cda7fd 100644 --- a/internal/plugin/installer/verification_test.go +++ b/internal/plugin/installer/verification_test.go @@ -16,10 +16,8 @@ limitations under the License. package installer import ( - "bytes" "crypto/sha256" "fmt" - "io" "os" "path/filepath" "strings" @@ -44,33 +42,49 @@ func TestInstallWithOptions_VerifyMissingProvenance(t *testing.T) { } defer os.RemoveAll(installer.Path()) - // Capture stderr to check warning message - oldStderr := os.Stderr - r, w, _ := os.Pipe() - os.Stderr = w - - // Install with verification enabled (should warn but succeed) + // Install with verification enabled should fail when .prov is missing result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"}) - // Restore stderr and read captured output - w.Close() - os.Stderr = oldStderr - var buf bytes.Buffer - io.Copy(&buf, r) - output := buf.String() - - // Should succeed with nil result (no verification performed) - if err != nil { - t.Fatalf("Expected installation to succeed despite missing .prov file, got error: %v", err) + // Should fail with a missing provenance error + if err == nil { + t.Fatal("Expected installation to fail when .prov file is missing and verification is enabled") + } + if !strings.Contains(err.Error(), "no provenance file") { + t.Errorf("Expected 'no provenance file' in error message, got: %v", err) } if result != nil { t.Errorf("Expected nil verification result when .prov file is missing, got: %+v", result) } - // Should contain warning message - expectedWarning := "WARNING: No provenance file found for plugin" - if !strings.Contains(output, expectedWarning) { - t.Errorf("Expected warning message '%s' in output, got: %s", expectedWarning, output) + // Plugin should NOT be installed + if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) { + t.Error("Plugin should not be installed when verification fails due to missing .prov") + } +} + +func TestInstallWithOptions_NoVerifyMissingProvenance(t *testing.T) { + ensure.HelmHome(t) + + // Create a temporary plugin tarball without .prov file + pluginDir := createTestPluginDir(t) + pluginTgz := createTarballFromPluginDir(t, pluginDir) + defer os.Remove(pluginTgz) + + // Create local installer + installer, err := NewLocalInstaller(pluginTgz) + if err != nil { + t.Fatalf("Failed to create installer: %v", err) + } + defer os.RemoveAll(installer.Path()) + + // Install with verification explicitly disabled should succeed without .prov + result, err := InstallWithOptions(installer, Options{Verify: false}) + + if err != nil { + t.Fatalf("Expected installation to succeed with --verify=false, got error: %v", err) + } + if result != nil { + t.Errorf("Expected nil verification result when verification is disabled, got: %+v", result) } // Plugin should be installed @@ -107,7 +121,7 @@ func TestInstallWithOptions_VerifyWithValidProvenance(t *testing.T) { // Should fail due to invalid signature (empty keyring) but we test that it gets past the hash check if err == nil { - t.Fatalf("Expected installation to fail with empty keyring") + t.Fatal("Expected installation to fail with empty keyring") } if !strings.Contains(err.Error(), "plugin verification failed") { t.Errorf("Expected plugin verification failed error, got: %v", err) @@ -118,7 +132,7 @@ func TestInstallWithOptions_VerifyWithValidProvenance(t *testing.T) { // Plugin should not be installed due to verification failure if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) { - t.Errorf("Plugin should not be installed when verification fails") + t.Error("Plugin should not be installed when verification fails") } } @@ -150,7 +164,7 @@ func TestInstallWithOptions_VerifyWithInvalidProvenance(t *testing.T) { // Should fail with verification error if err == nil { - t.Fatalf("Expected installation with invalid .prov file to fail") + t.Fatal("Expected installation with invalid .prov file to fail") } if result != nil { t.Errorf("Expected nil verification result when verification fails, got: %+v", result) @@ -164,7 +178,7 @@ func TestInstallWithOptions_VerifyWithInvalidProvenance(t *testing.T) { // Plugin should not be installed if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) { - t.Errorf("Plugin should not be installed when verification fails") + t.Error("Plugin should not be installed when verification fails") } } @@ -218,7 +232,7 @@ func TestInstallWithOptions_VerifyDirectoryNotSupported(t *testing.T) { // Should fail with verification not supported error if err == nil { - t.Fatalf("Expected installation to fail with verification not supported error") + t.Fatal("Expected installation to fail with verification not supported error") } if !strings.Contains(err.Error(), "--verify is only supported for plugin tarballs") { t.Errorf("Expected verification not supported error, got: %v", err) @@ -257,7 +271,7 @@ func TestInstallWithOptions_VerifyMismatchedProvenance(t *testing.T) { // Should fail with verification error if err == nil { - t.Fatalf("Expected installation to fail with hash mismatch") + t.Fatal("Expected installation to fail with hash mismatch") } if !strings.Contains(err.Error(), "plugin verification failed") { t.Errorf("Expected plugin verification failed error, got: %v", err) @@ -298,7 +312,7 @@ func TestInstallWithOptions_VerifyProvenanceAccessError(t *testing.T) { // Should fail with access error (either at stat level or during verification) if err == nil { - t.Fatalf("Expected installation to fail with provenance file access error") + t.Fatal("Expected installation to fail with provenance file access error") } // The error could be either "failed to access provenance file" or "plugin verification failed" // depending on when the permission error occurs diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index 2f051b99e..cfa618f39 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -19,6 +19,7 @@ import ( "bytes" "fmt" "io" + "log/slog" "os" "path/filepath" @@ -44,7 +45,6 @@ func peekAPIVersion(r io.Reader) (string, error) { } 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 @@ -64,7 +64,6 @@ func loadMetadataLegacy(metadataData []byte) (*Metadata, error) { } func loadMetadataV1(metadataData []byte) (*Metadata, error) { - var mv1 MetadataV1 d := yaml.NewDecoder(bytes.NewReader(metadataData)) d.KnownFields(true) @@ -108,7 +107,6 @@ type prototypePluginManager struct { } func newPrototypePluginManager() (*prototypePluginManager, error) { - cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build")) if err != nil { return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err) @@ -158,18 +156,27 @@ func LoadDir(dirname string) (Plugin, error) { return pm.CreatePlugin(dirname, m) } -// LoadAll loads all plugins found beneath the base directory. +func LogIgnorePluginLoadErrorFilterFunc(pluginYAML string, err error) error { + slog.Warn("failed to load plugin (ignoring)", slog.String("plugin_yaml", pluginYAML), slog.Any("error", err)) + return nil +} + +// errorFilterFunc is a function that can filter errors during plugin loading +type ErrorFilterFunc func(string, error) error + +// LoadAllDir load all plugins found beneath the base directory, using the provided error filter to determine whether to fail on individual plugin load errors. // // This scans only one directory level. -func LoadAll(basedir string) ([]Plugin, error) { - var plugins []Plugin - // We want basedir/*/plugin.yaml +func LoadAllDir(basedir string, errorFilter ErrorFilterFunc) ([]Plugin, error) { + // We want /*/plugin.yaml scanpath := filepath.Join(basedir, "*", PluginFileName) matches, err := filepath.Glob(scanpath) if err != nil { return nil, fmt.Errorf("failed to search for plugins in %q: %w", scanpath, err) } + plugins := make([]Plugin, 0, len(matches)) + // empty dir should load if len(matches) == 0 { return plugins, nil @@ -179,9 +186,12 @@ func LoadAll(basedir string) ([]Plugin, error) { dir := filepath.Dir(yamlFile) p, err := LoadDir(dir) if err != nil { - return plugins, err + if errNew := errorFilter(yamlFile, err); errNew != nil { + return plugins, errNew + } + } else { + plugins = append(plugins, p) } - plugins = append(plugins, p) } return plugins, detectDuplicates(plugins) } @@ -193,8 +203,12 @@ type findFunc func(pluginsDir string) ([]Plugin, error) type filterFunc func(Plugin) bool // FindPlugins returns a list of plugins that match the descriptor +// Errors loading a plugin are ignored with a warning func FindPlugins(pluginsDirs []string, descriptor Descriptor) ([]Plugin, error) { - return findPlugins(pluginsDirs, LoadAll, makeDescriptorFilter(descriptor)) + loadAllIgnoreErrors := func(pluginsDir string) ([]Plugin, error) { + return LoadAllDir(pluginsDir, LogIgnorePluginLoadErrorFilterFunc) + } + return findPlugins(pluginsDirs, loadAllIgnoreErrors, makeDescriptorFilter(descriptor)) } // findPlugins is the internal implementation that uses the find and filter functions @@ -212,7 +226,6 @@ func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([] found = append(found, p) } } - } return found, nil @@ -225,7 +238,6 @@ func makeDescriptorFilter(descriptor Descriptor) filterFunc { // If name is specified, it must match if descriptor.Name != "" && p.Metadata().Name != descriptor.Name { return false - } // If type is specified, it must match if descriptor.Type != "" && p.Metadata().Type != descriptor.Type { @@ -237,7 +249,11 @@ func makeDescriptorFilter(descriptor Descriptor) filterFunc { // FindPlugin returns a single plugin that matches the descriptor func FindPlugin(dirs []string, descriptor Descriptor) (Plugin, error) { - plugins, err := FindPlugins(dirs, descriptor) + loadAllIgnoreErrors := func(pluginsDir string) ([]Plugin, error) { + return LoadAllDir(pluginsDir, LogIgnorePluginLoadErrorFilterFunc) + } + + plugins, err := findPlugins(dirs, loadAllIgnoreErrors, makeDescriptorFilter(descriptor)) if err != nil { return nil, err } diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index e84905248..d6ca472f3 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -17,7 +17,6 @@ package plugin import ( "bytes" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -63,7 +62,6 @@ name: "test-plugin" } func TestLoadDir(t *testing.T) { - makeMetadata := func(apiVersion string) Metadata { usage := "hello [params]..." if apiVersion == "legacy" { @@ -71,7 +69,7 @@ func TestLoadDir(t *testing.T) { } return Metadata{ APIVersion: apiVersion, - Name: fmt.Sprintf("hello-%s", apiVersion), + Name: "hello-" + apiVersion, Version: "0.1.0", Type: "cli/v1", Runtime: "subprocess", @@ -205,16 +203,16 @@ func TestDetectDuplicates(t *testing.T) { } } -func TestLoadAll(t *testing.T) { - // Verify that empty dir loads: - { - plugs, err := LoadAll("testdata") - require.NoError(t, err) - assert.Len(t, plugs, 0) - } +func TestLoadAllDir_Empty(t *testing.T) { + emptyDir := t.TempDir() + plugs, err := LoadAllDir(emptyDir, func(_ string, err error) error { return err }) + require.NoError(t, err) + assert.Empty(t, plugs) +} +func TestLoadAllPluginsDir(t *testing.T) { basedir := "testdata/plugdir/good" - plugs, err := LoadAll(basedir) + plugs, err := LoadAllDir(basedir, func(_ string, err error) error { return err }) require.NoError(t, err) require.NotEmpty(t, plugs, "expected plugins to be loaded from %s", basedir) @@ -233,7 +231,7 @@ func TestLoadAll(t *testing.T) { assert.Contains(t, plugsMap, "postrenderer-v1") } -func TestFindPlugins(t *testing.T) { +func TestLoadAllPluginsDir_Zero(t *testing.T) { cases := []struct { name string plugdirs string @@ -241,28 +239,20 @@ func TestFindPlugins(t *testing.T) { }{ { name: "plugdirs is empty", - plugdirs: "", - expected: 0, + plugdirs: t.TempDir(), }, { name: "plugdirs isn't dir", plugdirs: "./plugin_test.go", - expected: 0, }, { name: "plugdirs doesn't have plugin", plugdirs: ".", - expected: 0, - }, - { - name: "normal", - plugdirs: "./testdata/plugdir/good", - expected: 7, }, } for _, c := range cases { t.Run(t.Name(), func(t *testing.T) { - plugin, err := LoadAll(c.plugdirs) + plugin, err := LoadAllDir(c.plugdirs, func(_ string, err error) error { return err }) require.NoError(t, err) assert.Len(t, plugin, c.expected, "expected %d plugins, got %d", c.expected, len(plugin)) }) @@ -338,6 +328,7 @@ runtime: subprocess "correct name field": { yaml: `apiVersion: v1 name: my-plugin +version: 1.0.0 type: cli/v1 runtime: subprocess `, diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index 4e019f0b3..562861b1c 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -19,9 +19,17 @@ import ( "errors" "fmt" + "github.com/Masterminds/semver/v3" + "helm.sh/helm/v4/internal/plugin/schema" ) +// isValidSemver checks if the given string is a valid semantic version +func isValidSemver(v string) bool { + _, err := semver.StrictNewVersion(v) + return err == nil +} + // Metadata of a plugin, converted from the "on-disk" legacy or v1 plugin.yaml // Specifically, Config and RuntimeConfig are converted to their respective types based on the plugin type and runtime type Metadata struct { @@ -57,24 +65,29 @@ func (m Metadata) Validate() error { errs = append(errs, fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name)) } + // Require version to be valid semver if specified + if m.Version != "" && !isValidSemver(m.Version) { + errs = append(errs, fmt.Errorf("invalid plugin version %q: must be valid semver", m.Version)) + } + if m.APIVersion == "" { - errs = append(errs, fmt.Errorf("empty APIVersion")) + errs = append(errs, errors.New("empty APIVersion")) } if m.Type == "" { - errs = append(errs, fmt.Errorf("empty type field")) + errs = append(errs, errors.New("empty type field")) } if m.Runtime == "" { - errs = append(errs, fmt.Errorf("empty runtime field")) + errs = append(errs, errors.New("empty runtime field")) } if m.Config == nil { - errs = append(errs, fmt.Errorf("missing config field")) + errs = append(errs, errors.New("missing config field")) } if m.RuntimeConfig == nil { - errs = append(errs, fmt.Errorf("missing runtimeConfig field")) + errs = append(errs, errors.New("missing runtimeConfig field")) } // Validate the config itself diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go index 3cd1a50cd..26132a783 100644 --- a/internal/plugin/metadata_legacy.go +++ b/internal/plugin/metadata_legacy.go @@ -16,6 +16,7 @@ limitations under the License. package plugin import ( + "errors" "fmt" "strings" "unicode" @@ -49,7 +50,8 @@ type MetadataLegacy struct { PlatformCommand []PlatformCommand `yaml:"platformCommand"` // Command is the plugin command, as a single string. - // DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins. + // + // Deprecated: Use PlatformCommand instead. Removed in subprocess/v1 plugins. Command string `yaml:"command"` // IgnoreFlags ignores any flags passed in from Helm @@ -59,7 +61,8 @@ type MetadataLegacy struct { PlatformHooks PlatformHooks `yaml:"platformHooks"` // Hooks are commands that will run on plugin events, as a single string. - // DEPRECATED: Use PlatformHooks instead. Removed in subprocess/v1 plugins. + // + // Deprecated: Use PlatformHooks instead. Removed in subprocess/v1 plugins. Hooks Hooks `yaml:"hooks"` // Downloaders field is used if the plugin supply downloader mechanism @@ -71,14 +74,19 @@ func (m *MetadataLegacy) Validate() error { if !validPluginName.MatchString(m.Name) { return fmt.Errorf("invalid plugin name %q: must contain only a-z, A-Z, 0-9, _ and -", m.Name) } + + if m.Version != "" && !isValidSemver(m.Version) { + return fmt.Errorf("invalid plugin version %q: must be valid semver", m.Version) + } + m.Usage = sanitizeString(m.Usage) if len(m.PlatformCommand) > 0 && len(m.Command) > 0 { - return fmt.Errorf("both platformCommand and command are set") + return errors.New("both platformCommand and command are set") } if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 { - return fmt.Errorf("both platformHooks and hooks are set") + return errors.New("both platformHooks and hooks are set") } // Validate downloader plugins diff --git a/internal/plugin/metadata_legacy_test.go b/internal/plugin/metadata_legacy_test.go index 9421e98b5..0ecb7e619 100644 --- a/internal/plugin/metadata_legacy_test.go +++ b/internal/plugin/metadata_legacy_test.go @@ -26,6 +26,10 @@ func TestMetadataLegacyValidate(t *testing.T) { "valid metadata": { Name: "myplugin", }, + "valid metadata (empty version)": { + Name: "myplugin", + Version: "", + }, "valid with command": { Name: "myplugin", Command: "echo hello", @@ -59,6 +63,13 @@ func TestMetadataLegacyValidate(t *testing.T) { }, }, }, + "valid with version": { + Name: "myplugin", + Version: "1.0.0", + }, + "valid with empty version": { + Name: "myplugin", + }, } for testName, metadata := range testsValid { @@ -116,6 +127,14 @@ func TestMetadataLegacyValidate(t *testing.T) { }, }, }, + "path traversal version": { + Name: "myplugin", + Version: "../../../../tmp/evil", + }, + "invalid version": { + Name: "myplugin", + Version: "not-a-version", + }, } for testName, metadata := range testsInvalid { diff --git a/internal/plugin/metadata_test.go b/internal/plugin/metadata_test.go index 145ef5101..6113f30c7 100644 --- a/internal/plugin/metadata_test.go +++ b/internal/plugin/metadata_test.go @@ -18,10 +18,11 @@ package plugin import ( "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestValidatePluginData(t *testing.T) { - // A mock plugin with no commands mockNoCommand := mockSubprocessCLIPlugin(t, "foo") mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{ @@ -72,6 +73,43 @@ func TestValidatePluginData(t *testing.T) { } } +func TestMetadataValidateVersion(t *testing.T) { + testValid := map[string]struct { + version string + }{ + "valid semver": {version: "1.0.0"}, + "valid semver with prerelease": {version: "1.2.3-alpha.1+build.123"}, + "empty version": {version: ""}, + } + + testInvalid := map[string]struct { + version string + }{ + "valid semver with v prefix": {version: "v1.0.0"}, + "path traversal": {version: "../../../../tmp/evil"}, + "path traversal in version": {version: "1.0.0/../../etc"}, + "not a version": {version: "not-a-version"}, + } + + for name, tc := range testValid { + t.Run(name, func(t *testing.T) { + m := mockSubprocessCLIPlugin(t, "testplugin") + m.metadata.Version = tc.version + err := m.Metadata().Validate() + assert.NoError(t, err) + }) + } + + for name, tc := range testInvalid { + t.Run(name, func(t *testing.T) { + m := mockSubprocessCLIPlugin(t, "testplugin") + m.metadata.Version = tc.version + err := m.Metadata().Validate() + assert.ErrorContains(t, err, "invalid plugin version") + }) + } +} + func TestMetadataValidateMultipleErrors(t *testing.T) { // Create metadata with multiple validation issues metadata := Metadata{ diff --git a/internal/plugin/metadata_v1.go b/internal/plugin/metadata_v1.go index 81dbc2e20..81d4a8a70 100644 --- a/internal/plugin/metadata_v1.go +++ b/internal/plugin/metadata_v1.go @@ -16,6 +16,7 @@ limitations under the License. package plugin import ( + "errors" "fmt" ) @@ -48,7 +49,14 @@ type MetadataV1 struct { func (m *MetadataV1) Validate() error { if !validPluginName.MatchString(m.Name) { - return fmt.Errorf("invalid plugin `name`") + return errors.New("invalid plugin `name`") + } + + if m.Version == "" { + return errors.New("plugin `version` is required") + } + if !isValidSemver(m.Version) { + return fmt.Errorf("invalid plugin `version` %q: must be valid semver", m.Version) } if m.APIVersion != "v1" { @@ -56,11 +64,11 @@ func (m *MetadataV1) Validate() error { } if m.Type == "" { - return fmt.Errorf("`type` missing") + return errors.New("`type` missing") } if m.Runtime == "" { - return fmt.Errorf("`runtime` missing") + return errors.New("`runtime` missing") } return nil diff --git a/internal/plugin/metadata_v1_test.go b/internal/plugin/metadata_v1_test.go new file mode 100644 index 000000000..17a02dac0 --- /dev/null +++ b/internal/plugin/metadata_v1_test.go @@ -0,0 +1,85 @@ +/* +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 TestMetadataV1ValidateVersion(t *testing.T) { + base := func() MetadataV1 { + return MetadataV1{ + APIVersion: "v1", + Name: "myplugin", + Type: "cli/v1", + Runtime: "subprocess", + Version: "1.0.0", + } + } + + testsValid := map[string]string{ + "simple version": "1.0.0", + "with prerelease": "1.2.3-alpha.1", + "with build meta": "1.2.3+build.123", + "full prerelease": "1.2.3-alpha.1+build.123", + } + + for name, version := range testsValid { + t.Run("valid/"+name, func(t *testing.T) { + m := base() + m.Version = version + assert.NoError(t, m.Validate()) + }) + } + + testsInvalid := map[string]struct { + version string + errMsg string + }{ + "empty version": { + version: "", + errMsg: "plugin `version` is required", + }, + "v prefix": { + version: "v1.0.0", + errMsg: "invalid plugin `version` \"v1.0.0\": must be valid semver", + }, + "path traversal": { + version: "../../../../tmp/evil", + errMsg: "invalid plugin `version`", + }, + "path traversal etc": { + version: "../../../etc/passwd", + errMsg: "invalid plugin `version`", + }, + "not a version": { + version: "not-a-version", + errMsg: "invalid plugin `version`", + }, + } + + for name, tc := range testsInvalid { + t.Run("invalid/"+name, func(t *testing.T) { + m := base() + m.Version = tc.version + err := m.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errMsg) + }) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 132b1739e..789e583ae 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -77,5 +77,5 @@ type Output struct { // validPluginName is a regular expression that validates plugin names. // -// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-. +// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, _ and -. var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$") diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index ae0b343f3..2e3f274f2 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -82,7 +82,7 @@ func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginR return &SubprocessPluginRuntime{ metadata: Metadata{ Name: pluginName, - Version: "v0.1.2", + Version: "0.1.2", Type: "cli/v1", APIVersion: "v1", Runtime: "subprocess", diff --git a/internal/plugin/plugin_type_registry_test.go b/internal/plugin/plugin_type_registry_test.go index 22f26262d..157c2f1d7 100644 --- a/internal/plugin/plugin_type_registry_test.go +++ b/internal/plugin/plugin_type_registry_test.go @@ -28,7 +28,6 @@ func TestMakeOutputMessage(t *testing.T) { ptm := pluginTypesIndex["getter/v1"] outputType := reflect.Zero(ptm.outputType).Interface() assert.IsType(t, schema.OutputMessageGetterV1{}, outputType) - } func TestMakeConfig(t *testing.T) { diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go index cd9a02535..ffa108a08 100644 --- a/internal/plugin/runtime_extismv1.go +++ b/internal/plugin/runtime_extismv1.go @@ -99,7 +99,6 @@ type RuntimeExtismV1 struct { var _ Runtime = (*RuntimeExtismV1)(nil) func (r *RuntimeExtismV1) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) { - rc, ok := metadata.RuntimeConfig.(*RuntimeConfigExtismV1) if !ok { return nil, fmt.Errorf("invalid extism/v1 plugin runtime config type: %T", metadata.RuntimeConfig) @@ -139,7 +138,6 @@ func (p *ExtismV1PluginRuntime) Dir() string { } func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Output, error) { - var tmpDir string if p.rc.FileSystem.CreateTempDir { tmpDirInner, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*") diff --git a/internal/plugin/runtime_extismv1_test.go b/internal/plugin/runtime_extismv1_test.go index 8d9c55195..9451c693b 100644 --- a/internal/plugin/runtime_extismv1_test.go +++ b/internal/plugin/runtime_extismv1_test.go @@ -46,7 +46,7 @@ func buildLoadExtismPlugin(t *testing.T, dir string) pluginRaw { require.NoError(t, err) require.Equal(t, "extism/v1", m.Runtime, "expected plugin runtime to be extism/v1") - cmd := exec.Command("make", "-C", dir) + cmd := exec.CommandContext(t.Context(), "make", "-C", dir) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr require.NoError(t, cmd.Run(), "failed to build plugin in %q", dir) @@ -79,7 +79,7 @@ func TestRuntimeExtismV1InvokePlugin(t *testing.T) { Name: "Phippy", }, }) - require.Nil(t, err) + require.NoError(t, err) msg := output.Message.(schema.OutputMessageTestV1) assert.Equal(t, "Hello, Phippy! (6)", msg.Greeting) diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index c836c1c6d..3c0af4a4e 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -96,14 +96,14 @@ func (r *SubprocessPluginRuntime) Metadata() Metadata { return r.metadata } -func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Output, error) { +func (r *SubprocessPluginRuntime) Invoke(ctx context.Context, input *Input) (*Output, error) { switch input.Message.(type) { case schema.InputMessageCLIV1: - return r.runCLI(input) + return r.runCLI(ctx, input) case schema.InputMessageGetterV1: - return r.runGetter(input) + return r.runGetter(ctx, input) case schema.InputMessagePostRendererV1: - return r.runPostrenderer(input) + return r.runPostrenderer(ctx, input) default: return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type) } @@ -113,12 +113,12 @@ func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Outp // This method allows execution with different command/args than the plugin's default func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error { mainCmdExp := os.ExpandEnv(main) - cmd := exec.Command(mainCmdExp, argv...) + cmd := exec.CommandContext(context.Background(), mainCmdExp, argv...) cmd.Env = slices.Clone(os.Environ()) cmd.Env = append( cmd.Env, - fmt.Sprintf("HELM_PLUGIN_NAME=%s", r.metadata.Name), - fmt.Sprintf("HELM_PLUGIN_DIR=%s", r.pluginDir)) + "HELM_PLUGIN_NAME="+r.metadata.Name, + "HELM_PLUGIN_DIR="+r.pluginDir) cmd.Env = append(cmd.Env, env...) cmd.Stdin = stdin @@ -149,7 +149,7 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { return err } - cmd := exec.Command(main, argv...) + cmd := exec.CommandContext(context.Background(), main, argv...) cmd.Env = FormatEnv(env) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -189,7 +189,7 @@ func executeCmd(prog *exec.Cmd, pluginName string) error { return nil } -func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { +func (r *SubprocessPluginRuntime) runCLI(ctx context.Context, input *Input) (*Output, error) { if _, ok := input.Message.(schema.InputMessageCLIV1); !ok { return nil, fmt.Errorf("plugin %q input message does not implement InputMessageCLIV1", r.metadata.Name) } @@ -209,7 +209,7 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { return nil, fmt.Errorf("failed to prepare plugin command: %w", err) } - cmd := exec.Command(command, args...) + cmd := exec.CommandContext(ctx, command, args...) cmd.Env = FormatEnv(env) cmd.Stdin = input.Stdin @@ -226,7 +226,7 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { }, nil } -func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) { +func (r *SubprocessPluginRuntime) runPostrenderer(ctx context.Context, input *Input) (*Output, error) { if _, ok := input.Message.(schema.InputMessagePostRendererV1); !ok { return nil, fmt.Errorf("plugin %q input message does not implement InputMessagePostRendererV1", r.metadata.Name) } @@ -244,7 +244,8 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) return nil, fmt.Errorf("failed to prepare plugin command: %w", err) } - cmd := exec.Command( + cmd := exec.CommandContext( + ctx, command, args...) diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go index fa6f470a9..3f07fa764 100644 --- a/internal/plugin/runtime_subprocess_getter.go +++ b/internal/plugin/runtime_subprocess_getter.go @@ -17,6 +17,7 @@ package plugin import ( "bytes" + "context" "fmt" "log/slog" "maps" @@ -24,6 +25,7 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "helm.sh/helm/v4/internal/plugin/schema" ) @@ -39,7 +41,7 @@ func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) * } // TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv? -func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { +func (r *SubprocessPluginRuntime) runGetter(ctx context.Context, input *Input) (*Output, error) { msg, ok := (input.Message).(schema.InputMessageGetterV1) if !ok { return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input) @@ -63,7 +65,7 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { env["HELM_PLUGIN_DIR"] = r.pluginDir env["HELM_PLUGIN_USERNAME"] = msg.Options.Username env["HELM_PLUGIN_PASSWORD"] = msg.Options.Password - env["HELM_PLUGIN_PASS_CREDENTIALS_ALL"] = fmt.Sprintf("%t", msg.Options.PassCredentialsAll) + env["HELM_PLUGIN_PASS_CREDENTIALS_ALL"] = strconv.FormatBool(msg.Options.PassCredentialsAll) command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}, env) if err != nil { @@ -80,7 +82,8 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout pluginCommand := filepath.Join(r.pluginDir, command) - cmd := exec.Command( + cmd := exec.CommandContext( + ctx, pluginCommand, args...) cmd.Env = FormatEnv(env) diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go index 271e79661..c651dd3f7 100644 --- a/internal/plugin/runtime_subprocess_test.go +++ b/internal/plugin/runtime_subprocess_test.go @@ -42,7 +42,7 @@ func mockSubprocessCLIPluginErrorExit(t *testing.T, pluginName string, exitCode md := Metadata{ Name: pluginName, - Version: "v0.1.2", + Version: "0.1.2", Type: "cli/v1", APIVersion: "v1", Runtime: "subprocess", diff --git a/internal/plugin/schema/getter.go b/internal/plugin/schema/getter.go index 2c5e81df1..f53ae29bf 100644 --- a/internal/plugin/schema/getter.go +++ b/internal/plugin/schema/getter.go @@ -14,6 +14,7 @@ package schema import ( + "errors" "fmt" "time" ) @@ -55,7 +56,7 @@ type ConfigGetterV1 struct { func (c *ConfigGetterV1) Validate() error { if len(c.Protocols) == 0 { - return fmt.Errorf("getter has no protocols") + return errors.New("getter has no protocols") } for i, protocol := range c.Protocols { if protocol == "" { diff --git a/internal/plugin/signing_info.go b/internal/plugin/signing_info.go index 61ee9cd15..ff27cc02d 100644 --- a/internal/plugin/signing_info.go +++ b/internal/plugin/signing_info.go @@ -23,7 +23,7 @@ import ( "path/filepath" "strings" - "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint + "github.com/ProtonMail/go-crypto/openpgp/clearsign" "helm.sh/helm/v4/pkg/helmpath" ) diff --git a/internal/plugin/subprocess_commands.go b/internal/plugin/subprocess_commands.go index 9a57ed891..211ce5ebf 100644 --- a/internal/plugin/subprocess_commands.go +++ b/internal/plugin/subprocess_commands.go @@ -16,7 +16,7 @@ limitations under the License. package plugin import ( - "fmt" + "errors" "os" "runtime" "strings" @@ -80,7 +80,7 @@ func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) { func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string, env map[string]string) (string, []string, error) { cmdParts, args := getPlatformCommand(cmds) if len(cmdParts) == 0 || cmdParts[0] == "" { - return "", nil, fmt.Errorf("no plugin command is applicable") + return "", nil, errors.New("no plugin command is applicable") } envMappingFunc := func(key string) string { return env[key] diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go index 8e9c1663e..dbce42f6a 100644 --- a/internal/plugin/subprocess_commands_test.go +++ b/internal/plugin/subprocess_commands_test.go @@ -48,7 +48,6 @@ func TestPrepareCommand(t *testing.T) { } func TestPrepareCommandExtraArgs(t *testing.T) { - cmdMain := "sh" cmdArgs := []string{"-c", "echo \"test\""} platformCommand := []PlatformCommand{ @@ -210,7 +209,7 @@ func TestPrepareCommandsNoMatch(t *testing.T) { env := map[string]string{} if _, _, err := PrepareCommands(cmds, true, []string{}, env); err == nil { - t.Fatalf("Expected error to be returned") + t.Fatal("Expected error to be returned") } } @@ -219,7 +218,7 @@ func TestPrepareCommandsNoCommands(t *testing.T) { env := map[string]string{} if _, _, err := PrepareCommands(cmds, true, []string{}, env); err == nil { - t.Fatalf("Expected error to be returned") + t.Fatal("Expected error to be returned") } } diff --git a/internal/plugin/verify.go b/internal/plugin/verify.go index 760a56e67..df31fc4ce 100644 --- a/internal/plugin/verify.go +++ b/internal/plugin/verify.go @@ -33,7 +33,7 @@ func VerifyPlugin(archiveData, provData []byte, filename, keyring string) (*prov return sig.Verify(archiveData, provData, filename) } -// isTarball checks if a file has a tarball extension +// IsTarball checks if a file has a tarball extension func IsTarball(filename string) bool { return filepath.Ext(filename) == ".gz" || filepath.Ext(filename) == ".tgz" } diff --git a/internal/release/v2/hook.go b/internal/release/v2/hook.go index 255b6ed01..5009ffbd0 100644 --- a/internal/release/v2/hook.go +++ b/internal/release/v2/hook.go @@ -86,7 +86,7 @@ type Hook struct { // Events are the events that this hook fires on. Events []HookEvent `json:"events,omitempty"` // LastRun indicates the date/time this was last run. - LastRun HookExecution `json:"last_run,omitempty"` + LastRun HookExecution `json:"last_run"` // Weight indicates the sort order for execution among similar Hook type Weight int `json:"weight,omitempty"` // DeletePolicies are the policies that indicate when to delete the hook @@ -133,7 +133,7 @@ type hookExecutionJSON struct { // It handles empty string time fields by treating them as zero values. func (h *HookExecution) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } diff --git a/internal/release/v2/hook_test.go b/internal/release/v2/hook_test.go index c3e2e0261..5a0867398 100644 --- a/internal/release/v2/hook_test.go +++ b/internal/release/v2/hook_test.go @@ -220,7 +220,7 @@ func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&exec) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/internal/release/v2/info.go b/internal/release/v2/info.go index 038f19409..fe2f09a46 100644 --- a/internal/release/v2/info.go +++ b/internal/release/v2/info.go @@ -36,6 +36,8 @@ type Info struct { Description string `json:"description,omitempty"` // Status is the current state of the release Status common.Status `json:"status,omitempty"` + // RollbackRevision is the revision that was rolled back to. Zero means not a rollback. + RollbackRevision int `json:"rollback_revision,omitempty"` // Contains the rendered templates/NOTES.txt if available Notes string `json:"notes,omitempty"` // Contains the deployed resources information @@ -44,20 +46,21 @@ type Info struct { // infoJSON is used for custom JSON marshaling/unmarshaling type infoJSON struct { - FirstDeployed *time.Time `json:"first_deployed,omitempty"` - LastDeployed *time.Time `json:"last_deployed,omitempty"` - Deleted *time.Time `json:"deleted,omitempty"` - Description string `json:"description,omitempty"` - Status common.Status `json:"status,omitempty"` - Notes string `json:"notes,omitempty"` - Resources map[string][]runtime.Object `json:"resources,omitempty"` + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface. // It handles empty string time fields by treating them as zero values. func (i *Info) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -95,6 +98,7 @@ func (i *Info) UnmarshalJSON(data []byte) error { } i.Description = tmp.Description i.Status = tmp.Status + i.RollbackRevision = tmp.RollbackRevision i.Notes = tmp.Notes i.Resources = tmp.Resources @@ -105,10 +109,11 @@ func (i *Info) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (i Info) MarshalJSON() ([]byte, error) { tmp := infoJSON{ - Description: i.Description, - Status: i.Status, - Notes: i.Notes, - Resources: i.Resources, + Description: i.Description, + Status: i.Status, + RollbackRevision: i.RollbackRevision, + Notes: i.Notes, + Resources: i.Resources, } if !i.FirstDeployed.IsZero() { diff --git a/internal/release/v2/info_test.go b/internal/release/v2/info_test.go index 32babb82f..5812c6560 100644 --- a/internal/release/v2/info_test.go +++ b/internal/release/v2/info_test.go @@ -87,6 +87,27 @@ func TestInfoMarshalJSON(t *testing.T) { }, expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, }, + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + }, + { + name: "zero rollback revision omitted", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + }, } for _, tt := range tests { @@ -203,6 +224,27 @@ func TestInfoUnmarshalJSON(t *testing.T) { Status: "", }, }, + { + name: "with rollback revision", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + }, + { + name: "zero rollback revision omitted", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + }, } for _, tt := range tests { @@ -219,6 +261,7 @@ func TestInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) assert.Equal(t, tt.expected.Description, info.Description) assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Notes, info.Notes) assert.Equal(t, tt.expected.Resources, info.Resources) }) @@ -252,6 +295,61 @@ func TestInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Notes, decoded.Notes) } +func TestInfoRollbackRevisionRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + }{ + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Rollback to 2", + Status: common.StatusDeployed, + RollbackRevision: 2, + }, + }, + { + name: "zero rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Normal install", + Status: common.StatusDeployed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, tt.info.RollbackRevision, decoded.RollbackRevision) + assert.Equal(t, tt.info.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, tt.info.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, tt.info.Status, decoded.Status) + assert.Equal(t, tt.info.Description, decoded.Description) + + // Verify omitempty behavior: zero rollback_revision should not appear in JSON + if tt.info.RollbackRevision == 0 { + var raw map[string]any + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + assert.NotContains(t, raw, "rollback_revision") + } + }) + } +} + func TestInfoEmptyStringRoundTrip(t *testing.T) { // This test specifically verifies that empty string time fields // are handled correctly during parsing @@ -272,7 +370,7 @@ func TestInfoEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&info) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/internal/release/v2/mock.go b/internal/release/v2/mock.go index b1b06cade..295eb219f 100644 --- a/internal/release/v2/mock.go +++ b/internal/release/v2/mock.go @@ -17,8 +17,8 @@ limitations under the License. package v2 import ( - "fmt" "math/rand" + "strconv" "time" v3 "helm.sh/helm/v4/internal/chart/v3" @@ -57,7 +57,7 @@ func Mock(opts *MockReleaseOptions) *Release { name := opts.Name if name == "" { - name = "testrelease-" + fmt.Sprint(rand.Intn(100)) + name = "testrelease-" + strconv.Itoa(rand.Intn(100)) } version := 1 @@ -124,7 +124,7 @@ func Mock(opts *MockReleaseOptions) *Release { Name: name, Info: info, Chart: ch, - Config: map[string]interface{}{"name": "value"}, + Config: map[string]any{"name": "value"}, Version: version, Namespace: namespace, Hooks: []*Hook{ diff --git a/internal/release/v2/release.go b/internal/release/v2/release.go index 85ffb0b2f..8b8f2ee07 100644 --- a/internal/release/v2/release.go +++ b/internal/release/v2/release.go @@ -36,7 +36,7 @@ type Release struct { Chart *chart.Chart `json:"chart,omitempty"` // Config is the set of extra Values added to the chart. // These values override the default values inside of the chart. - Config map[string]interface{} `json:"config,omitempty"` + Config map[string]any `json:"config,omitempty"` // Manifest is the string representation of the rendered template. Manifest string `json:"manifest,omitempty"` // Hooks are all of the hooks declared for this release. diff --git a/internal/release/v2/util/filter_test.go b/internal/release/v2/util/filter_test.go index 35236498a..8c7363b87 100644 --- a/internal/release/v2/util/filter_test.go +++ b/internal/release/v2/util/filter_test.go @@ -19,23 +19,19 @@ package util // import "helm.sh/helm/v4/internal/release/v2/util" import ( "testing" + "github.com/stretchr/testify/require" + rspb "helm.sh/helm/v4/internal/release/v2" "helm.sh/helm/v4/pkg/release/common" ) func TestFilterAny(t *testing.T) { ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases) - if len(ls) != 2 { - t.Fatalf("expected 2 results, got '%d'", len(ls)) - } + require.Len(t, ls, 2) r0, r1 := ls[0], ls[1] - switch { - case r0.Info.Status != common.StatusUninstalled: - t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - case r1.Info.Status != common.StatusUninstalled: - t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - } + require.Equal(t, common.StatusUninstalled, r0.Info.Status) + require.Equal(t, common.StatusUninstalled, r1.Info.Status) } func TestFilterAll(t *testing.T) { @@ -47,14 +43,9 @@ func TestFilterAll(t *testing.T) { }) ls := All(fn).Filter(releases) - if len(ls) != 1 { - t.Fatalf("expected 1 result, got '%d'", len(ls)) - } - - switch r0 := ls[0]; { - case r0.Version == 4: - t.Fatal("got release with status revision 4") - case r0.Info.Status == common.StatusUninstalled: - t.Fatal("got release with status UNINSTALLED") - } + require.Len(t, ls, 1) + + r0 := ls[0] + require.NotEqual(t, 4, r0.Version, "got release with status revision 4") + require.NotEqual(t, common.StatusUninstalled, r0.Info.Status, "got release with status UNINSTALLED") } diff --git a/internal/release/v2/util/kind_sorter.go b/internal/release/v2/util/kind_sorter.go index 71bacb228..dba35b6d2 100644 --- a/internal/release/v2/util/kind_sorter.go +++ b/internal/release/v2/util/kind_sorter.go @@ -137,7 +137,7 @@ func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.H return h } -func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o KindSortOrder) bool { +func lessByKind(_ any, _ any, kindA string, kindB string, o KindSortOrder) bool { ordering := make(map[string]int, len(o)) for v, k := range o { ordering[k] = v diff --git a/internal/release/v2/util/kind_sorter_test.go b/internal/release/v2/util/kind_sorter_test.go index 06418a5fc..dba19e287 100644 --- a/internal/release/v2/util/kind_sorter_test.go +++ b/internal/release/v2/util/kind_sorter_test.go @@ -20,6 +20,9 @@ import ( "bytes" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + release "helm.sh/helm/v4/internal/release/v2" ) @@ -193,21 +196,15 @@ func TestKindSorter(t *testing.T) { } { var buf bytes.Buffer t.Run(test.description, func(t *testing.T) { - if got, want := len(test.expected), len(manifests); got != want { - t.Fatalf("Expected %d names in order, got %d", want, got) - } + require.Len(t, manifests, len(test.expected), "Expected %d names in order", len(manifests)) defer buf.Reset() orig := manifests for _, r := range sortManifestsByKind(manifests, test.order) { buf.WriteString(r.Name) } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) - } + assert.Equal(t, test.expected, buf.String()) for i, manifest := range orig { - if manifest != manifests[i] { - t.Fatal("Expected input to sortManifestsByKind to stay the same") - } + require.Equal(t, manifest, manifests[i], "Expected input to sortManifestsByKind to stay the same") } }) } @@ -267,9 +264,7 @@ func TestKindSorterKeepOriginalOrder(t *testing.T) { for _, r := range sortManifestsByKind(manifests, test.order) { buf.WriteString(r.Name) } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) - } + assert.Equal(t, test.expected, buf.String()) }) } } @@ -289,9 +284,7 @@ func TestKindSorterNamespaceAgainstUnknown(t *testing.T) { expectedOrder := []Manifest{namespace, unknown} for i, manifest := range manifests { - if expectedOrder[i].Name != manifest.Name { - t.Errorf("Expected %s, got %s", expectedOrder[i].Name, manifest.Name) - } + assert.Equal(t, expectedOrder[i].Name, manifest.Name) } } @@ -326,22 +319,16 @@ func TestKindSorterForHooks(t *testing.T) { } { var buf bytes.Buffer t.Run(test.description, func(t *testing.T) { - if got, want := len(test.expected), len(hooks); got != want { - t.Fatalf("Expected %d names in order, got %d", want, got) - } + require.Len(t, hooks, len(test.expected), "Expected %d names in order", len(hooks)) defer buf.Reset() orig := hooks for _, r := range sortHooksByKind(hooks, test.order) { buf.WriteString(r.Name) } for i, hook := range orig { - if hook != hooks[i] { - t.Fatal("Expected input to sortHooksByKind to stay the same") - } - } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) + require.Equal(t, hook, hooks[i], "Expected input to sortHooksByKind to stay the same") } + assert.Equal(t, test.expected, buf.String()) }) } } diff --git a/internal/release/v2/util/manifest.go b/internal/release/v2/util/manifest.go index 20d097d9b..5dbcdaea5 100644 --- a/internal/release/v2/util/manifest.go +++ b/internal/release/v2/util/manifest.go @@ -21,6 +21,7 @@ import ( "regexp" "strconv" "strings" + "unicode" ) // SimpleHead defines what the structure of the head of a manifest file @@ -35,7 +36,16 @@ type SimpleHead struct { var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") -// SplitManifests takes a string of manifest and returns a map contains individual manifests +// SplitManifests takes a manifest string and returns a map containing individual manifests. +// +// **Note for Chart API v3**: This function (due to the regex above) has allowed _WRONG_ +// Go templates to be defined inside charts across the years. The generated text from Go +// templates may contain `---apiVersion: v1`, and this function magically splits this back +// to `---\napiVersion: v1`. This has caused issues recently after Helm 4 introduced +// kio.ParseAll to inject annotations when post-renderers are used. In Chart API v3, +// we should kill this regex with fire (or change it) and expose charts doing the wrong +// thing Go template-wise. Helm should say a big _NO_ to charts doing the wrong thing, +// with or without post-renderers. func SplitManifests(bigFile string) map[string]string { // Basically, we're quickly splitting a stream of YAML documents into an // array of YAML docs. The file name is just a place holder, but should be @@ -44,15 +54,15 @@ func SplitManifests(bigFile string) map[string]string { tpl := "manifest-%d" res := map[string]string{} // Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. - bigFileTmp := strings.TrimSpace(bigFile) + bigFileTmp := strings.TrimLeftFunc(bigFile, unicode.IsSpace) docs := sep.Split(bigFileTmp, -1) var count int for _, d := range docs { - if d == "" { + if strings.TrimSpace(d) == "" { continue } - d = strings.TrimSpace(d) + d = strings.TrimLeftFunc(d, unicode.IsSpace) res[fmt.Sprintf(tpl, count)] = d count = count + 1 } diff --git a/internal/release/v2/util/manifest_sorter_test.go b/internal/release/v2/util/manifest_sorter_test.go index 28f0b34cc..1c932d70c 100644 --- a/internal/release/v2/util/manifest_sorter_test.go +++ b/internal/release/v2/util/manifest_sorter_test.go @@ -17,16 +17,16 @@ limitations under the License. package util // import "helm.sh/helm/v4/internal/release/v2/util" import ( - "reflect" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" release "helm.sh/helm/v4/internal/release/v2" ) func TestSortManifests(t *testing.T) { - data := []struct { name []string path string @@ -139,56 +139,39 @@ metadata: } hs, generic, err := SortManifests(manifests, nil, InstallOrder) - if err != nil { - t.Fatalf("Unexpected error: %s", err) - } + require.NoError(t, err) // This test will fail if 'six' or 'seven' was added. - if len(generic) != 2 { - t.Errorf("Expected 2 generic manifests, got %d", len(generic)) - } + assert.Len(t, generic, 2) - if len(hs) != 4 { - t.Errorf("Expected 4 hooks, got %d", len(hs)) - } + assert.Len(t, hs, 4) for _, out := range hs { found := false for _, expect := range data { if out.Path == expect.path { found = true - if out.Path != expect.path { - t.Errorf("Expected path %s, got %s", expect.path, out.Path) - } + assert.Equal(t, expect.path, out.Path) nameFound := false for _, expectedName := range expect.name { if out.Name == expectedName { nameFound = true } } - if !nameFound { - t.Errorf("Got unexpected name %s", out.Name) - } + assert.True(t, nameFound, "Got unexpected name %s", out.Name) kindFound := false for _, expectedKind := range expect.kind { if out.Kind == expectedKind { kindFound = true } } - if !kindFound { - t.Errorf("Got unexpected kind %s", out.Kind) - } + assert.True(t, kindFound, "Got unexpected kind %s", out.Kind) expectedHooks := expect.hooks[out.Name] - if !reflect.DeepEqual(expectedHooks, out.Events) { - t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events) - } - + assert.Equal(t, expectedHooks, out.Events, "expected events: %v but got: %v", expectedHooks, out.Events) } } - if !found { - t.Errorf("Result not found: %v", out) - } + assert.True(t, found, "Result not found: %v", out) } // Verify the sort order @@ -220,8 +203,6 @@ metadata: sorted = sortManifestsByKind(sorted, InstallOrder) for i, m := range generic { - if m.Content != sorted[i].Content { - t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content) - } + assert.Equal(t, m.Content, sorted[i].Content) } } diff --git a/internal/release/v2/util/manifest_test.go b/internal/release/v2/util/manifest_test.go index 7fd332fbc..463ab5a4d 100644 --- a/internal/release/v2/util/manifest_test.go +++ b/internal/release/v2/util/manifest_test.go @@ -17,11 +17,20 @@ limitations under the License. package util // import "helm.sh/helm/v4/internal/release/v2/util" import ( - "reflect" "testing" + + "github.com/stretchr/testify/assert" ) -const mockManifestFile = ` +func TestSplitManifests(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "single doc with leading separator and whitespace", + input: ` --- apiVersion: v1 @@ -35,9 +44,9 @@ spec: - name: nemo-test image: fake-image cmd: fake-command -` - -const expectedManifest = `apiVersion: v1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 kind: Pod metadata: name: finding-nemo, @@ -47,15 +56,461 @@ spec: containers: - name: nemo-test image: fake-image - cmd: fake-command` + cmd: fake-command +`, + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "whitespace only", + input: " \n\n \n", + expected: map[string]string{}, + }, + { + name: "whitespace-only doc after separator is skipped", + input: "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\n---\n \n", + expected: map[string]string{ + "manifest-0": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1", + }, + }, + { + name: "single doc no separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + }, + }, + { + name: "two docs with proper separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + }, + + // |- (strip) + { + name: "block scalar strip (|-) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + }, + + // |+ (keep) + { + name: "block scalar keep (|+) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + }, -func TestSplitManifest(t *testing.T) { - manifests := SplitManifests(mockManifestFile) - if len(manifests) != 1 { - t.Errorf("Expected 1 manifest, got %v", len(manifests)) + // Multi-doc with block scalars: the regex consumes \s*\n before ---, + // so trailing newlines from non-last docs are stripped. + { + name: "multi-doc block scalar clip (|) before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + { + name: "multi-doc block scalar keep (|+) with 2 trailing newlines before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + + // **Note for Chart API v3**: The following tests exercise the lenient + // regex that splits `---apiVersion` back into separate documents. + // In Chart API v3, these inputs should return an _ERROR_ instead. + // See the comment on the SplitManifests function for more details. + { + name: "leading glued separator (---apiVersion)", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + }, + }, + { + name: "mid-content glued separator (---apiVersion)", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + { + name: "multiple glued separators", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, + { + name: "mixed glued and proper separators", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, } - expected := map[string]string{"manifest-0": expectedManifest} - if !reflect.DeepEqual(manifests, expected) { - t.Errorf("Expected %v, got %v", expected, manifests) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitManifests(tt.input) + assert.Equal(t, tt.expected, result, "SplitManifests() =\n%v\nwant:\n%v", result, tt.expected) + }) } } diff --git a/internal/release/v2/util/sorter_test.go b/internal/release/v2/util/sorter_test.go index 6cb876f69..82a53ae66 100644 --- a/internal/release/v2/util/sorter_test.go +++ b/internal/release/v2/util/sorter_test.go @@ -20,6 +20,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + rspb "helm.sh/helm/v4/internal/release/v2" "helm.sh/helm/v4/pkg/release/common" ) @@ -45,9 +47,7 @@ func tsRelease(name string, vers int, dur time.Duration, status common.Status) * func check(t *testing.T, by string, fn func(int, int) bool) { t.Helper() for i := len(releases) - 1; i > 0; i-- { - if fn(i, i-1) { - t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by) - } + assert.False(t, fn(i, i-1), "release at positions '(%d,%d)' not sorted by %s", i-1, i, by) } } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 3efe94f10..5f0c5b148 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -54,7 +54,6 @@ func New(chartpath, cachepath string, registryClient *registry.Client) *Resolver // Resolve resolves dependencies and returns a lock file with the resolution. func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string) (*chart.Lock, error) { - // Now we clone the dependencies, locking as we go. locked := make([]*chart.Dependency, len(reqs)) missing := []string{} @@ -146,10 +145,9 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string Version: version, }, }} - } else { // Retrieve list of tags for repository - ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name) + ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, registry.OCIScheme+"://"), d.Name) tags, err := r.registryClient.Tags(ref) if err != nil { return nil, fmt.Errorf("could not retrieve list of tags for repository %s: %w", d.Repository, err) diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 1e33837a9..547419e17 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -19,6 +19,9 @@ import ( "runtime" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/registry" ) @@ -149,34 +152,24 @@ func TestResolve(t *testing.T) { if tt.err { return } - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Fatalf("Expected error in test %q", tt.name) + require.Failf(t, "Expected error in test %q", tt.name) } - if h, err := HashReq(tt.req, tt.expect.Dependencies); err != nil { - t.Fatal(err) - } else if h != l.Digest { - t.Errorf("%q: hashes don't match.", tt.name) - } + h, err := HashReq(tt.req, tt.expect.Dependencies) + require.NoError(t, err) + assert.Equal(t, l.Digest, h, "%q: hashes don't match.", tt.name) // Check fields. - if len(l.Dependencies) != len(tt.req) { - t.Errorf("%s: wrong number of dependencies in lock", tt.name) - } + assert.Len(t, l.Dependencies, len(tt.req), "%s: wrong number of dependencies in lock", tt.name) d0 := l.Dependencies[0] e0 := tt.expect.Dependencies[0] - if d0.Name != e0.Name { - t.Errorf("%s: expected name %s, got %s", tt.name, e0.Name, d0.Name) - } - if d0.Repository != e0.Repository { - t.Errorf("%s: expected repo %s, got %s", tt.name, e0.Repository, d0.Repository) - } - if d0.Version != e0.Version { - t.Errorf("%s: expected version %s, got %s", tt.name, e0.Version, d0.Version) - } + assert.Equal(t, e0.Name, d0.Name, tt.name) + assert.Equal(t, e0.Repository, d0.Repository, tt.name) + assert.Equal(t, e0.Version, d0.Version, tt.name) }) } } @@ -231,13 +224,11 @@ func TestHashReq(t *testing.T) { {Name: "alpine", Version: tt.lockVersion, Repository: "http://localhost:8879/charts"}, } h, err := HashReq(req, lock) - if err != nil { - t.Fatal(err) - } - if !tt.wantError && expect != h { - t.Errorf("Expected %q, got %q", expect, h) - } else if tt.wantError && expect == h { - t.Errorf("Expected not %q, but same", expect) + require.NoError(t, err) + if !tt.wantError { + assert.Equal(t, expect, h) + } else { + assert.NotEqual(t, expect, h, "Expected not %q, but same", expect) } }) } @@ -293,18 +284,16 @@ func TestGetLocalPath(t *testing.T) { if tt.err { return } - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Fatalf("Expected error in test %q", tt.name) + require.Failf(t, "Expected error in test %q", tt.name) } expect := tt.expect if runtime.GOOS == "windows" { expect = tt.winExpect } - if p != expect { - t.Errorf("%q: expected %q, got %q", tt.name, expect, p) - } + assert.Equal(t, expect, p, tt.name) }) } } diff --git a/internal/sympath/walk_test.go b/internal/sympath/walk_test.go index 1eba8b996..b5bffbdab 100644 --- a/internal/sympath/walk_test.go +++ b/internal/sympath/walk_test.go @@ -24,6 +24,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type Node struct { @@ -80,21 +83,14 @@ func makeTree(t *testing.T) { walkTree(tree, tree.name, func(path string, n *Node) { if n.entries == nil { if n.symLinkedTo != "" { - if err := os.Symlink(n.symLinkedTo, path); err != nil { - t.Fatalf("makeTree: %v", err) - } + require.NoError(t, os.Symlink(n.symLinkedTo, path), "makeTree") } else { fd, err := os.Create(path) - if err != nil { - t.Fatalf("makeTree: %v", err) - return - } + require.NoError(t, err, "makeTree") fd.Close() } } else { - if err := os.Mkdir(path, 0770); err != nil { - t.Fatalf("makeTree: %v", err) - } + require.NoError(t, os.Mkdir(path, 0770), "makeTree") } }) } @@ -102,8 +98,8 @@ func makeTree(t *testing.T) { func checkMarks(t *testing.T, report bool) { t.Helper() walkTree(tree, tree.name, func(path string, n *Node) { - if n.marks != n.expectedMarks && report { - t.Errorf("node %s mark = %d; expected %d", path, n.marks, n.expectedMarks) + if report { + assert.Equal(t, n.expectedMarks, n.marks, "node %s", path) } n.marks = 0 }) @@ -137,16 +133,10 @@ func TestWalk(t *testing.T) { } // Expect no errors. err := Walk(tree.name, markFn) - if err != nil { - t.Fatalf("no error expected, found: %s", err) - } - if len(errors) != 0 { - t.Fatalf("unexpected errors: %s", errors) - } + require.NoError(t, err) + require.Empty(t, errors, "unexpected errors") checkMarks(t, true) // cleanup - if err := os.RemoveAll(tree.name); err != nil { - t.Errorf("removeTree: %v", err) - } + assert.NoError(t, os.RemoveAll(tree.name), "removeTree") } diff --git a/internal/third_party/dep/fs/fs_test.go b/internal/third_party/dep/fs/fs_test.go index 8f28c3af7..806e33c28 100644 --- a/internal/third_party/dep/fs/fs_test.go +++ b/internal/third_party/dep/fs/fs_test.go @@ -238,7 +238,6 @@ func TestCopyDirFail_SrcIsNotDir(t *testing.T) { if !errors.Is(err, errSrcNotDir) { t.Fatalf("expected %v error for CopyDir(%s, %s), got %s", errSrcNotDir, srcdir, dstdir, err) } - } func TestCopyDirFail_DstExists(t *testing.T) { @@ -491,7 +490,6 @@ func setupInaccessibleDir(t *testing.T, op func(dir string) error) func() { } func TestIsDir(t *testing.T) { - var currentUID = os.Getuid() if currentUID == 0 { @@ -544,7 +542,6 @@ func TestIsDir(t *testing.T) { } func TestIsSymlink(t *testing.T) { - var currentUID = os.Getuid() if currentUID == 0 { diff --git a/internal/tlsutil/tls.go b/internal/tlsutil/tls.go index 88f26d47b..e986e6909 100644 --- a/internal/tlsutil/tls.go +++ b/internal/tlsutil/tls.go @@ -112,7 +112,7 @@ func NewTLSConfig(options ...TLSConfigOption) (*tls.Config, error) { if len(to.caPEMBlock) > 0 { cp := x509.NewCertPool() if !cp.AppendCertsFromPEM(to.caPEMBlock) { - return nil, fmt.Errorf("failed to append certificates from pem block") + return nil, errors.New("failed to append certificates from pem block") } config.RootCAs = cp diff --git a/internal/tlsutil/tls_test.go b/internal/tlsutil/tls_test.go index f16eb218f..324b00969 100644 --- a/internal/tlsutil/tls_test.go +++ b/internal/tlsutil/tls_test.go @@ -19,6 +19,9 @@ package tlsutil import ( "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const tlsTestDir = "../../testdata" @@ -32,9 +35,7 @@ const ( func testfile(t *testing.T, file string) (path string) { t.Helper() path, err := filepath.Abs(filepath.Join(tlsTestDir, file)) - if err != nil { - t.Fatalf("error getting absolute path to test file %q: %v", file, err) - } + require.NoError(t, err, "error getting absolute path to test file %q", file) return path } @@ -50,38 +51,22 @@ func TestNewTLSConfig(t *testing.T) { WithCertKeyPairFiles(certFile, keyFile), WithCAFile(caCertFile), ) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) - if got := len(cfg.Certificates); got != 1 { - t.Fatalf("expecting 1 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") - } + require.Len(t, cfg.Certificates, 1) + require.False(t, cfg.InsecureSkipVerify, "insecure skip verify mismatch, expecting false") + require.NotNil(t, cfg.RootCAs, "mismatch tls RootCAs, expecting non-nil") } { cfg, err := NewTLSConfig( WithInsecureSkipVerify(insecureSkipTLSVerify), WithCAFile(caCertFile), ) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) - if got := len(cfg.Certificates); got != 0 { - t.Fatalf("expecting 0 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs == nil { - t.Fatalf("mismatch tls RootCAs, expecting non-nil") - } + require.Empty(t, cfg.Certificates) + require.False(t, cfg.InsecureSkipVerify, "insecure skip verify mismatch, expecting false") + require.NotNil(t, cfg.RootCAs, "mismatch tls RootCAs, expecting non-nil") } { @@ -89,18 +74,10 @@ func TestNewTLSConfig(t *testing.T) { WithInsecureSkipVerify(insecureSkipTLSVerify), WithCertKeyPairFiles(certFile, keyFile), ) - if err != nil { - t.Error(err) - } + assert.NoError(t, err) - if got := len(cfg.Certificates); got != 1 { - t.Fatalf("expecting 1 client certificates, got %d", got) - } - if cfg.InsecureSkipVerify { - t.Fatalf("insecure skip verify mismatch, expecting false") - } - if cfg.RootCAs != nil { - t.Fatalf("mismatch tls RootCAs, expecting nil") - } + require.Len(t, cfg.Certificates, 1) + require.False(t, cfg.InsecureSkipVerify, "insecure skip verify mismatch, expecting false") + require.Nil(t, cfg.RootCAs, "mismatch tls RootCAs, expecting nil") } } diff --git a/internal/urlutil/urlutil_test.go b/internal/urlutil/urlutil_test.go index 82acc40fe..d6b3f5256 100644 --- a/internal/urlutil/urlutil_test.go +++ b/internal/urlutil/urlutil_test.go @@ -16,7 +16,11 @@ limitations under the License. package urlutil -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestURLJoin(t *testing.T) { tests := []struct { @@ -31,11 +35,9 @@ func TestURLJoin(t *testing.T) { } for _, tt := range tests { - if got, err := URLJoin(tt.url, tt.paths...); err != nil { - t.Errorf("%s: error %q", tt.name, err) - } else if got != tt.expect { - t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got) - } + got, err := URLJoin(tt.url, tt.paths...) + assert.NoError(t, err, tt.name) + assert.Equal(t, tt.expect, got, tt.name) } } @@ -60,9 +62,7 @@ func TestEqual(t *testing.T) { {"%/1234", "%/123", false}, {"/1234", "%/1234", false}, } { - if tt.match != Equal(tt.a, tt.b) { - t.Errorf("Expected %q==%q to be %t", tt.a, tt.b, tt.match) - } + assert.Equal(t, tt.match, Equal(tt.a, tt.b), "Expected %q==%q to be %t", tt.a, tt.b, tt.match) } } @@ -74,8 +74,7 @@ func TestExtractHostname(t *testing.T) { "https://example.com:31337/not/with/a/bang/but/a/whimper": "example.com", } for start, expect := range tests { - if got, _ := ExtractHostname(start); got != expect { - t.Errorf("Got %q, expected %q", got, expect) - } + got, _ := ExtractHostname(start) + assert.Equal(t, expect, got) } } diff --git a/internal/version/clientgo.go b/internal/version/clientgo.go index ab2a38fd5..50a4fd5cb 100644 --- a/internal/version/clientgo.go +++ b/internal/version/clientgo.go @@ -17,7 +17,7 @@ limitations under the License. package version import ( - "fmt" + "errors" "runtime/debug" "slices" @@ -27,7 +27,7 @@ import ( func K8sIOClientGoModVersion() (string, error) { info, ok := debug.ReadBuildInfo() if !ok { - return "", fmt.Errorf("failed to read build info") + return "", errors.New("failed to read build info") } idx := slices.IndexFunc(info.Deps, func(m *debug.Module) bool { @@ -35,7 +35,7 @@ func K8sIOClientGoModVersion() (string, error) { }) if idx == -1 { - return "", fmt.Errorf("k8s.io/client-go not found in build info") + return "", errors.New("k8s.io/client-go not found in build info") } m := info.Deps[idx] diff --git a/internal/version/version.go b/internal/version/version.go index 3daf80893..572938c96 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -34,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.1" + version = "v4.2" // metadata is extra build time data metadata = "" @@ -77,7 +77,6 @@ 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) diff --git a/pkg/action/action.go b/pkg/action/action.go index c2a27940f..6e5d8c15a 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -18,6 +18,7 @@ package action import ( "bytes" + "context" "errors" "fmt" "io" @@ -27,6 +28,7 @@ import ( "path" "path/filepath" "slices" + "sort" "strings" "sync" "text/template" @@ -87,6 +89,33 @@ const ( DryRunServer DryRunStrategy = "server" ) +// PostRenderStrategy determines how hooks and regular templates are passed +// to the configured post-renderer. +type PostRenderStrategy string + +const ( + // PostRenderStrategyCombined sends hooks and regular templates together + // as a single stream to the post-renderer. This is the default in Helm 4. + PostRenderStrategyCombined PostRenderStrategy = "combined" + + // PostRenderStrategySeparate sends hooks and regular templates to the + // post-renderer in independent invocations. This avoids duplicate-resource + // errors from post-renderers that de-duplicate by resource identity + // (for example Kustomize) when the same resource appears in both a hook + // and a regular template. Passing hooks to post-renderers was introduced + // in Helm 4; Helm 3 never did so, which is why the issue only surfaces + // with the Helm 4 combined default. + PostRenderStrategySeparate PostRenderStrategy = "separate" + + // PostRenderStrategyNoHooks sends only regular templates to the + // post-renderer and leaves hooks untouched. This matches the Helm 3 + // behavior and is useful for post-renderers that declare transforms + // targeting template-only resources (for example Kustomize patches + // against a Deployment that exists in templates but not in hooks), + // which would otherwise fail against the hook stream. + PostRenderStrategyNoHooks PostRenderStrategy = "nohooks" +) + // Configuration injects the dependencies that all actions share. type Configuration struct { // RESTClientGetter is an interface that loads Kubernetes clients. @@ -159,15 +188,32 @@ func annotateAndMerge(files map[string]string) (string, error) { continue } - manifests, err := kio.ParseAll(content) - if err != nil { - return "", fmt.Errorf("parsing %s: %w", fname, err) + // For consistency with the non-post-renderers code path, we need + // to use releaseutil.SplitManifests here to split the file into + // individual documents before feeding them to kio.ParseAll. In + // Chart API before v3 this function had leniency for badly-written + // Go templates, so this must be preserved for older charts. + splitDocs := releaseutil.SplitManifests(content) + keys := make([]string, 0, len(splitDocs)) + for k := range splitDocs { + keys = append(keys, k) } - for _, manifest := range manifests { - if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { - return "", fmt.Errorf("annotating %s: %w", fname, err) + sort.Sort(releaseutil.BySplitManifestsOrder(keys)) + for _, key := range keys { + doc := splitDocs[key] + if strings.TrimSpace(doc) == "" { + continue + } + manifests, err := kio.ParseAll(doc) + if err != nil { + return "", fmt.Errorf("parsing %s: %w", fname, err) + } + for _, manifest := range manifests { + if err := manifest.PipeE(kyaml.SetAnnotation(filenameAnnotation, fname)); err != nil { + return "", fmt.Errorf("annotating %s: %w", fname, err) + } + combinedManifests = append(combinedManifests, manifest) } - combinedManifests = append(combinedManifests, manifest) } } @@ -180,7 +226,14 @@ func annotateAndMerge(files map[string]string) (string, error) { // splitAndDeannotate reconstructs individual files from a merged YAML stream, // removing filename annotations and grouping documents by their original filenames. -func splitAndDeannotate(postrendered string) (map[string]string, error) { +// Documents without a filename annotation are assigned a synthesized name of the +// form "generated-by-postrender--.yaml" (or +// "generated-by-postrender-.yaml" when fallbackPrefix is empty). The prefix +// disambiguates fallback filenames across multiple post-render invocations (for +// example when PostRenderStrategySeparate runs the post-renderer once per +// group), so that merging results from different invocations does not collide +// on the same synthetic key. +func splitAndDeannotate(postrendered, fallbackPrefix string) (map[string]string, error) { manifests, err := kio.ParseAll(postrendered) if err != nil { return nil, fmt.Errorf("error parsing YAML: %w", err) @@ -194,7 +247,11 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { } fname := meta.Annotations[filenameAnnotation] if fname == "" { - fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i) + if fallbackPrefix == "" { + fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i) + } else { + fname = fmt.Sprintf("generated-by-postrender-%s-%d.yaml", fallbackPrefix, i) + } } if err := manifest.PipeE(kyaml.ClearAnnotation(filenameAnnotation)); err != nil { return nil, fmt.Errorf("clearing filename annotation: %w", err) @@ -219,7 +276,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // This code has to do with writing files to disk. -func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { +func (cfg *Configuration) renderResources(ctx context.Context, ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool, postRenderStrategy PostRenderStrategy) ([]*release.Hook, *bytes.Buffer, string, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) @@ -249,13 +306,13 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, e.EnableDNS = enableDNS e.CustomTemplateFuncs = cfg.CustomTemplateFuncs - files, err2 = e.Render(ch, values) + files, err2 = e.RenderWithContext(ctx, ch, values) } else { var e engine.Engine e.EnableDNS = enableDNS e.CustomTemplateFuncs = cfg.CustomTemplateFuncs - files, err2 = e.Render(ch, values) + files, err2 = e.RenderWithContext(ctx, ch, values) } if err2 != nil { @@ -283,29 +340,122 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, notes := notesBuffer.String() if pr != nil { - // We need to send files to the post-renderer before sorting and splitting - // hooks from manifests. The post-renderer interface expects a stream of - // manifests (similar to what tools like Kustomize and kubectl expect), whereas - // the sorter uses filenames. - // Here, we merge the documents into a stream, post-render them, and then split - // them back into a map of filename -> content. - - // Merge files as stream of documents for sending to post renderer - merged, err := annotateAndMerge(files) - if err != nil { - return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) - } + switch postRenderStrategy { + case PostRenderStrategySeparate, PostRenderStrategyNoHooks: + // Split hooks from manifests before post-rendering. For "separate", + // hooks and templates are sent to the post-renderer as independent + // streams to avoid duplicate-resource errors when the same resource + // appears in both (e.g. a ServiceAccount used by a pre-install hook + // that is also declared in the chart's regular templates). For + // "nohooks", hooks skip the post-renderer entirely, matching the + // Helm 3 behavior. + sortedHooks, sortedManifests, err := releaseutil.SortManifests(files, nil, releaseutil.InstallOrder) + if err != nil { + for name, content := range files { + if strings.TrimSpace(content) == "" { + continue + } + fmt.Fprintf(b, "---\n# Source: %s\n%s\n", name, content) + } + return hs, b, "", err + } - // Run the post renderer - postRendered, err := pr.Run(bytes.NewBufferString(merged)) - if err != nil { - return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) - } + // Build separate files maps for hooks and manifests. + hookFiles := make(map[string]string) + for _, h := range sortedHooks { + if existing, ok := hookFiles[h.Path]; ok { + hookFiles[h.Path] = existing + "\n---\n" + h.Manifest + } else { + hookFiles[h.Path] = h.Manifest + } + } + manifestFiles := make(map[string]string) + for _, m := range sortedManifests { + if existing, ok := manifestFiles[m.Name]; ok { + manifestFiles[m.Name] = existing + "\n---\n" + m.Content + } else { + manifestFiles[m.Name] = m.Content + } + } - // Use the file list and contents received from the post renderer - files, err = splitAndDeannotate(postRendered.String()) - if err != nil { - return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + // Decide which groups to post-render. "nohooks" passes hooks + // through untouched and only post-renders manifests. + groups := []struct { + name string + files map[string]string + postRender bool + }{ + {"hooks", hookFiles, postRenderStrategy == PostRenderStrategySeparate}, + {"manifests", manifestFiles, true}, + } + + files = make(map[string]string) + for _, group := range groups { + if len(group.files) == 0 { + continue + } + + if !group.postRender { + for k, v := range group.files { + if existing, ok := files[k]; ok { + files[k] = existing + "\n---\n" + v + } else { + files[k] = v + } + } + continue + } + + merged, err := annotateAndMerge(group.files) + if err != nil { + return hs, b, notes, fmt.Errorf("error merging %s: %w", group.name, err) + } + + postRendered, err := pr.Run(bytes.NewBufferString(merged)) + if err != nil { + return hs, b, notes, fmt.Errorf("error while running post render on %s: %w", group.name, err) + } + + rendered, err := splitAndDeannotate(postRendered.String(), group.name) + if err != nil { + return hs, b, notes, fmt.Errorf("error while parsing post rendered output for %s: %w", group.name, err) + } + + for k, v := range rendered { + if existing, ok := files[k]; ok { + files[k] = existing + "\n---\n" + v + } else { + files[k] = v + } + } + } + case PostRenderStrategyCombined, "": + // We need to send files to the post-renderer before sorting and splitting + // hooks from manifests. The post-renderer interface expects a stream of + // manifests (similar to what tools like Kustomize and kubectl expect), whereas + // the sorter uses filenames. + // Here, we merge the documents into a stream, post-render them, and then split + // them back into a map of filename -> content. + + // Merge files as stream of documents for sending to post renderer + merged, err := annotateAndMerge(files) + if err != nil { + return hs, b, notes, fmt.Errorf("error merging manifests: %w", err) + } + + // Run the post renderer + postRendered, err := pr.Run(bytes.NewBufferString(merged)) + if err != nil { + return hs, b, notes, fmt.Errorf("error while running post render on files: %w", err) + } + + // Use the file list and contents received from the post renderer + files, err = splitAndDeannotate(postRendered.String(), "") + if err != nil { + return hs, b, notes, fmt.Errorf("error while parsing post rendered output: %w", err) + } + default: + return hs, b, notes, fmt.Errorf("unknown post-render strategy: '%s'", postRenderStrategy) } } @@ -466,7 +616,7 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet return common.DefaultVersionSet, nil } - versionMap := make(map[string]interface{}) + versionMap := make(map[string]any) var versions []string // Extract the groups @@ -481,7 +631,6 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet var ok bool for _, r := range resources { for _, rl := range r.APIResources { - // A Kind at a GroupVersion can show up more than once. We only want // it displayed once in the final output. id = path.Join(r.GroupVersion, rl.Kind) diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 85ee42d64..ecf160500 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -157,12 +157,12 @@ func withName(name string) chartOption { } func withSampleValues() chartOption { - values := map[string]interface{}{ + values := map[string]any{ "someKey": "someValue", - "nestedKey": map[string]interface{}{ + "nestedKey": map[string]any{ "simpleKey": "simpleValue", - "anotherNestedKey": map[string]interface{}{ - "yetAnotherNestedKey": map[string]interface{}{ + "anotherNestedKey": map[string]any{ + "yetAnotherNestedKey": map[string]any{ "youReadyForAnotherNestedKey": "No", }, }, @@ -173,7 +173,7 @@ func withSampleValues() chartOption { } } -func withValues(values map[string]interface{}) chartOption { +func withValues(values map[string]any) chartOption { return func(opts *chartOptions) { opts.Values = values } @@ -274,7 +274,7 @@ func namedReleaseStub(name string, status rcommon.Status) *release.Release { Description: "Named Release Stub", }, Chart: buildChart(withSampleTemplates()), - Config: map[string]interface{}{"name": "value"}, + Config: map[string]any{"name": "value"}, Version: 1, Hooks: []*release.Hook{ { @@ -304,7 +304,7 @@ func TestConfiguration_Init(t *testing.T) { tests := []struct { name string helmDriver string - expectedDriverType interface{} + expectedDriverType any expectErr bool errMsg string }{ @@ -377,7 +377,7 @@ func TestGetVersionSet(t *testing.T) { } if !vs.Has("v1") { - t.Errorf("Expected supported versions to at least include v1.") + t.Error("Expected supported versions to at least include v1.") } if vs.Has("nosuchversion/v1") { t.Error("Non-existent version is reported found.") @@ -418,131 +418,1156 @@ func TestAnnotateAndMerge(t *testing.T) { { name: "single file with single manifest", files: map[string]string{ - "templates/configmap.yaml": `apiVersion: v1 + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + key: value +`, + }, + { + name: "multiple files with multiple manifests", + files: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + key: value`, + "templates/secret.yaml": ` +apiVersion: v1 +kind: Secret +metadata: + name: test-secret +data: + password: dGVzdA==`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + key: value +--- +apiVersion: v1 +kind: Secret +metadata: + name: test-secret + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/secret.yaml' +data: + password: dGVzdA== +`, + }, + { + name: "file with multiple manifests", + files: map[string]string{ + "templates/multi.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 +data: + key: value2`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +data: + key: value1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +data: + key: value2 +`, + }, + { + name: "partials and empty files are removed", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 +`, + "templates/_partial.tpl": ` +{{-define name}} + {{- "abracadabra"}} +{{- end -}}`, + "templates/empty.yaml": ``, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +`, + }, + { + name: "empty file", + files: map[string]string{ + "templates/empty.yaml": ` +`, + }, + expected: ``, + }, + { + name: "invalid yaml", + files: map[string]string{ + "templates/invalid.yaml": ` +invalid: yaml: content: + - malformed`, + }, + expectedError: "parsing templates/invalid.yaml", + }, + { + name: "leading doc separator glued to content by template whitespace trimming", + files: map[string]string{ + "templates/service.yaml": ` +---apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "leading doc separator on its own line", + files: map[string]string{ + "templates/service.yaml": ` +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "multiple leading doc separators", + files: map[string]string{ + "templates/service.yaml": ` +--- +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/service.yaml' +`, + }, + { + name: "mid-content doc separator glued to content by template whitespace trimming", + files: map[string]string{ + "templates/all.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +---apiVersion: v1 +kind: Service +metadata: + name: test-svc +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +--- +apiVersion: v1 +kind: Service +metadata: + name: test-svc + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/all.yaml' +`, + }, + { + name: "ConfigMap with embedded CA certificate", + files: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: ca-bundle +data: + ca.crt: | + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: ca-bundle + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + ca.crt: | + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ + ------BEGIN CERTIFICATE------ + MIICEzCCAXygAwIBAgIQMIMChMLGrR+QvmQvpwAU6zAKBggqhkjOPQQDAzASMRAw + DgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYwMDAw + WjASMRAwDgYDVQQKEwdBY21lIENvMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7Rmm + ------END CERTIFICATE------ +`, + }, + { + name: "consecutive dashes in YAML value are not treated as document separators", + files: map[string]string{ + "templates/configmap.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-cm +data: + config: | + # --------------------------------------------------------------------------- + [section] + key = value + # --------------------------------------------------------------------------- +`, + }, + expected: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' +data: + config: | + # --------------------------------------------------------------------------- + [section] + key = value + # --------------------------------------------------------------------------- +`, + }, + { + name: "JSON with dashes in values is not corrupted", + files: map[string]string{ + "templates/dashboard.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard +data: + dashboard.json: | + {"options":{"---------":{"color":"#292929","text":"N/A"}}} +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/dashboard.yaml' +data: + dashboard.json: | + {"options":{"---------":{"color":"#292929","text":"N/A"}}} +`, + }, + + // **Note for Chart API v3**: This input should return an _ERROR_ in Chart API v3. + // See the comment on the releaseutil.SplitManifests function for more details. + { + name: "multiple glued separators in same file", + files: map[string]string{ + "templates/multi.yaml": ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' +`, + }, + + // **Note for Chart API v3**: This input should return an _ERROR_ in Chart API v3. + // See the comment on the releaseutil.SplitManifests function for more details. + { + name: "mixed glued and proper separators", + files: map[string]string{ + "templates/mixed.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/mixed.yaml' +`, + }, + { + name: "12 documents preserve in-file order", + files: map[string]string{ + "templates/many.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-01 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-02 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-03 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-04 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-05 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-06 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-07 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-08 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-09 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-10 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-11 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-12 +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-01 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-02 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-03 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-04 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-05 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-06 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-07 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-08 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-09 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-10 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-11 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm-12 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/many.yaml' +`, + }, + + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + + // |- (strip) — strips all trailing newlines + { + name: "block scalar strip (|-) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + + // |+ (keep) — preserves all trailing newlines + { + name: "block scalar keep (|+) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +`, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: | + hello +`, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |+ + hello + +`, + }, + + // Multi-doc tests: block scalar doc is NOT the last document. + // SplitManifests' regex consumes \s*\n before ---, so trailing + // newlines from non-last docs are always stripped. + + // | (clip) in multi-doc (first doc) + { + name: "multi-doc block scalar clip (|) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar clip (|) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar clip (|) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + + // |- (strip) in multi-doc (first doc) + { + name: "multi-doc block scalar strip (|-) with 0 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar strip (|-) with 1 trailing newline", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple +`, + }, + { + name: "multi-doc block scalar strip (|-) with 2 trailing newlines", + files: map[string]string{ + "templates/cm.yaml": ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, + }, + expected: `apiVersion: v1 +kind: ConfigMap +metadata: + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - key: value`, - }, - expected: `apiVersion: v1 + key: |- + hello +--- +apiVersion: v1 kind: ConfigMap metadata: - name: test-cm + name: test2 annotations: - postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - key: value + val: simple `, }, + + // |+ (keep) in multi-doc (first doc) { - name: "multiple files with multiple manifests", + name: "multi-doc block scalar keep (|+) with 0 trailing newlines", files: map[string]string{ - "templates/configmap.yaml": `apiVersion: v1 + "templates/cm.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: - name: test-cm + name: test data: - key: value`, - "templates/secret.yaml": `apiVersion: v1 -kind: Secret + key: |+ + hello +--- +apiVersion: v1 +kind: ConfigMap metadata: - name: test-secret + name: test2 data: - password: dGVzdA==`, + val: simple`, }, expected: `apiVersion: v1 kind: ConfigMap metadata: - name: test-cm + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/configmap.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - key: value + key: |- + hello --- apiVersion: v1 -kind: Secret +kind: ConfigMap metadata: - name: test-secret + name: test2 annotations: - postrenderer.helm.sh/postrender-filename: 'templates/secret.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - password: dGVzdA== + val: simple `, }, { - name: "file with multiple manifests", + name: "multi-doc block scalar keep (|+) with 1 trailing newline", files: map[string]string{ - "templates/multi.yaml": `apiVersion: v1 + "templates/cm.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: - name: test-cm1 + name: test data: - key: value1 + key: |+ + hello + --- apiVersion: v1 kind: ConfigMap metadata: - name: test-cm2 + name: test2 data: - key: value2`, + val: simple`, }, expected: `apiVersion: v1 kind: ConfigMap metadata: - name: test-cm1 + name: test annotations: - postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - key: value1 + key: |- + hello --- apiVersion: v1 kind: ConfigMap metadata: - name: test-cm2 + name: test2 annotations: - postrenderer.helm.sh/postrender-filename: 'templates/multi.yaml' + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' data: - key: value2 + val: simple `, }, { - name: "partials and empty files are removed", + name: "multi-doc block scalar keep (|+) with 2 trailing newlines", files: map[string]string{ - "templates/cm.yaml": `apiVersion: v1 + "templates/cm.yaml": ` +apiVersion: v1 kind: ConfigMap metadata: - name: test-cm1 -`, - "templates/_partial.tpl": ` -{{-define name}} - {{- "abracadabra"}} -{{- end -}}`, - "templates/empty.yaml": ``, + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +data: + val: simple`, }, expected: `apiVersion: v1 kind: ConfigMap metadata: - name: test-cm1 + name: test + annotations: + postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + key: |- + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 annotations: postrenderer.helm.sh/postrender-filename: 'templates/cm.yaml' +data: + val: simple `, }, - { - name: "empty file", - files: map[string]string{ - "templates/empty.yaml": "", - }, - expected: ``, - }, - { - name: "invalid yaml", - files: map[string]string{ - "templates/invalid.yaml": `invalid: yaml: content: - - malformed`, - }, - expectedError: "parsing templates/invalid.yaml", - }, } for _, tt := range tests { @@ -697,7 +1722,7 @@ metadata: data: key: value`, expectedFiles: map[string]string{ - "generated-by-postrender-0.yaml": `apiVersion: v1 + "generated-by-postrender-test-0.yaml": `apiVersion: v1 kind: ConfigMap metadata: name: test-cm @@ -710,14 +1735,14 @@ data: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - files, err := splitAndDeannotate(tt.input) + files, err := splitAndDeannotate(tt.input, "test") if tt.expectedError != "" { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) } else { assert.NoError(t, err) - assert.Equal(t, len(tt.expectedFiles), len(files)) + assert.Len(t, files, len(tt.expectedFiles)) for expectedFile, expectedContent := range tt.expectedFiles { actualContent, exists := files[expectedFile] @@ -764,11 +1789,11 @@ data: require.NoError(t, err) // Split and deannotate - reconstructed, err := splitAndDeannotate(merged) + reconstructed, err := splitAndDeannotate(merged, "test") require.NoError(t, err) // Compare the results - assert.Equal(t, len(originalFiles), len(reconstructed)) + assert.Len(t, reconstructed, len(originalFiles)) for filename, originalContent := range originalFiles { reconstructedContent, exists := reconstructed[filename] assert.True(t, exists, "File %s should exist in reconstructed files", filename) @@ -795,26 +1820,29 @@ func TestRenderResources_PostRenderer_Success(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) assert.NotNil(t, hooks) assert.NotNil(t, buf) - assert.Equal(t, "", notes) + assert.Empty(t, notes) expectedBuf := `--- # Source: yellow/templates/foodpie foodpie: world + --- # Source: yellow/templates/with-partials yellow: Earth + --- # Source: yellow/templates/yellow yellow: world + ` expectedHook := `kind: ConfigMap metadata: @@ -822,7 +1850,8 @@ metadata: annotations: "helm.sh/hook": post-install,pre-delete,post-upgrade data: - name: value` + name: value +` assert.Equal(t, expectedBuf, buf.String()) assert.Len(t, hooks, 1) @@ -838,11 +1867,11 @@ func TestRenderResources_PostRenderer_Error(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -866,11 +1895,11 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) { {Name: "templates/invalid", ModTime: time.Now(), Data: []byte("invalid: yaml: content:")}, }, } - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -888,11 +1917,11 @@ func TestRenderResources_PostRenderer_SplitError(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} _, _, _, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.Error(t, err) @@ -909,17 +1938,17 @@ func TestRenderResources_PostRenderer_Integration(t *testing.T) { } ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - mockPR, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) assert.NotNil(t, hooks) assert.NotNil(t, buf) - assert.Equal(t, "", notes) // Notes should be empty for this test + assert.Empty(t, notes) // Notes should be empty for this test // Verify that the post-renderer modifications are present in the output output := buf.String() @@ -927,14 +1956,17 @@ func TestRenderResources_PostRenderer_Integration(t *testing.T) { # Source: hello/templates/goodbye goodbye: world color: blue + --- # Source: hello/templates/hello hello: world color: blue + --- # Source: hello/templates/with-partials hello: Earth color: blue + ` assert.Contains(t, output, "color: blue") assert.Equal(t, 3, strings.Count(output, "color: blue")) @@ -945,17 +1977,316 @@ func TestRenderResources_NoPostRenderer(t *testing.T) { cfg := actionConfigFixture(t) ch := buildChart(withSampleTemplates()) - values := map[string]interface{}{} + values := map[string]any{} hooks, buf, notes, err := cfg.renderResources( - ch, values, "test-release", "", false, false, false, - nil, false, false, false, + t.Context(), ch, values, "test-release", "", false, false, false, + nil, false, false, false, PostRenderStrategyCombined, ) assert.NoError(t, err) assert.NotNil(t, hooks) assert.NotNil(t, buf) - assert.Equal(t, "", notes) + assert.Empty(t, notes) +} + +func TestRenderResources_PostRenderer_DuplicateResourceInHookAndTemplate(t *testing.T) { + cfg := actionConfigFixture(t) + + // Simulate a chart where the same ServiceAccount appears both as a + // pre-install hook and as a regular template. This is a valid Helm pattern + // but previously caused post-renderers like Kustomize to fail with + // "may not add resource with an already registered id" because hooks and + // templates were merged into a single stream before post-rendering. + saHook := `apiVersion: v1 +kind: ServiceAccount +metadata: + name: my-app + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded` + + saTemplate := `apiVersion: v1 +kind: ServiceAccount +metadata: + name: my-app` + + deployment := `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + template: + spec: + serviceAccountName: my-app` + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/sa-hook.yaml", ModTime: modTime, Data: []byte(saHook)}, + {Name: "templates/sa.yaml", ModTime: modTime, Data: []byte(saTemplate)}, + {Name: "templates/deployment.yaml", ModTime: modTime, Data: []byte(deployment)}, + }) + + // Use a post-renderer that rejects duplicate resource IDs, similar to + // how Kustomize behaves. We verify that no single post-render call + // receives the ServiceAccount twice. + mockPR := &mockPostRenderer{ + transform: func(content string) string { + count := strings.Count(content, "kind: ServiceAccount") + if count > 1 { + t.Errorf("post-renderer received %d ServiceAccount resources in a single stream, expected at most 1", count) + } + return content + }, + } + + hooks, buf, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Len(t, hooks, 1) + assert.Equal(t, "my-app", hooks[0].Name) + assert.Contains(t, buf.String(), "kind: Deployment") + assert.Contains(t, buf.String(), "kind: ServiceAccount") +} + +func TestRenderResources_PostRenderer_CombinedInvokesOnceWithEverything(t *testing.T) { + cfg := actionConfigFixture(t) + + hookManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install` + templateManifest := `apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm` + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(hookManifest)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(templateManifest)}, + }) + + var calls int + var lastInput string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + lastInput = content + return content + }, + } + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyCombined, + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "combined strategy should invoke the post-renderer exactly once") + assert.Contains(t, lastInput, "hook-cm") + assert.Contains(t, lastInput, "template-cm") +} + +func TestRenderResources_PostRenderer_ZeroValueStrategyActsAsCombined(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategy(""), + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "unset strategy must preserve backwards-compatible combined behavior") +} + +func TestRenderResources_PostRenderer_SeparateSplitsHooksAndTemplates(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var inputs []string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + inputs = append(inputs, content) + return content + }, + } + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Len(t, inputs, 2, "separate strategy should invoke the post-renderer twice when both hooks and templates exist") + for _, in := range inputs { + hasHook := strings.Contains(in, "hook-cm") + hasTemplate := strings.Contains(in, "template-cm") + assert.False(t, hasHook && hasTemplate, "a single post-render invocation must not contain both hook and template resources") + assert.True(t, hasHook || hasTemplate, "each post-render invocation must contain either a hook or a template") + } +} + +func TestRenderResources_PostRenderer_SeparateWithOnlyTemplates(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategySeparate, + ) + + assert.NoError(t, err) + assert.Equal(t, 1, calls, "separate strategy should skip the empty hook group and invoke the post-renderer only once") +} + +func TestRenderResources_PostRenderer_NoHooksSkipsHooks(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + var inputs []string + mockPR := &mockPostRenderer{ + transform: func(content string) string { + inputs = append(inputs, content) + return content + }, + } + + hooks, manifestDoc, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyNoHooks, + ) + + assert.NoError(t, err) + assert.Len(t, inputs, 1, "nohooks strategy should invoke the post-renderer exactly once (for templates only)") + assert.NotContains(t, inputs[0], "hook-cm", "hooks must not be sent to the post-renderer") + assert.Contains(t, inputs[0], "template-cm", "templates must be sent to the post-renderer") + + // Hooks still round-trip through the release so they can execute. + require.Len(t, hooks, 1) + assert.Contains(t, hooks[0].Manifest, "hook-cm") + assert.Contains(t, manifestDoc.String(), "template-cm") +} + +func TestRenderResources_PostRenderer_NoHooksWithOnlyHooks(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/hook.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: hook-cm + annotations: + "helm.sh/hook": pre-install`)}, + }) + + var calls int + mockPR := &mockPostRenderer{ + transform: func(content string) string { + calls++ + return content + }, + } + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategyNoHooks, + ) + + assert.NoError(t, err) + assert.Equal(t, 0, calls, "nohooks strategy should not invoke the post-renderer when the chart only has hooks") +} + +func TestRenderResources_PostRenderer_UnknownStrategyErrors(t *testing.T) { + cfg := actionConfigFixture(t) + + modTime := time.Now() + ch := buildChartWithTemplates([]*common.File{ + {Name: "templates/cm.yaml", ModTime: modTime, Data: []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: template-cm`)}, + }) + + mockPR := &mockPostRenderer{} + + _, _, _, err := cfg.renderResources( + t.Context(), ch, nil, "test-release", "", false, false, false, + mockPR, false, false, false, PostRenderStrategy("bogus"), + ) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown post-render strategy") + assert.Contains(t, err.Error(), "bogus") } func TestDetermineReleaseSSAApplyMethod(t *testing.T) { diff --git a/pkg/action/dependency.go b/pkg/action/dependency.go index b12887bde..8ceca1433 100644 --- a/pkg/action/dependency.go +++ b/pkg/action/dependency.go @@ -92,7 +92,7 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p for _, arc := range archives { // we need to trip the prefix dirs and the extension off. filename = strings.TrimSuffix(filepath.Base(arc), ".tgz") - maybeVersion := strings.TrimPrefix(filename, fmt.Sprintf("%s-", dep.Name)) + maybeVersion := strings.TrimPrefix(filename, dep.Name+"-") if _, err := semver.StrictNewVersion(maybeVersion); err == nil { // If the version parsed without an error, it is possibly a valid @@ -120,7 +120,6 @@ func (d *Dependency) dependencyStatus(chartpath string, dep *chart.Dependency, p if r := statArchiveForStatus(archive, dep); r != "" { return r } - } // End unnecessary code. diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 5312dac7f..7d5a3f5f9 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -119,7 +119,6 @@ func (m *Metadata) FormattedDepNames() string { continue } depsNames = append(depsNames, ac.Name()) - } sort.StringSlice(depsNames).Sort() diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index 4caa966ab..b488abf64 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -279,9 +279,9 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { // Verify dependencies without aliases assert.Equal(t, "nginx", dep1.Name()) - assert.Equal(t, "", dep1.Alias()) + assert.Empty(t, dep1.Alias()) assert.Equal(t, "postgresql", dep3.Name()) - assert.Equal(t, "", dep3.Alias()) + assert.Empty(t, dep3.Alias()) } func TestGetMetadata_Run_WithAnnotations(t *testing.T) { @@ -485,7 +485,7 @@ func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) - assert.Equal(t, "", result.AppVersion) + assert.Empty(t, result.AppVersion) } func TestMetadata_FormattedDepNames(t *testing.T) { diff --git a/pkg/action/get_values.go b/pkg/action/get_values.go index 6475a140b..4a5e6f589 100644 --- a/pkg/action/get_values.go +++ b/pkg/action/get_values.go @@ -42,7 +42,7 @@ func NewGetValues(cfg *Configuration) *GetValues { } // Run executes 'helm get values' against the given release. -func (g *GetValues) Run(name string) (map[string]interface{}, error) { +func (g *GetValues) Run(name string) (map[string]any, error) { if err := g.cfg.KubeClient.IsReachable(); err != nil { return nil, err } diff --git a/pkg/action/get_values_test.go b/pkg/action/get_values_test.go index 69a95a2e4..c4cbdf2d5 100644 --- a/pkg/action/get_values_test.go +++ b/pkg/action/get_values_test.go @@ -37,7 +37,7 @@ func TestNewGetValues(t *testing.T) { assert.NotNil(t, client) assert.Equal(t, cfg, client.cfg) assert.Equal(t, 0, client.Version) - assert.Equal(t, false, client.AllValues) + assert.False(t, client.AllValues) } func TestGetValues_Run_UserConfigOnly(t *testing.T) { @@ -45,12 +45,12 @@ func TestGetValues_Run_UserConfigOnly(t *testing.T) { client := NewGetValues(cfg) releaseName := "test-release" - userConfig := map[string]interface{}{ - "database": map[string]interface{}{ + userConfig := map[string]any{ + "database": map[string]any{ "host": "localhost", "port": 5432, }, - "app": map[string]interface{}{ + "app": map[string]any{ "name": "my-app", "replicas": 3, }, @@ -66,9 +66,9 @@ func TestGetValues_Run_UserConfigOnly(t *testing.T) { Name: "test-chart", Version: "1.0.0", }, - Values: map[string]interface{}{ + Values: map[string]any{ "defaultKey": "defaultValue", - "app": map[string]interface{}{ + "app": map[string]any{ "name": "default-app", "timeout": 30, }, @@ -92,19 +92,19 @@ func TestGetValues_Run_AllValues(t *testing.T) { client.AllValues = true releaseName := "test-release" - userConfig := map[string]interface{}{ - "database": map[string]interface{}{ + userConfig := map[string]any{ + "database": map[string]any{ "host": "localhost", "port": 5432, }, - "app": map[string]interface{}{ + "app": map[string]any{ "name": "my-app", }, } - chartDefaultValues := map[string]interface{}{ + chartDefaultValues := map[string]any{ "defaultKey": "defaultValue", - "app": map[string]interface{}{ + "app": map[string]any{ "name": "default-app", "timeout": 30, }, @@ -132,11 +132,11 @@ func TestGetValues_Run_AllValues(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) - assert.Equal(t, "my-app", result["app"].(map[string]interface{})["name"]) - assert.Equal(t, 30, result["app"].(map[string]interface{})["timeout"]) + assert.Equal(t, "my-app", result["app"].(map[string]any)["name"]) + assert.Equal(t, 30, result["app"].(map[string]any)["timeout"]) assert.Equal(t, "defaultValue", result["defaultKey"]) - assert.Equal(t, "localhost", result["database"].(map[string]interface{})["host"]) - assert.Equal(t, 5432, result["database"].(map[string]interface{})["port"]) + assert.Equal(t, "localhost", result["database"].(map[string]any)["host"]) + assert.Equal(t, 5432, result["database"].(map[string]any)["port"]) } func TestGetValues_Run_EmptyValues(t *testing.T) { @@ -156,7 +156,7 @@ func TestGetValues_Run_EmptyValues(t *testing.T) { Version: "1.0.0", }, }, - Config: map[string]interface{}{}, + Config: map[string]any{}, Version: 1, Namespace: "default", } @@ -165,7 +165,7 @@ func TestGetValues_Run_EmptyValues(t *testing.T) { result, err := client.Run(releaseName) require.NoError(t, err) - assert.Equal(t, map[string]interface{}{}, result) + assert.Equal(t, map[string]any{}, result) } func TestGetValues_Run_UnreachableKubeClient(t *testing.T) { diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index 28033395b..d7824d69f 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -35,7 +35,6 @@ import ( func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, waitOptions []kube.WaitOption, timeout time.Duration, serverSideApply bool) error { - shutdown, err := cfg.execHookWithDelayedShutdown(rl, hook, waitStrategy, waitOptions, timeout, serverSideApply) if shutdown == nil { return err @@ -59,7 +58,6 @@ func shutdownNoOp() error { func (cfg *Configuration) execHookWithDelayedShutdown(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, waitOptions []kube.WaitOption, timeout time.Duration, serverSideApply bool) (ExecuteShutdownFunc, error) { - executingHooks := []*release.Hook{} for _, h := range rl.Hooks { @@ -150,8 +148,8 @@ func (cfg *Configuration) execHookWithDelayedShutdown(rl *release.Release, hook return func() error { // If all hooks are successful, check the annotation of each hook to determine whether the hook should be deleted // or output should be logged under succeeded condition. If so, then clear the corresponding resource object in each hook - for i := len(executingHooks) - 1; i >= 0; i-- { - h := executingHooks[i] + for _, v := range slices.Backward(executingHooks) { + h := v if err := cfg.outputLogsByPolicy(h, rl.Namespace, release.HookOutputOnSucceeded); err != nil { // We log here as we still want to attempt hook resource deletion even if output logging fails. log.Printf("error outputting logs for hook failure: %v", err) @@ -179,7 +177,6 @@ func (x hookByWeight) Less(i, j int) bool { // deleteHookByPolicy deletes a hook if the hook policy instructs it to func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.HookDeletePolicy, waitStrategy kube.WaitStrategy, waitOptions []kube.WaitOption, timeout time.Duration) error { - // Never delete CustomResourceDefinitions; this could cause lots of // cascading garbage collection. if h.Kind == "CustomResourceDefinition" { @@ -214,7 +211,6 @@ func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.Hoo // deleteHooksByPolicy deletes all hooks if the hook policy instructs it to func (cfg *Configuration) deleteHooksByPolicy(hooks []*release.Hook, policy release.HookDeletePolicy, waitStrategy kube.WaitStrategy, waitOptions []kube.WaitOption, timeout time.Duration) error { - for _, h := range hooks { if err := cfg.deleteHookByPolicy(h, policy, waitStrategy, waitOptions, timeout); err != nil { return err @@ -257,9 +253,9 @@ func (cfg *Configuration) outputLogsByPolicy(h *release.Hook, releaseNamespace s } switch h.Kind { case "Job": - return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", h.Name)}) + return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{LabelSelector: "job-name=" + h.Name}) case "Pod": - return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{FieldSelector: fmt.Sprintf("metadata.name=%s", h.Name)}) + return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{FieldSelector: "metadata.name=" + h.Name}) default: return nil } diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index 0270a0630..b017b1f11 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -19,6 +19,7 @@ package action import ( "bytes" "context" + "errors" "fmt" "io" "reflect" @@ -173,7 +174,7 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str t.Helper() var expectedOutput string if shouldOutput { - expectedOutput = fmt.Sprintf("attempted to output logs for namespace: %s", expectedNamespace) + expectedOutput = "attempted to output logs for namespace: " + expectedNamespace } is := assert.New(t) instAction := installAction(t) @@ -186,7 +187,7 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)}, } - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChartWithTemplates(templates), vals) is.NoError(err) @@ -200,13 +201,13 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str t.Helper() var expectedOutput string if shouldOutput { - expectedOutput = fmt.Sprintf("attempted to output logs for namespace: %s", expectedNamespace) + expectedOutput = "attempted to output logs for namespace: " + expectedNamespace } is := assert.New(t) instAction := installAction(t) instAction.ReleaseName = "failed-hooks" failingClient := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failingClient.WatchUntilReadyError = fmt.Errorf("failed watch") + failingClient.WatchUntilReadyError = errors.New("failed watch") instAction.cfg.KubeClient = failingClient outBuffer := &bytes.Buffer{} failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} @@ -216,7 +217,7 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str {Name: "templates/hello", ModTime: modTime, Data: []byte("hello: world")}, {Name: "templates/hooks", ModTime: modTime, Data: []byte(manifest)}, } - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChartWithTemplates(templates), vals) is.Error(err) @@ -402,11 +403,11 @@ data: } if err != nil && !tc.expectError { - t.Fatalf("Got an unexpected error.") + t.Fatal("Got an unexpected error.") } if err == nil && tc.expectError { - t.Fatalf("Expected and error but did not get it.") + t.Fatal("Expected and error but did not get it.") } }) } diff --git a/pkg/action/install.go b/pkg/action/install.go index 2f747a789..fcfae7214 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -130,6 +130,10 @@ type Install struct { // TakeOwnership will ignore the check for helm annotations and take ownership of the resources. TakeOwnership bool PostRenderer postrenderer.PostRenderer + // PostRenderStrategy controls how hooks and regular templates are passed + // to the configured post-renderer. See PostRenderStrategy for the + // available modes. Defaults to PostRenderStrategyCombined. + PostRenderStrategy PostRenderStrategy // Lock to control raceconditions when the process receives a SIGTERM Lock sync.Mutex goroutineCount atomic.Int32 @@ -158,9 +162,10 @@ type ChartPathOptions struct { // NewInstall creates a new Install object with the given configuration. func NewInstall(cfg *Configuration) *Install { in := &Install{ - cfg: cfg, - ServerSideApply: true, // Must always match the CLI default. - DryRunStrategy: DryRunNone, + cfg: cfg, + ServerSideApply: true, // Must always match the CLI default. + DryRunStrategy: DryRunNone, + PostRenderStrategy: PostRenderStrategyCombined, } in.registryClient = cfg.RegistryClient @@ -370,14 +375,14 @@ func (i *Install) RunWithContext(ctx context.Context, ch ci.Charter, vals map[st rel := i.createRelease(chrt, vals, i.Labels) var manifestDoc *bytes.Buffer - rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret) + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(ctx, chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithServer(i.DryRunStrategy), i.EnableDNS, i.HideSecret, i.PostRenderStrategy) // Even for errors, attach this if available if manifestDoc != nil { rel.Manifest = manifestDoc.String() } // Check error from render if err != nil { - rel.SetStatus(rcommon.StatusFailed, fmt.Sprintf("failed to render resource: %s", err.Error())) + rel.SetStatus(rcommon.StatusFailed, "failed to render resource: "+err.Error()) // Return a release with partial data so that the client can show debugging information. return rel, err } diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 52dd83788..705a8e11d 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -182,7 +182,7 @@ func TestInstallRelease(t *testing.T) { req := require.New(t) instAction := installAction(t) - vals := map[string]interface{}{} + vals := map[string]any{} ctx, done := context.WithCancel(t.Context()) resi, err := instAction.RunWithContext(ctx, buildChart(), vals) if err != nil { @@ -204,8 +204,8 @@ func TestInstallRelease(t *testing.T) { is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") - is.NotEqual(len(res.Manifest), 0) - is.NotEqual(len(rel.Manifest), 0) + is.NotEmpty(res.Manifest) + is.NotEmpty(rel.Manifest) is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") is.Equal(rel.Info.Description, "Install complete") @@ -288,13 +288,13 @@ func TestInstallReleaseWithTakeOwnership_ResourceOwnedNoFlag(t *testing.T) { func TestInstallReleaseWithValues(t *testing.T) { is := assert.New(t) instAction := installAction(t) - userVals := map[string]interface{}{ - "nestedKey": map[string]interface{}{ + userVals := map[string]any{ + "nestedKey": map[string]any{ "simpleKey": "simpleValue", }, } - expectedUserValues := map[string]interface{}{ - "nestedKey": map[string]interface{}{ + expectedUserValues := map[string]any{ + "nestedKey": map[string]any{ "simpleKey": "simpleValue", }, } @@ -318,8 +318,8 @@ func TestInstallReleaseWithValues(t *testing.T) { is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") - is.NotEqual(len(res.Manifest), 0) - is.NotEqual(len(rel.Manifest), 0) + is.NotEmpty(res.Manifest) + is.NotEmpty(rel.Manifest) is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") is.Equal("Install complete", rel.Info.Description) is.Equal(expectedUserValues, rel.Config) @@ -328,7 +328,7 @@ func TestInstallReleaseWithValues(t *testing.T) { func TestInstallRelease_NoName(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "" - vals := map[string]interface{}{} + vals := map[string]any{} _, err := instAction.Run(buildChart(), vals) if err == nil { t.Fatal("expected failure when no name is specified") @@ -340,7 +340,7 @@ func TestInstallRelease_WithNotes(t *testing.T) { is := assert.New(t) instAction := installAction(t) instAction.ReleaseName = "with-notes" - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withNotes("note here")), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -359,8 +359,8 @@ func TestInstallRelease_WithNotes(t *testing.T) { is.Equal(rel.Hooks[0].Manifest, manifestWithHook) is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") - is.NotEqual(len(res.Manifest), 0) - is.NotEqual(len(rel.Manifest), 0) + is.NotEmpty(res.Manifest) + is.NotEmpty(rel.Manifest) is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") is.Equal(rel.Info.Description, "Install complete") @@ -371,7 +371,7 @@ func TestInstallRelease_WithNotesRendered(t *testing.T) { is := assert.New(t) instAction := installAction(t) instAction.ReleaseName = "with-notes" - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withNotes("got-{{.Release.Name}}")), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -384,7 +384,7 @@ func TestInstallRelease_WithNotesRendered(t *testing.T) { rel, err := releaserToV1Release(r) is.NoError(err) - expectedNotes := fmt.Sprintf("got-%s", res.Name) + expectedNotes := "got-" + res.Name is.Equal(expectedNotes, rel.Info.Notes) is.Equal(rel.Info.Description, "Install complete") } @@ -394,7 +394,7 @@ func TestInstallRelease_WithChartAndDependencyParentNotes(t *testing.T) { is := assert.New(t) instAction := installAction(t) instAction.ReleaseName = "with-notes" - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -417,7 +417,7 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "with-notes" instAction.SubNotes = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withNotes("parent"), withDependency(withNotes("child"))), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -443,7 +443,7 @@ func TestInstallRelease_DryRunClient(t *testing.T) { instAction := installAction(t) instAction.DryRunStrategy = dryRunStrategy - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withSampleTemplates()), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -471,7 +471,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { // First perform a normal dry-run with the secret and confirm its presence. instAction.DryRunStrategy = DryRunClient - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -486,7 +486,7 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { // Perform a dry-run where the secret should not be present instAction.HideSecret = true - vals = map[string]interface{}{} + vals = map[string]any{} res2i, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -502,10 +502,10 @@ func TestInstallRelease_DryRunHiddenSecret(t *testing.T) { // Ensure there is an error when HideSecret True but not in a dry-run mode instAction.DryRunStrategy = DryRunNone - vals = map[string]interface{}{} + vals = map[string]any{} _, err = instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals) if err == nil { - t.Fatalf("Did not get expected an error when dry-run false and hide secret is true") + t.Fatal("Did not get the expected error when dry-run is false and hide secret is true") } } @@ -514,7 +514,7 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { is := assert.New(t) instAction := installAction(t) instAction.DryRunStrategy = DryRunNone - vals := map[string]interface{}{} + vals := map[string]any{} mockChart := buildChart(withSampleTemplates()) mockChart.Templates = append(mockChart.Templates, &common.File{ @@ -537,7 +537,7 @@ func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { is := assert.New(t) instAction := installAction(t) instAction.DryRunStrategy = DryRunNone - vals := map[string]interface{}{} + vals := map[string]any{} _, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals) expectedErr := `hello/templates/incorrect:1:10 executing "hello/templates/incorrect" at <.Values.bad.doh>: @@ -555,7 +555,7 @@ func TestInstallRelease_NoHooks(t *testing.T) { instAction.ReleaseName = "no-hooks" require.NoError(t, instAction.cfg.Releases.Create(releaseStub())) - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(), vals) if err != nil { t.Fatalf("Failed install: %s", err) @@ -571,18 +571,18 @@ func TestInstallRelease_FailedHooks(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "failed-hooks" failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WatchUntilReadyError = fmt.Errorf("Failed watch") + failer.WatchUntilReadyError = errors.New("Failed watch") instAction.cfg.KubeClient = failer outBuffer := &bytes.Buffer{} failer.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(), vals) is.Error(err) res, err := releaserToV1Release(resi) is.NoError(err) is.Contains(res.Info.Description, "failed post-install") - is.Equal("", outBuffer.String()) + is.Empty(outBuffer.String()) is.Equal(rcommon.StatusFailed, res.Info.Status) } @@ -596,7 +596,7 @@ func TestInstallRelease_ReplaceRelease(t *testing.T) { require.NoError(t, instAction.cfg.Releases.Create(rel)) instAction.ReleaseName = rel.Name - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(), vals) is.NoError(err) res, err := releaserToV1Release(resi) @@ -616,13 +616,13 @@ func TestInstallRelease_ReplaceRelease(t *testing.T) { func TestInstallRelease_KubeVersion(t *testing.T) { is := assert.New(t) instAction := installAction(t) - vals := map[string]interface{}{} + vals := map[string]any{} _, err := instAction.Run(buildChart(withKube(">=0.0.0")), vals) is.NoError(err) // This should fail for a few hundred years instAction.ReleaseName = "should-fail" - vals = map[string]interface{}{} + vals = map[string]any{} _, err = instAction.Run(buildChart(withKube(">=99.0.0")), vals) is.Error(err) is.Contains(err.Error(), "chart requires kubeVersion: >=99.0.0 which is incompatible with Kubernetes v1.20.") @@ -633,10 +633,10 @@ func TestInstallRelease_Wait(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "come-fail-away" failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") + failer.WaitError = errors.New("I timed out") instAction.cfg.KubeClient = failer instAction.WaitStrategy = kube.StatusWatcherStrategy - vals := map[string]interface{}{} + vals := map[string]any{} goroutines := instAction.getGoroutineCount() @@ -657,7 +657,7 @@ func TestInstallRelease_Wait_Interrupted(t *testing.T) { failer.WaitDuration = 10 * time.Second instAction.cfg.KubeClient = failer instAction.WaitStrategy = kube.StatusWatcherStrategy - vals := map[string]interface{}{} + vals := map[string]any{} ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) @@ -677,11 +677,11 @@ func TestInstallRelease_WaitForJobs(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "come-fail-away" failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") + failer.WaitError = errors.New("I timed out") instAction.cfg.KubeClient = failer instAction.WaitStrategy = kube.StatusWatcherStrategy instAction.WaitForJobs = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(), vals) is.Error(err) @@ -698,13 +698,13 @@ func TestInstallRelease_RollbackOnFailure(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "come-fail-away" failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") + failer.WaitError = errors.New("I timed out") instAction.cfg.KubeClient = failer instAction.RollbackOnFailure = true // disabling hooks to avoid an early fail when // WaitForDelete is called on the pre-delete hook execution instAction.DisableHooks = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := instAction.Run(buildChart(), vals) is.Error(err) @@ -723,11 +723,11 @@ func TestInstallRelease_RollbackOnFailure(t *testing.T) { instAction := installAction(t) instAction.ReleaseName = "come-fail-away-with-me" failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") - failer.DeleteError = fmt.Errorf("uninstall fail") + failer.WaitError = errors.New("I timed out") + failer.DeleteError = errors.New("uninstall fail") instAction.cfg.KubeClient = failer instAction.RollbackOnFailure = true - vals := map[string]interface{}{} + vals := map[string]any{} _, err := instAction.Run(buildChart(), vals) is.Error(err) @@ -737,7 +737,6 @@ func TestInstallRelease_RollbackOnFailure(t *testing.T) { }) } func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { - is := assert.New(t) instAction := installAction(t) instAction.ReleaseName = "interrupted-release" @@ -745,7 +744,7 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { failer.WaitDuration = 10 * time.Second instAction.cfg.KubeClient = failer instAction.RollbackOnFailure = true - vals := map[string]interface{}{} + vals := map[string]any{} ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) @@ -767,7 +766,6 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { is.Equal(goroutines+1, instAction.getGoroutineCount()) // installation goroutine still is in background time.Sleep(10 * time.Second) // wait for goroutine to finish is.Equal(goroutines, instAction.getGoroutineCount()) - } func TestNameTemplate(t *testing.T) { testCases := []nameTemplateTestCase{ @@ -804,7 +802,6 @@ func TestNameTemplate(t *testing.T) { } for _, tc := range testCases { - n, err := TemplateName(tc.tpl) if err != nil { if tc.expectedErrorStr == "" { @@ -841,7 +838,7 @@ func TestNameTemplate(t *testing.T) { func TestInstallReleaseOutputDir(t *testing.T) { is := assert.New(t) instAction := installAction(t) - vals := map[string]interface{}{} + vals := map[string]any{} dir := t.TempDir() @@ -867,13 +864,13 @@ func TestInstallReleaseOutputDir(t *testing.T) { test.AssertGoldenFile(t, filepath.Join(dir, "hello/templates/rbac"), "rbac.txt") _, err = os.Stat(filepath.Join(dir, "hello/templates/empty")) - is.True(errors.Is(err, fs.ErrNotExist)) + is.ErrorIs(err, fs.ErrNotExist) } func TestInstallOutputDirWithReleaseName(t *testing.T) { is := assert.New(t) instAction := installAction(t) - vals := map[string]interface{}{} + vals := map[string]any{} dir := t.TempDir() @@ -903,7 +900,7 @@ func TestInstallOutputDirWithReleaseName(t *testing.T) { test.AssertGoldenFile(t, filepath.Join(newDir, "hello/templates/rbac"), "rbac.txt") _, err = os.Stat(filepath.Join(newDir, "hello/templates/empty")) - is.True(errors.Is(err, fs.ErrNotExist)) + is.ErrorIs(err, fs.ErrNotExist) } func TestNameAndChart(t *testing.T) { @@ -1168,7 +1165,7 @@ func TestInstallCRDs_AlreadyExist(t *testing.T) { mockChart := buildChart(withFile(mockFile)) crdsToInstall := mockChart.CRDObjects() - assert.Nil(t, instAction.installCRDs(crdsToInstall)) + assert.NoError(t, instAction.installCRDs(crdsToInstall)) } func TestInstallCRDs_KubeClient_BuildError(t *testing.T) { @@ -1227,7 +1224,7 @@ func TestCheckDependencies(t *testing.T) { dependency := chart.Dependency{Name: "hello"} mockChart := buildChart(withDependency()) - assert.Nil(t, CheckDependencies(mockChart, []ci.Dependency{&dependency})) + assert.NoError(t, CheckDependencies(mockChart, []ci.Dependency{&dependency})) } func TestCheckDependencies_MissingDependency(t *testing.T) { @@ -1270,7 +1267,7 @@ func TestInstallCRDs_CheckNilErrors(t *testing.T) { err := instAction.installCRDs(tt.input) if err == nil { - t.Errorf("got nil expected err") + t.Error("got nil expected err") } }) } @@ -1290,7 +1287,7 @@ func TestInstallRelease_WaitOptionsPassedDownstream(t *testing.T) { // Access the underlying FailingKubeClient to check recorded options failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - vals := map[string]interface{}{} + vals := map[string]any{} _, err := instAction.Run(buildChart(), vals) is.NoError(err) diff --git a/pkg/action/lint.go b/pkg/action/lint.go index 208fd4637..6156fe5c8 100644 --- a/pkg/action/lint.go +++ b/pkg/action/lint.go @@ -53,7 +53,7 @@ func NewLint() *Lint { } // Run executes 'helm Lint' against the given chart. -func (l *Lint) Run(paths []string, vals map[string]interface{}) *LintResult { +func (l *Lint) Run(paths []string, vals map[string]any) *LintResult { lowestTolerance := support.ErrorSev if l.Strict { lowestTolerance = support.WarningSev @@ -87,7 +87,7 @@ func HasWarningsOrErrors(result *LintResult) bool { return len(result.Errors) > 0 } -func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) (support.Linter, error) { +func lintChart(path string, vals map[string]any, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) (support.Linter, error) { var chartPath string linter := support.Linter{} diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go index 4684f91f1..5b5c2c50a 100644 --- a/pkg/action/lint_test.go +++ b/pkg/action/lint_test.go @@ -26,7 +26,7 @@ import ( ) var ( - values = make(map[string]interface{}) + values = make(map[string]any) namespace = "testNamespace" chart1MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-1" chart2MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-2" @@ -88,12 +88,12 @@ func TestLintChart(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := lintChart(tt.chartPath, map[string]interface{}{}, namespace, nil, tt.skipSchemaValidation) + _, err := lintChart(tt.chartPath, map[string]any{}, namespace, nil, tt.skipSchemaValidation) switch { case err != nil && !tt.err: t.Errorf("%s", err) case err == nil && tt.err: - t.Errorf("Expected a chart parsing error") + t.Error("Expected a chart parsing error") } }) } diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go index 643bcea42..19596dbbb 100644 --- a/pkg/action/list_test.go +++ b/pkg/action/list_test.go @@ -65,7 +65,7 @@ func TestList_Empty(t *testing.T) { lister := NewList(actionConfigFixture(t)) list, err := lister.Run() assert.NoError(t, err) - assert.Len(t, list, 0) + assert.Empty(t, list) } func newListFixture(t *testing.T) *List { @@ -166,7 +166,7 @@ func TestList_LimitOffsetOutOfBounds(t *testing.T) { makeMeSomeReleases(t, lister.cfg.Releases) list, err := lister.Run() is.NoError(err) - is.Len(list, 0) + is.Empty(list) lister.Limit = 10 lister.Offset = 1 diff --git a/pkg/action/package.go b/pkg/action/package.go index 0ab49538c..86426b412 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -70,7 +70,7 @@ func NewPackage() *Package { } // Run executes 'helm package' against the given chart and returns the path to the packaged chart. -func (p *Package) Run(path string, _ map[string]interface{}) (string, error) { +func (p *Package) Run(path string, _ map[string]any) (string, error) { chrt, err := loader.LoadDir(path) if err != nil { return "", err diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go index 2e1d4ff07..f8a55c0e0 100644 --- a/pkg/action/package_test.go +++ b/pkg/action/package_test.go @@ -150,7 +150,6 @@ func TestValidateVersion(t *testing.T) { if !errors.Is(err, tt.wantErr) { t.Errorf("Expected {%v}, got {%v}", tt.wantErr, err) } - } }) } diff --git a/pkg/action/push_test.go b/pkg/action/push_test.go index 35c6f3efc..125799252 100644 --- a/pkg/action/push_test.go +++ b/pkg/action/push_test.go @@ -47,14 +47,14 @@ func TestNewPushWithInsecureSkipTLSVerify(t *testing.T) { client := NewPushWithOpts(WithInsecureSkipTLSVerify(true)) assert.NotNil(t, client) - assert.Equal(t, true, client.insecureSkipTLSVerify) + assert.True(t, client.insecureSkipTLSVerify) } func TestNewPushWithPlainHTTP(t *testing.T) { client := NewPushWithOpts(WithPlainHTTP(true)) assert.NotNil(t, client) - assert.Equal(t, true, client.plainHTTP) + assert.True(t, client.plainHTTP) } func TestNewPushWithPushOptWriter(t *testing.T) { diff --git a/pkg/action/registry_login_test.go b/pkg/action/registry_login_test.go index de2450d9d..474118c9f 100644 --- a/pkg/action/registry_login_test.go +++ b/pkg/action/registry_login_test.go @@ -37,7 +37,7 @@ func TestWithCertFile(t *testing.T) { certFile := "testdata/cert.pem" opt := WithCertFile(certFile) - assert.Nil(t, opt(client)) + assert.NoError(t, opt(client)) assert.Equal(t, certFile, client.certFile) } @@ -47,8 +47,8 @@ func TestWithInsecure(t *testing.T) { opt := WithInsecure(true) - assert.Nil(t, opt(client)) - assert.Equal(t, true, client.insecure) + assert.NoError(t, opt(client)) + assert.True(t, client.insecure) } func TestWithKeyFile(t *testing.T) { @@ -58,7 +58,7 @@ func TestWithKeyFile(t *testing.T) { keyFile := "testdata/key.pem" opt := WithKeyFile(keyFile) - assert.Nil(t, opt(client)) + assert.NoError(t, opt(client)) assert.Equal(t, keyFile, client.keyFile) } @@ -69,7 +69,7 @@ func TestWithCAFile(t *testing.T) { caFile := "testdata/ca.pem" opt := WithCAFile(caFile) - assert.Nil(t, opt(client)) + assert.NoError(t, opt(client)) assert.Equal(t, caFile, client.caFile) } @@ -79,6 +79,6 @@ func TestWithPlainHTTPLogin(t *testing.T) { opt := WithPlainHTTPLogin(true) - assert.Nil(t, opt(client)) - assert.Equal(t, true, client.plainHTTP) + assert.NoError(t, opt(client)) + assert.True(t, client.plainHTTP) } diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go index 043a41236..5604e8cc4 100644 --- a/pkg/action/release_testing.go +++ b/pkg/action/release_testing.go @@ -18,6 +18,7 @@ package action import ( "context" + "errors" "fmt" "io" "slices" @@ -25,6 +26,8 @@ import ( "time" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" @@ -124,9 +127,9 @@ func (r *ReleaseTesting) GetPodLogs(out io.Writer, rel *release.Release) error { return fmt.Errorf("unable to get kubernetes client to fetch pod logs: %w", err) } - hooksByWight := append([]*release.Hook{}, rel.Hooks...) - sort.Stable(hookByWeight(hooksByWight)) - for _, h := range hooksByWight { + hooksByWeight := append([]*release.Hook{}, rel.Hooks...) + sort.Stable(hookByWeight(hooksByWeight)) + for _, h := range hooksByWeight { for _, e := range h.Events { if e == release.HookTest { if slices.Contains(r.Filters[ExcludeNameFilter], h.Name) { @@ -135,20 +138,47 @@ func (r *ReleaseTesting) GetPodLogs(out io.Writer, rel *release.Release) error { if len(r.Filters[IncludeNameFilter]) > 0 && !slices.Contains(r.Filters[IncludeNameFilter], h.Name) { continue } - req := client.CoreV1().Pods(r.Namespace).GetLogs(h.Name, &v1.PodLogOptions{}) - logReader, err := req.Stream(context.Background()) - if err != nil { - return fmt.Errorf("unable to get pod logs for %s: %w", h.Name, err) - } - fmt.Fprintf(out, "POD LOGS: %s\n", h.Name) - _, err = io.Copy(out, logReader) - fmt.Fprintln(out) - if err != nil { - return fmt.Errorf("unable to write pod logs for %s: %w", h.Name, err) + if h.Kind != "Pod" { + continue + } + if err := r.getContainerLogs(out, client, h.Name); err != nil { + return err } } } } return nil } + +// getContainerLogs fetches logs from all containers (init and regular) in the +// named pod and writes them to out. It continues on per-container errors and +// returns all of them joined at the end. +func (r *ReleaseTesting) getContainerLogs(out io.Writer, client kubernetes.Interface, podName string) error { + pod, err := client.CoreV1().Pods(r.Namespace).Get(context.Background(), podName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("unable to get pod %s: %w", podName, err) + } + + allContainers := append(pod.Spec.InitContainers, pod.Spec.Containers...) + + var errs []error + for _, c := range allContainers { + opts := &v1.PodLogOptions{Container: c.Name} + req := client.CoreV1().Pods(r.Namespace).GetLogs(podName, opts) + logReader, err := req.Stream(context.Background()) + if err != nil { + errs = append(errs, fmt.Errorf("unable to get logs for pod %s, container %s: %w", podName, c.Name, err)) + continue + } + + fmt.Fprintf(out, "POD LOGS: %s (%s)\n", podName, c.Name) + _, err = io.Copy(out, logReader) + logReader.Close() + fmt.Fprintln(out) + if err != nil { + errs = append(errs, fmt.Errorf("unable to write logs for pod %s, container %s: %w", podName, c.Name, err)) + } + } + return errors.Join(errs...) +} diff --git a/pkg/action/release_testing_test.go b/pkg/action/release_testing_test.go index ab35e104a..2ec62caf0 100644 --- a/pkg/action/release_testing_test.go +++ b/pkg/action/release_testing_test.go @@ -26,6 +26,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + fakeclientset "k8s.io/client-go/kubernetes/fake" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/kube" @@ -62,13 +65,20 @@ func TestReleaseTestingGetPodLogs_FilterEvents(t *testing.T) { hooks := []*release.Hook{ { + Kind: "Pod", Name: "event-1", Events: []release.HookEvent{release.HookTest}, }, { + Kind: "Pod", Name: "event-2", Events: []release.HookEvent{release.HookTest}, }, + { + Kind: "ConfigMap", + Name: "event-3", + Events: []release.HookEvent{release.HookTest}, + }, } out := &bytes.Buffer{} @@ -77,6 +87,25 @@ func TestReleaseTestingGetPodLogs_FilterEvents(t *testing.T) { assert.Empty(t, out.String()) } +func TestReleaseTestingGetPodLogs_ExcludeFilter_SkipsPodHook(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{"excluded-pod"} + + hooks := []*release.Hook{ + { + Kind: "Pod", + Name: "excluded-pod", + 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"))) @@ -84,12 +113,36 @@ func TestReleaseTestingGetPodLogs_PodRetrievalError(t *testing.T) { hooks := []*release.Hook{ { + Kind: "Pod", Name: "event-1", Events: []release.HookEvent{release.HookTest}, }, } - require.ErrorContains(t, client.GetPodLogs(&bytes.Buffer{}, &release.Release{Hooks: hooks}), "unable to get pod logs") + require.ErrorContains(t, client.GetPodLogs(&bytes.Buffer{}, &release.Release{Hooks: hooks}), "unable to get pod") +} + +func TestReleaseTestingGetPodLogs_SkipNonPodHooks(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: "cm-hook", + Kind: "ConfigMap", + Events: []release.HookEvent{release.HookTest}, + }, + { + Name: "secret-hook", + Kind: "Secret", + Events: []release.HookEvent{release.HookTest}, + }, + } + + out := &bytes.Buffer{} + require.NoError(t, client.GetPodLogs(out, &release.Release{Hooks: hooks})) + assert.Empty(t, out.String()) } func TestReleaseTesting_WaitOptionsPassedDownstream(t *testing.T) { @@ -117,3 +170,91 @@ func TestReleaseTesting_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +func TestGetContainerLogs_MultipleContainers(t *testing.T) { + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "main"}, + {Name: "sidecar"}, + }, + }, + } + + client := fakeclientset.NewClientset(pod) + rt := &ReleaseTesting{Namespace: "default"} + + var buf bytes.Buffer + err := rt.getContainerLogs(&buf, client, "test-pod") + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "POD LOGS: test-pod (main)") + assert.Contains(t, output, "POD LOGS: test-pod (sidecar)") +} + +func TestGetContainerLogs_WithInitContainers(t *testing.T) { + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + Spec: v1.PodSpec{ + InitContainers: []v1.Container{ + {Name: "init-setup"}, + }, + Containers: []v1.Container{ + {Name: "main"}, + }, + }, + } + + client := fakeclientset.NewClientset(pod) + rt := &ReleaseTesting{Namespace: "default"} + + var buf bytes.Buffer + err := rt.getContainerLogs(&buf, client, "test-pod") + require.NoError(t, err) + output := buf.String() + // Init containers should appear before regular containers + assert.Contains(t, output, "POD LOGS: test-pod (init-setup)") + assert.Contains(t, output, "POD LOGS: test-pod (main)") +} + +func TestGetContainerLogs_PodNotFound(t *testing.T) { + client := fakeclientset.NewClientset() + rt := &ReleaseTesting{Namespace: "default"} + + var buf bytes.Buffer + err := rt.getContainerLogs(&buf, client, "nonexistent-pod") + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to get pod nonexistent-pod") +} + +func TestGetContainerLogs_OutputHeaderFormat(t *testing.T) { + pod := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-test", + Namespace: "default", + }, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "container-a"}, + {Name: "container-b"}, + }, + }, + } + + client := fakeclientset.NewClientset(pod) + rt := &ReleaseTesting{Namespace: "default"} + + var buf bytes.Buffer + err := rt.getContainerLogs(&buf, client, "multi-test") + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "POD LOGS: multi-test (container-a)") + assert.Contains(t, output, "POD LOGS: multi-test (container-b)") +} diff --git a/pkg/action/resource_policy.go b/pkg/action/resource_policy.go index fcea98ad6..4f4e2ff55 100644 --- a/pkg/action/resource_policy.go +++ b/pkg/action/resource_policy.go @@ -40,7 +40,6 @@ func filterManifestsToKeep(manifests []releaseutil.Manifest) (keep, remaining [] if resourcePolicyType == kube.KeepPolicy { keep = append(keep, m) } - } return keep, remaining } diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index 459569781..6fc449c30 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -176,10 +176,11 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Chart: previousRelease.Chart, Config: previousRelease.Config, Info: &release.Info{ - FirstDeployed: currentRelease.Info.FirstDeployed, - LastDeployed: time.Now(), - Status: common.StatusPendingRollback, - Notes: previousRelease.Info.Notes, + FirstDeployed: currentRelease.Info.FirstDeployed, + LastDeployed: time.Now(), + Status: common.StatusPendingRollback, + Notes: previousRelease.Info.Notes, + RollbackRevision: previousVersion, // Because we lose the reference to previous version elsewhere, we set the // message here, and only override it later if we experience failure. Description: fmt.Sprintf("Rollback to %d", previousVersion), diff --git a/pkg/action/rollback_test.go b/pkg/action/rollback_test.go index deb6c7c80..b34adda91 100644 --- a/pkg/action/rollback_test.go +++ b/pkg/action/rollback_test.go @@ -83,3 +83,51 @@ func TestRollback_WaitOptionsPassedDownstream(t *testing.T) { // Verify that WaitOptions were passed to GetWaiter is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") } + +func TestRollbackSetsRollbackRevision(t *testing.T) { + config := actionConfigFixture(t) + + rel1 := releaseStub() + rel1.Name = "rollback-rev-test" + rel1.Version = 1 + rel1.Info.Status = "superseded" + rel1.ApplyMethod = "csa" + require.NoError(t, config.Releases.Create(rel1)) + + rel2 := releaseStub() + rel2.Name = "rollback-rev-test" + rel2.Version = 2 + rel2.Info.Status = "deployed" + rel2.ApplyMethod = "csa" + require.NoError(t, config.Releases.Create(rel2)) + + client := NewRollback(config) + client.Version = 1 + client.ServerSideApply = "auto" + + require.NoError(t, client.Run("rollback-rev-test")) + + reli, err := config.Releases.Get("rollback-rev-test", 3) + require.NoError(t, err) + rel, err := releaserToV1Release(reli) + require.NoError(t, err) + + assert.Equal(t, 1, rel.Info.RollbackRevision) + assert.Equal(t, "Rollback to 1", rel.Info.Description) +} + +func TestRollbackRevisionZeroForNonRollback(t *testing.T) { + config := actionConfigFixture(t) + + rel := releaseStub() + rel.Name = "non-rollback" + rel.Info.Status = "deployed" + require.NoError(t, config.Releases.Create(rel)) + + reli, err := config.Releases.Get("non-rollback", 1) + require.NoError(t, err) + r, err := releaserToV1Release(reli) + require.NoError(t, err) + + assert.Equal(t, 0, r.Info.RollbackRevision) +} diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index 6e270ac6d..854dee07a 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -43,7 +43,7 @@ func TestShow(t *testing.T) { Raw: []*common.File{ {Name: "values.yaml", ModTime: modTime, Data: []byte("VALUES\n")}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } output, err := client.Run("") diff --git a/pkg/action/testdata/rbac.txt b/pkg/action/testdata/rbac.txt index 0cb15b868..91938d5cc 100644 --- a/pkg/action/testdata/rbac.txt +++ b/pkg/action/testdata/rbac.txt @@ -23,3 +23,4 @@ subjects: - kind: ServiceAccount name: schedule-agents namespace: spaced + diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 79156991c..b14305112 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -88,6 +88,73 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) if err != nil { return nil, err } + + // Verify ownership in dry-run mode to show what would actually be deleted + manifests := releaseutil.SplitManifests(r.Manifest) + _, files, err := releaseutil.SortManifests(manifests, nil, releaseutil.UninstallOrder) + if err == nil { + filesToKeep, filesToDelete := filterManifestsToKeep(files) + + var builder strings.Builder + for _, file := range filesToDelete { + builder.WriteString("\n---\n" + file.Content) + } + + resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false) + if err == nil && len(resources) > 0 { + ownedResources, unownedResources, unverifiableResources, err := verifyOwnershipBeforeDelete(resources, r.Name, r.Namespace) + if err == nil { + if len(unownedResources) > 0 { + u.cfg.Logger().Warn("dry-run: resources would be skipped because they are not owned by this release", + "release", r.Name, + "count", len(unownedResources)) + for _, info := range unownedResources { + u.cfg.Logger().Warn("dry-run: would skip resource", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace) + } + } + + if len(unverifiableResources) > 0 { + u.cfg.Logger().Warn("dry-run: resources would be skipped because their ownership could not be verified", + "release", r.Name, + "count", len(unverifiableResources)) + for _, ur := range unverifiableResources { + u.cfg.Logger().Warn("dry-run: would skip resource (ownership could not be verified)", + "kind", ur.Info.Mapping.GroupVersionKind.Kind, + "name", ur.Info.Name, + "namespace", ur.Info.Namespace, + "error", ur.Err) + } + } + + if len(ownedResources) > 0 { + u.cfg.Logger().Debug("dry-run: resources would be deleted", + "release", r.Name, + "count", len(ownedResources)) + for _, info := range ownedResources { + u.cfg.Logger().Debug("dry-run: would delete resource", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace) + } + } + } + } + + // Include kept resources in dry-run info + if len(filesToKeep) > 0 { + var kept strings.Builder + kept.WriteString("These resources were kept due to the resource policy:\n") + for _, f := range filesToKeep { + fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) + } + res := &releasei.UninstallReleaseResponse{Release: r, Info: kept.String()} + return res, nil + } + } + return &releasei.UninstallReleaseResponse{Release: r}, nil } @@ -153,9 +220,6 @@ func (u *Uninstall) Run(name string) (*releasei.UninstallReleaseResponse, error) return nil, fmt.Errorf("failed to delete release: %s", name) } - if kept != "" { - kept = "These resources were kept due to the resource policy:\n" + kept - } res.Info = kept if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil { @@ -269,8 +333,11 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri filesToKeep, filesToDelete := filterManifestsToKeep(files) var kept strings.Builder - for _, f := range filesToKeep { - fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) + if len(filesToKeep) > 0 { + kept.WriteString("These resources were kept due to the resource policy:\n") + for _, f := range filesToKeep { + fmt.Fprintf(&kept, "[%s] %s\n", f.Head.Kind, f.Head.Metadata.Name) + } } var builder strings.Builder @@ -282,13 +349,69 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri if err != nil { return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)} } + + // Verify ownership before deleting resources + var ownedResources, unownedResources kube.ResourceList + var unverifiableResources []unverifiableResource if len(resources) > 0 { - _, errs = u.cfg.KubeClient.Delete(resources, parseCascadingFlag(u.DeletionPropagation)) + ownedResources, unownedResources, unverifiableResources, err = verifyOwnershipBeforeDelete(resources, rel.Name, rel.Namespace) + if err != nil { + return nil, "", []error{fmt.Errorf("unable to verify resource ownership: %w", err)} + } + + // Log warnings for unowned resources + if len(unownedResources) > 0 { + for _, info := range unownedResources { + u.cfg.Logger().Warn("skipping delete of resource not owned by this release", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace, + "release", rel.Name) + } + if kept.Len() > 0 { + kept.WriteString("\n") + } + fmt.Fprintf(&kept, "%d resource(s) were not deleted because they are not owned by this release:\n", len(unownedResources)) + for _, info := range unownedResources { + fmt.Fprintf(&kept, "[%s] %s\n", info.Mapping.GroupVersionKind.Kind, info.Name) + } + } + + // Log warnings for resources whose ownership could not be verified + if len(unverifiableResources) > 0 { + for _, ur := range unverifiableResources { + u.cfg.Logger().Warn("skipping delete of resource because ownership could not be verified", + "kind", ur.Info.Mapping.GroupVersionKind.Kind, + "name", ur.Info.Name, + "namespace", ur.Info.Namespace, + "release", rel.Name, + "error", ur.Err) + } + if kept.Len() > 0 { + kept.WriteString("\n") + } + fmt.Fprintf(&kept, "%d resource(s) were not deleted because their ownership could not be verified:\n", len(unverifiableResources)) + for _, ur := range unverifiableResources { + fmt.Fprintf(&kept, "[%s] %s: %s\n", ur.Info.Mapping.GroupVersionKind.Kind, ur.Info.Name, ur.Err) + } + } + + // Delete only owned resources + if len(ownedResources) > 0 { + for _, info := range ownedResources { + u.cfg.Logger().Debug("deleting resource owned by this release", + "kind", info.Mapping.GroupVersionKind.Kind, + "name", info.Name, + "namespace", info.Namespace, + "release", rel.Name) + } + _, errs = u.cfg.KubeClient.Delete(ownedResources, parseCascadingFlag(u.DeletionPropagation, u.cfg.Logger())) + } } - return resources, kept.String(), errs + return ownedResources, kept.String(), errs } -func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { +func parseCascadingFlag(cascadingFlag string, logger *slog.Logger) v1.DeletionPropagation { switch cascadingFlag { case "orphan": return v1.DeletePropagationOrphan @@ -297,7 +420,7 @@ func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation { case "background": return v1.DeletePropagationBackground default: - slog.Debug("uninstall: given cascade value, defaulting to delete propagation background", "value", cascadingFlag) + logger.Debug("uninstall: given cascade value, defaulting to delete propagation background", "value", cascadingFlag) return v1.DeletePropagationBackground } } diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index b5a76d983..90f69d639 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -17,10 +17,10 @@ limitations under the License. package action import ( - "context" + "bytes" "errors" - "fmt" "io" + "log/slog" "testing" "github.com/stretchr/testify/assert" @@ -115,7 +115,7 @@ func TestUninstallRelease_Wait(t *testing.T) { }` require.NoError(t, unAction.cfg.Releases.Create(rel)) failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitForDeleteError = fmt.Errorf("U timed out") + failer.WaitForDeleteError = errors.New("U timed out") unAction.cfg.KubeClient = failer resi, err := unAction.Run(rel.Name) is.Error(err) @@ -148,9 +148,16 @@ func TestUninstallRelease_Cascade(t *testing.T) { } }` require.NoError(t, unAction.cfg.Releases.Create(rel)) + + // Create dummy resources with Mapping but no Client - this skips ownership verification + // (nil Client is treated as owned) and goes directly to delete + dummyResources := kube.ResourceList{ + newDeploymentResource("secret", "", ""), + } + failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.DeleteError = fmt.Errorf("Uninstall with cascade failed") - failer.BuildDummy = true + failer.DeleteError = errors.New("Uninstall with cascade failed") + failer.DummyResources = dummyResources unAction.cfg.KubeClient = failer _, err := unAction.Run(rel.Name) require.Error(t, err) @@ -171,39 +178,177 @@ func TestUninstallRun_UnreachableKubeClient(t *testing.T) { assert.ErrorContains(t, err, "connection refused") } -func TestUninstall_WaitOptionsPassedDownstream(t *testing.T) { +func TestUninstallRelease_OwnershipVerification(t *testing.T) { is := assert.New(t) - unAction := uninstallAction(t) + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) unAction.DisableHooks = true unAction.DryRun = false - unAction.WaitStrategy = kube.StatusWatcherStrategy + unAction.KeepHistory = true + + rel := releaseStub() + rel.Name = "ownership-test" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: ownership-test + meta.helm.sh/release-namespace: default +data: + key: value` + require.NoError(t, config.Releases.Create(rel)) + + // Create dummy resources with proper ownership metadata + labels := map[string]string{ + "app.kubernetes.io/managed-by": "Helm", + } + annotations := map[string]string{ + "meta.helm.sh/release-name": "ownership-test", + "meta.helm.sh/release-namespace": "default", + } + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("owned-deploy", "default", labels, annotations), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources - // Use WithWaitContext as a marker WaitOption that we can track - ctx := context.Background() - unAction.WaitOptions = []kube.WaitOption{kube.WithWaitContext(ctx)} + resi, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(resi) + res, err := releaserToV1Release(resi.Release) + is.NoError(err) + is.Equal(common.StatusUninstalled, res.Info.Status) + + // Verify log contains debug message about deleting owned resource + logOutput := logBuffer.String() + is.Contains(logOutput, "deleting resource owned by this release") + is.Contains(logOutput, "owned-deploy") + is.Contains(logOutput, "Deployment") +} + +func TestUninstallRelease_OwnershipVerification_WithKeepPolicy(t *testing.T) { + is := assert.New(t) + + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) + unAction.DisableHooks = true + unAction.DryRun = false + unAction.KeepHistory = true rel := releaseStub() - rel.Name = "wait-options-uninstall" - rel.Manifest = `{ - "apiVersion": "v1", - "kind": "Secret", - "metadata": { - "name": "secret" - }, - "type": "Opaque", - "data": { - "password": "password" - } - }` - require.NoError(t, unAction.cfg.Releases.Create(rel)) + rel.Name = "keep-and-ownership" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: Secret +metadata: + name: kept-secret + annotations: + helm.sh/resource-policy: keep + meta.helm.sh/release-name: keep-and-ownership + meta.helm.sh/release-namespace: default + labels: + app.kubernetes.io/managed-by: Helm +type: Opaque +data: + password: cGFzc3dvcmQ= +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: deleted-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: keep-and-ownership + meta.helm.sh/release-namespace: default +data: + key: value` + require.NoError(t, config.Releases.Create(rel)) - // Access the underlying FailingKubeClient to check recorded options - failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient) + // Create dummy resources - one unowned to test logging + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("unowned-deploy", "default", nil, nil), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources - _, err := unAction.Run(rel.Name) + res, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(res) + // Should contain info about kept resources + is.Contains(res.Info, "kept due to the resource policy") + + // Verify log contains warning about skipped unowned resource + logOutput := logBuffer.String() + is.Contains(logOutput, "skipping delete of resource not owned by this release") + is.Contains(logOutput, "unowned-deploy") +} + +func TestUninstallRelease_DryRun_OwnershipVerification(t *testing.T) { + is := assert.New(t) + + // Create a buffer to capture log output + logBuffer := &bytes.Buffer{} + handler := slog.NewTextHandler(logBuffer, &slog.HandlerOptions{Level: slog.LevelWarn}) + + config := actionConfigFixture(t) + config.SetLogger(handler) + + unAction := NewUninstall(config) + unAction.DisableHooks = true + unAction.DryRun = true + + rel := releaseStub() + rel.Name = "dryrun-ownership" + rel.Namespace = "default" + rel.Manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-configmap + labels: + app.kubernetes.io/managed-by: Helm + annotations: + meta.helm.sh/release-name: dryrun-ownership + meta.helm.sh/release-namespace: default +data: + key: value` + require.NoError(t, config.Releases.Create(rel)) + + // Create dummy resources - one unowned to test dry-run logging + dummyResources := kube.ResourceList{ + newDeploymentWithOwner("dryrun-unowned-deploy", "default", nil, nil), + } + failer := config.KubeClient.(*kubefake.FailingKubeClient) + failer.DummyResources = dummyResources + + resi, err := unAction.Run(rel.Name) + is.NoError(err) + is.NotNil(resi) + is.NotNil(resi.Release) + res, err := releaserToV1Release(resi.Release) is.NoError(err) + is.Equal("dryrun-ownership", res.Name) - // Verify that WaitOptions were passed to GetWaiter - is.NotEmpty(failer.RecordedWaitOptions, "WaitOptions should be passed to GetWaiter") + // Verify log contains dry-run warning about resources that would be skipped + logOutput := logBuffer.String() + is.Contains(logOutput, "dry-run: would skip resource") + is.Contains(logOutput, "dryrun-unowned-deploy") + is.Contains(logOutput, "Deployment") } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 4c5a5b55e..81d51164f 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -121,6 +121,10 @@ type Upgrade struct { // If this is non-nil, then after templates are rendered, they will be sent to the // post renderer before sending to the Kubernetes API server. PostRenderer postrenderer.PostRenderer + // PostRenderStrategy controls how hooks and regular templates are passed + // to the configured post-renderer. See PostRenderStrategy for the + // available modes. Defaults to PostRenderStrategyCombined. + PostRenderStrategy PostRenderStrategy // DisableOpenAPIValidation controls whether OpenAPI validation is enforced. DisableOpenAPIValidation bool // Get missing dependencies @@ -141,9 +145,10 @@ type resultMessage struct { // NewUpgrade creates a new Upgrade object with the given configuration. func NewUpgrade(cfg *Configuration) *Upgrade { up := &Upgrade{ - cfg: cfg, - ServerSideApply: "auto", // Must always match the CLI default. - DryRunStrategy: DryRunNone, + cfg: cfg, + ServerSideApply: "auto", // Must always match the CLI default. + DryRunStrategy: DryRunNone, + PostRenderStrategy: PostRenderStrategyCombined, } up.registryClient = cfg.RegistryClient @@ -188,7 +193,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char } u.cfg.Logger().Debug("preparing upgrade", "name", name) - currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(name, chrt, vals) + currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(ctx, name, chrt, vals) if err != nil { return nil, err } @@ -213,7 +218,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, ch chart.Char } // prepareUpgrade builds an upgraded release for an upgrade operation. -func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, error) { +func (u *Upgrade) prepareUpgrade(ctx context.Context, name string, chart *chartv2.Chart, vals map[string]any) (*release.Release, *release.Release, bool, error) { if chart == nil { return nil, nil, false, errMissingChart } @@ -253,7 +258,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str var cerr error currentRelease, cerr = releaserToV1Release(currentReleasei) if cerr != nil { - return nil, nil, false, err + return nil, nil, false, cerr } if err != nil { if errors.Is(err, driver.ErrNoDeployedReleases) && @@ -263,7 +268,6 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } } - } // determine if values will be reused @@ -296,7 +300,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chartv2.Chart, vals map[str return nil, nil, false, err } - hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret) + hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(ctx, chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithServer(u.DryRunStrategy), u.EnableDNS, u.HideSecret, u.PostRenderStrategy) if err != nil { return nil, nil, false, err } @@ -455,7 +459,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele if !u.DisableHooks { if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { - u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %s", err)) + u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %w", err)) return } } else { @@ -503,7 +507,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele // post-upgrade hooks if !u.DisableHooks { if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.WaitOptions, u.Timeout, serverSideApply); err != nil { - u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %s", err)) + u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %w", err)) return } } diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index 848e8a682..bd020cbdb 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -60,7 +60,7 @@ func TestUpgradeRelease_Success(t *testing.T) { req.NoError(upAction.cfg.Releases.Create(rel)) upAction.WaitStrategy = kube.StatusWatcherStrategy - vals := map[string]interface{}{} + vals := map[string]any{} ctx, done := context.WithCancel(t.Context()) resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals) @@ -91,10 +91,10 @@ func TestUpgradeRelease_Wait(t *testing.T) { require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") + failer.WaitError = errors.New("I timed out") upAction.cfg.KubeClient = failer upAction.WaitStrategy = kube.StatusWatcherStrategy - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) @@ -115,11 +115,11 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) { require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") + failer.WaitError = errors.New("I timed out") upAction.cfg.KubeClient = failer upAction.WaitStrategy = kube.StatusWatcherStrategy upAction.WaitForJobs = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) @@ -140,12 +140,12 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) { require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.WaitError = fmt.Errorf("I timed out") - failer.DeleteError = fmt.Errorf("I tried to delete nil") + failer.WaitError = errors.New("I timed out") + failer.DeleteError = errors.New("I tried to delete nil") upAction.cfg.KubeClient = failer upAction.WaitStrategy = kube.StatusWatcherStrategy upAction.CleanupOnFail = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) @@ -170,10 +170,10 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) // We can't make Update error because then the rollback won't work - failer.WatchUntilReadyError = fmt.Errorf("arming key removed") + failer.WatchUntilReadyError = errors.New("arming key removed") upAction.cfg.KubeClient = failer upAction.RollbackOnFailure = true - vals := map[string]interface{}{} + vals := map[string]any{} resi, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) @@ -199,10 +199,10 @@ func TestUpgradeRelease_RollbackOnFailure(t *testing.T) { require.NoError(t, upAction.cfg.Releases.Create(rel)) failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - failer.UpdateError = fmt.Errorf("update fail") + failer.UpdateError = errors.New("update fail") upAction.cfg.KubeClient = failer upAction.RollbackOnFailure = true - vals := map[string]interface{}{} + vals := map[string]any{} _, err := upAction.Run(rel.Name, buildChart(), vals) req.Error(err) @@ -217,17 +217,17 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { t.Run("reuse values should work with values", func(t *testing.T) { upAction := upgradeAction(t) - existingValues := map[string]interface{}{ + existingValues := map[string]any{ "name": "value", "maxHeapSize": "128m", "replicas": 2, } - newValues := map[string]interface{}{ + newValues := map[string]any{ "name": "newValue", "maxHeapSize": "512m", "cpu": "12m", } - expectedValues := map[string]interface{}{ + expectedValues := map[string]any{ "name": "newValue", "maxHeapSize": "512m", "cpu": "12m", @@ -266,8 +266,8 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { t.Run("reuse values should not install disabled charts", func(t *testing.T) { upAction := upgradeAction(t) - chartDefaultValues := map[string]interface{}{ - "subchart": map[string]interface{}{ + chartDefaultValues := map[string]any{ + "subchart": map[string]any{ "enabled": true, }, } @@ -283,8 +283,8 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { withMetadataDependency(dependency), ) now := time.Now() - existingValues := map[string]interface{}{ - "subchart": map[string]interface{}{ + existingValues := map[string]any{ + "subchart": map[string]any{ "enabled": false, }, } @@ -311,7 +311,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { withMetadataDependency(dependency), ) // reusing values and upgrading - resi, err := upAction.Run(rel.Name, sampleChartWithSubChart, map[string]interface{}{}) + resi, err := upAction.Run(rel.Name, sampleChartWithSubChart, map[string]any{}) is.NoError(err) res, err := releaserToV1Release(resi) is.NoError(err) @@ -328,10 +328,10 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { is.NoError(err) is.Equal(common.StatusDeployed, updatedRes.Info.Status) - is.Equal(0, len(updatedRes.Chart.Dependencies()), "expected 0 dependencies") + is.Empty(updatedRes.Chart.Dependencies(), "expected 0 dependencies") - expectedValues := map[string]interface{}{ - "subchart": map[string]interface{}{ + expectedValues := map[string]any{ + "subchart": map[string]any{ "enabled": false, }, } @@ -345,20 +345,20 @@ func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) { t.Run("reset then reuse values should work with values", func(t *testing.T) { upAction := upgradeAction(t) - existingValues := map[string]interface{}{ + existingValues := map[string]any{ "name": "value", "maxHeapSize": "128m", "replicas": 2, } - newValues := map[string]interface{}{ + newValues := map[string]any{ "name": "newValue", "maxHeapSize": "512m", "cpu": "12m", } - newChartValues := map[string]interface{}{ + newChartValues := map[string]any{ "memory": "256m", } - expectedValues := map[string]interface{}{ + expectedValues := map[string]any{ "name": "newValue", "maxHeapSize": "512m", "cpu": "12m", @@ -411,7 +411,7 @@ func TestUpgradeRelease_Pending(t *testing.T) { rel2.Version = 2 require.NoError(t, upAction.cfg.Releases.Create(rel2)) - vals := map[string]interface{}{} + vals := map[string]any{} _, err := upAction.Run(rel.Name, buildChart(), vals) req.Contains(err.Error(), "progress", err) @@ -431,7 +431,7 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { failer.WaitDuration = 10 * time.Second upAction.cfg.KubeClient = failer upAction.WaitStrategy = kube.StatusWatcherStrategy - vals := map[string]interface{}{} + vals := map[string]any{} ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) @@ -446,7 +446,6 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) { } func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { - is := assert.New(t) req := require.New(t) @@ -460,7 +459,7 @@ func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) { failer.WaitDuration = 5 * time.Second upAction.cfg.KubeClient = failer upAction.RollbackOnFailure = true - vals := map[string]interface{}{} + vals := map[string]any{} ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) @@ -590,7 +589,7 @@ func TestUpgradeRelease_DryRun(t *testing.T) { req.NoError(upAction.cfg.Releases.Create(rel)) upAction.DryRunStrategy = DryRunClient - vals := map[string]interface{}{} + vals := map[string]any{} ctx, done := context.WithCancel(t.Context()) resi, err := upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) @@ -610,7 +609,7 @@ func TestUpgradeRelease_DryRun(t *testing.T) { // Test the case for hiding the secret to ensure it is not displayed upAction.HideSecret = true - vals = map[string]interface{}{} + vals = map[string]any{} ctx, done = context.WithCancel(t.Context()) resi, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) @@ -630,7 +629,7 @@ func TestUpgradeRelease_DryRun(t *testing.T) { // Ensure in a dry run mode when using HideSecret upAction.DryRunStrategy = DryRunNone - vals = map[string]interface{}{} + vals = map[string]any{} ctx, done = context.WithCancel(t.Context()) _, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals) @@ -716,7 +715,7 @@ func TestGetUpgradeServerSideValue(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { serverSideApply, err := getUpgradeServerSideValue(tt.actionServerSideOption, tt.releaseApplyMethod) - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, tt.expectedServerSideApply, serverSideApply) }) } @@ -741,7 +740,6 @@ func TestGetUpgradeServerSideValue(t *testing.T) { assert.ErrorContains(t, err, tt.expectedErrorMsg) }) } - } func TestUpgradeRun_UnreachableKubeClient(t *testing.T) { @@ -752,7 +750,7 @@ func TestUpgradeRun_UnreachableKubeClient(t *testing.T) { config.KubeClient = &failingKubeClient client := NewUpgrade(config) - vals := map[string]interface{}{} + vals := map[string]any{} result, err := client.Run("", buildChart(), vals) assert.Nil(t, result) @@ -795,7 +793,7 @@ func TestUpgradeRelease_WaitOptionsPassedDownstream(t *testing.T) { // Access the underlying FailingKubeClient to check recorded options failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient) - vals := map[string]interface{}{} + vals := map[string]any{} _, err := upAction.Run(rel.Name, buildChart(), vals) req.NoError(err) diff --git a/pkg/action/validate.go b/pkg/action/validate.go index 1bef5a742..948005521 100644 --- a/pkg/action/validate.go +++ b/pkg/action/validate.go @@ -17,6 +17,7 @@ limitations under the License. package action import ( + "errors" "fmt" "maps" @@ -46,6 +47,11 @@ func requireAdoption(resources kube.ResourceList) (kube.ResourceList, error) { return err } + isGenerateName, err := validateNameAndGenerateName(info) + if isGenerateName || err != nil { + return err + } + helper := resource.NewHelper(info.Client, info.Mapping) _, err = helper.Get(info.Namespace, info.Name) if err != nil { @@ -71,6 +77,11 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return err } + isGenerateName, err := validateNameAndGenerateName(info) + if isGenerateName || err != nil { + return err + } + helper := resource.NewHelper(info.Client, info.Mapping) existing, err := helper.Get(info.Namespace, info.Name) if err != nil { @@ -82,9 +93,9 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN // Allow adoption of the resource if it is managed by Helm and is annotated with correct release name and namespace. if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { - return fmt.Errorf("%s exists and cannot be imported into the current release: %s", resourceString(info), err) + return fmt.Errorf("%s exists and cannot be imported into the current release: %w", resourceString(info), err) } - + // Resources that are not found are skipped because they are already deleted and do not need deletion. infoCopy := *info requireUpdate.Append(&infoCopy) return nil @@ -93,6 +104,69 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN return requireUpdate, err } +// unverifiableResource pairs a resource with the error encountered while attempting +// to verify its ownership (for example, RBAC or network failures). +type unverifiableResource struct { + Info *resource.Info + Err error +} + +// verifyOwnershipBeforeDelete checks that resources in the list are owned by the specified release. +// It returns three lists: +// - owned: resources confirmed to be owned by the release (safe to delete). +// - unowned: resources that exist but are not owned by the release (should be skipped). +// - unverifiable: resources whose ownership could not be determined due to a fetch +// error (e.g. RBAC or network issues), paired with the underlying error. +// +// Resources that are not found on the server are excluded from all returned lists, +// since they have already been deleted and require no further action. +func verifyOwnershipBeforeDelete(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, kube.ResourceList, []unverifiableResource, error) { + var owned kube.ResourceList + var unowned kube.ResourceList + var unverifiable []unverifiableResource + + err := resources.Visit(func(info *resource.Info, err error) error { + if err != nil { + return err + } + + // If client is not available, skip verification (test scenario or build failure) + if info.Client == nil { + infoCopy := *info + owned.Append(&infoCopy) + return nil + } + + helper := resource.NewHelper(info.Client, info.Mapping) + existing, err := helper.Get(info.Namespace, info.Name) + if err != nil { + if apierrors.IsNotFound(err) { + // Resource already deleted; nothing to do. + return nil + } + // Cannot fetch resource (network/permission issue); ownership unverifiable. + infoCopy := *info + unverifiable = append(unverifiable, unverifiableResource{Info: &infoCopy, Err: err}) + return nil + } + + // Verify ownership of the existing resource + if err := checkOwnership(existing, releaseName, releaseNamespace); err != nil { + // Resource not owned by this release, cannot delete + infoCopy := *info + unowned.Append(&infoCopy) + return nil + } + + // Resource is owned by this release, can delete + infoCopy := *info + owned.Append(&infoCopy) + return nil + }) + + return owned, unowned, unverifiable, err +} + func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) error { lbls, err := accessor.Labels(obj) if err != nil { @@ -105,13 +179,13 @@ func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) er var errs []error if err := requireValue(lbls, appManagedByLabel, appManagedByHelm); err != nil { - errs = append(errs, fmt.Errorf("label validation error: %s", err)) + errs = append(errs, fmt.Errorf("label validation error: %w", err)) } if err := requireValue(annos, helmReleaseNameAnnotation, releaseName); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) + errs = append(errs, fmt.Errorf("annotation validation error: %w", err)) } if err := requireValue(annos, helmReleaseNamespaceAnnotation, releaseNamespace); err != nil { - errs = append(errs, fmt.Errorf("annotation validation error: %s", err)) + errs = append(errs, fmt.Errorf("annotation validation error: %w", err)) } if len(errs) > 0 { @@ -143,7 +217,7 @@ func setMetadataVisitor(releaseName, releaseNamespace string, forceOwnership boo if !forceOwnership { if err := checkOwnership(info.Object, releaseName, releaseNamespace); err != nil { - return fmt.Errorf("%s cannot be owned: %s", resourceString(info), err) + return fmt.Errorf("%s cannot be owned: %w", resourceString(info), err) } } @@ -151,7 +225,7 @@ func setMetadataVisitor(releaseName, releaseNamespace string, forceOwnership boo appManagedByLabel: appManagedByHelm, }); err != nil { return fmt.Errorf( - "%s labels could not be updated: %s", + "%s labels could not be updated: %w", resourceString(info), err, ) } @@ -161,7 +235,7 @@ func setMetadataVisitor(releaseName, releaseNamespace string, forceOwnership boo helmReleaseNamespaceAnnotation: releaseNamespace, }); err != nil { return fmt.Errorf( - "%s annotations could not be updated: %s", + "%s annotations could not be updated: %w", resourceString(info), err, ) } @@ -201,3 +275,23 @@ func mergeStrStrMaps(current, desired map[string]string) map[string]string { maps.Copy(result, desired) return result } + +// validateNameAndGenerateName validates that an object only has either `Name` or `GenerateName` set (and not both) +// If `GenerateName` is set, true is returned +// If an invalid combination of `Name` and `GenerateName` are set, an error is returned +func validateNameAndGenerateName(info *resource.Info) (bool, error) { + accessor, err := meta.Accessor(info.Object) + if err != nil { + return false, err + } + + if info.Name == "" && accessor.GetGenerateName() != "" { + return true, nil + } + + if info.Name != "" && accessor.GetGenerateName() != "" { + return true, errors.New("metadata.name and metadata.generateName cannot both be set") + } + + return false, nil +} diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go index 879a5fa4f..3624d3676 100644 --- a/pkg/action/validate_test.go +++ b/pkg/action/validate_test.go @@ -36,7 +36,7 @@ import ( "k8s.io/client-go/rest/fake" ) -func newDeploymentResource(name, namespace string) *resource.Info { +func newDeploymentResource(name, namespace, generateName string) *resource.Info { return &resource.Info{ Name: name, Mapping: &meta.RESTMapping{ @@ -45,8 +45,9 @@ func newDeploymentResource(name, namespace string) *resource.Info { }, Object: &appsv1.Deployment{ ObjectMeta: v1.ObjectMeta{ - Name: name, - Namespace: namespace, + Name: name, + Namespace: namespace, + GenerateName: generateName, }, }, } @@ -166,7 +167,7 @@ func TestExistingResourceConflict(t *testing.T) { } func TestCheckOwnership(t *testing.T) { - deployFoo := newDeploymentResource("foo", "ns-a") + deployFoo := newDeploymentResource("foo", "ns-a", "") // Verify that a resource that lacks labels/annotations is not owned err := checkOwnership(deployFoo.Object, "rel-a", "ns-a") @@ -210,11 +211,103 @@ func TestCheckOwnership(t *testing.T) { assert.EqualError(t, err, `invalid ownership metadata; label validation error: key "app.kubernetes.io/managed-by" must equal "Helm": current value is "helm"`) } +func TestVerifyOwnershipBeforeDelete(t *testing.T) { + var ( + releaseName = "rel-a" + releaseNamespace = "ns-a" + labels = map[string]string{ + appManagedByLabel: appManagedByHelm, + } + annotations = map[string]string{ + helmReleaseNameAnnotation: releaseName, + helmReleaseNamespaceAnnotation: releaseNamespace, + } + wrongAnnotations = map[string]string{ + helmReleaseNameAnnotation: "rel-b", + helmReleaseNamespaceAnnotation: releaseNamespace, + } + ) + + // Test all resources properly owned + t.Run("all resources owned", func(t *testing.T) { + owned1 := newDeploymentWithOwner("owned1", "ns-a", labels, annotations) + owned2 := newDeploymentWithOwner("owned2", "ns-a", labels, annotations) + resources := kube.ResourceList{owned1, owned2} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 2) + assert.Empty(t, unownedList) + }) + + // Test mix of owned and unowned resources + t.Run("mixed ownership", func(t *testing.T) { + owned := newDeploymentWithOwner("owned", "ns-a", labels, annotations) + unowned := newDeploymentWithOwner("unowned", "ns-a", labels, wrongAnnotations) + resources := kube.ResourceList{owned, unowned} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 1) + assert.Len(t, unownedList, 1) + assert.Equal(t, "owned", ownedList[0].Name) + assert.Equal(t, "unowned", unownedList[0].Name) + }) + + // Test resource not found (should be skipped - not in either list) + t.Run("resource not found", func(t *testing.T) { + missing := newMissingDeployment("missing", "ns-a") + resources := kube.ResourceList{missing} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Empty(t, ownedList) + assert.Empty(t, unownedList) + }) + + // Test resource with no ownership metadata + t.Run("no ownership metadata", func(t *testing.T) { + noMeta := newDeploymentWithOwner("no-meta", "ns-a", nil, nil) + resources := kube.ResourceList{noMeta} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Empty(t, ownedList) + assert.Len(t, unownedList, 1) + }) + + // Test resource owned by different release + t.Run("owned by different release", func(t *testing.T) { + otherRelease := newDeploymentWithOwner("other", "ns-a", labels, wrongAnnotations) + resources := kube.ResourceList{otherRelease} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Empty(t, ownedList) + assert.Len(t, unownedList, 1) + }) + + // Test mixed scenario: owned, unowned, and missing resources + t.Run("mixed with missing resources", func(t *testing.T) { + owned := newDeploymentWithOwner("owned", "ns-a", labels, annotations) + unowned := newDeploymentWithOwner("unowned", "ns-a", labels, wrongAnnotations) + missing := newMissingDeployment("missing", "ns-a") + resources := kube.ResourceList{owned, unowned, missing} + + ownedList, unownedList, _, err := verifyOwnershipBeforeDelete(resources, releaseName, releaseNamespace) + assert.NoError(t, err) + assert.Len(t, ownedList, 1) + assert.Len(t, unownedList, 1) + assert.Equal(t, "owned", ownedList[0].Name) + assert.Equal(t, "unowned", unownedList[0].Name) + }) +} + func TestSetMetadataVisitor(t *testing.T) { var ( err error - deployFoo = newDeploymentResource("foo", "ns-a") - deployBar = newDeploymentResource("bar", "ns-a-system") + deployFoo = newDeploymentResource("foo", "ns-a", "") + deployBar = newDeploymentResource("bar", "ns-a-system", "") resources = kube.ResourceList{deployFoo, deployBar} ) @@ -235,8 +328,53 @@ func TestSetMetadataVisitor(t *testing.T) { assert.NoError(t, err) // Add a new resource that is missing ownership metadata and verify error - resources.Append(newDeploymentResource("baz", "default")) + resources.Append(newDeploymentResource("baz", "default", "")) err = resources.Visit(setMetadataVisitor("rel-b", "ns-a", false)) assert.Error(t, err) assert.Contains(t, err.Error(), `Deployment "baz" in namespace "" cannot be owned`) } + +func TestValidateNameAndGenerateName(t *testing.T) { + tests := []struct { + name string + info *resource.Info + wantSkip bool + wantErr bool + errContains string + }{ + { + name: "both name and generateName present", + info: newDeploymentResource("job-a", "foo", "job-a-"), + wantSkip: true, + wantErr: true, + errContains: "metadata.name and metadata.generateName cannot both be set", + }, + { + name: "only generateName present", + info: newDeploymentResource("", "foo", "job-a-"), + wantSkip: true, + wantErr: false, + }, + { + name: "only name present", + info: newDeploymentResource("job-a", "foo", ""), + wantSkip: false, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + skip, err := validateNameAndGenerateName(tc.info) + + if tc.wantErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tc.wantSkip, skip) + }) + } +} diff --git a/pkg/chart/common.go b/pkg/chart/common.go index 288227953..cec2c7091 100644 --- a/pkg/chart/common.go +++ b/pkg/chart/common.go @@ -17,7 +17,6 @@ package chart import ( "errors" - "fmt" "log/slog" "reflect" "strings" @@ -192,7 +191,7 @@ func structToMap(obj any) (map[string]any, error) { // Check if the input is a struct if objValue.Kind() != reflect.Struct { - return nil, fmt.Errorf("input must be a struct or a pointer to a struct") + return nil, errors.New("input must be a struct or a pointer to a struct") } result := make(map[string]any) diff --git a/pkg/chart/common/capabilities.go b/pkg/chart/common/capabilities.go index 48d6e993f..16910acaa 100644 --- a/pkg/chart/common/capabilities.go +++ b/pkg/chart/common/capabilities.go @@ -167,14 +167,13 @@ func makeDefaultCapabilities() (*Capabilities, error) { } 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), + Major: strconv.FormatUint(kubeVersionMajor, 10), + Minor: strconv.FormatUint(kubeVersionMinor, 10), }, APIVersions: DefaultVersionSet, HelmVersion: helmversion.Get(), diff --git a/pkg/chart/common/capabilities_test.go b/pkg/chart/common/capabilities_test.go index b96d7d29b..c8d47581c 100644 --- a/pkg/chart/common/capabilities_test.go +++ b/pkg/chart/common/capabilities_test.go @@ -60,15 +60,15 @@ func TestDefaultCapabilities(t *testing.T) { } hv := caps.HelmVersion - if hv.Version != "v4.1" { - t.Errorf("Expected default HelmVersion to be v4.1, got %q", hv.Version) + if hv.Version != "v4.2" { + t.Errorf("Expected default HelmVersion to be v4.2, got %q", hv.Version) } } func TestParseKubeVersion(t *testing.T) { kv, err := ParseKubeVersion("v1.16.0") if err != nil { - t.Errorf("Expected v1.16.0 to parse successfully") + t.Error("Expected v1.16.0 to parse successfully") } if kv.Version != "v1.16.0" { t.Errorf("Expected parsed KubeVersion.Version to be v1.16.0, got %q", kv.String()) diff --git a/pkg/chart/common/util/coalesce.go b/pkg/chart/common/util/coalesce.go index 5994febbc..999eeb208 100644 --- a/pkg/chart/common/util/coalesce.go +++ b/pkg/chart/common/util/coalesce.go @@ -251,6 +251,12 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]any, prefix st // If the key is a child chart, coalesce tables with Merge set to true merge := childChartMergeTrue(c, key, merge) + // When coalescing, clean nils from chart defaults before merging + // so they don't leak into the result. + if !merge { + cleanNilValues(src) + } + // Because v has higher precedence than nv, dest values override src // values. coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge) @@ -258,6 +264,16 @@ func coalesceValues(printf printFn, c chart.Charter, v map[string]any, prefix st } } else { // If the key is not in v, copy it from nv. + // When coalescing, skip chart default nils and clean nils from + // nested maps so they don't shadow globals or produce %!s(). + if !merge { + if val == nil { + continue + } + if sub, ok := val.(map[string]any); ok { + cleanNilValues(sub) + } + } v[key] = val } } @@ -326,7 +342,6 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]any, prefix strin // But if src also has nil (or key not in src), preserve the nil delete(dst, key) } else if !ok { - // key not in user values, preserve src value (including nil) dst[key] = val } else if istable(val) { if istable(dv) { @@ -341,6 +356,18 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]any, prefix strin return dst } +// cleanNilValues recursively removes nil entries in-place from a map so that chart +// default nils don't leak into the coalesced result. +func cleanNilValues(m map[string]any) { + for key, val := range m { + if val == nil { + delete(m, key) + } else if sub, ok := val.(map[string]any); ok { + cleanNilValues(sub) + } + } +} + // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. func istable(v any) bool { _, ok := v.(map[string]any) diff --git a/pkg/chart/common/util/coalesce_test.go b/pkg/chart/common/util/coalesce_test.go index 1d0baa84d..33274920c 100644 --- a/pkg/chart/common/util/coalesce_test.go +++ b/pkg/chart/common/util/coalesce_test.go @@ -666,7 +666,6 @@ func TestMergeTables(t *testing.T) { } func TestCoalesceValuesWarnings(t *testing.T) { - c := withDeps(&chart.Chart{ Metadata: &chart.Metadata{Name: "level1"}, Values: map[string]any{ @@ -724,7 +723,6 @@ func TestCoalesceValuesWarnings(t *testing.T) { assert.Contains(t, warnings, "warning: skipped value for level1.level2.level3.boat: Not a table.") assert.Contains(t, warnings, "warning: destination for level1.level2.level3.spear.tip is a table. Ignoring non-table value (true)") assert.Contains(t, warnings, "warning: cannot overwrite table with non table for level1.level2.level3.spear.sail (map[cotton:true])") - } func TestConcatPrefix(t *testing.T) { @@ -765,3 +763,166 @@ func TestCoalesceValuesEmptyMapWithNils(t *testing.T) { is.True(ok, "Expected data.baz key to be present but it was removed") is.Nil(data["baz"], "Expected data.baz key to be nil but it is not") } + +// TestCoalesceValuesSubchartDefaultNilsCleaned tests that nil values in subchart defaults +// are cleaned up during coalescing when the parent doesn't set those keys. +// Regression test for issue #31919. +func TestCoalesceValuesSubchartDefaultNilsCleaned(t *testing.T) { + is := assert.New(t) + + // Subchart has a default with nil values (e.g. keyMapping: {password: null}) + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // Parent user values don't mention keyMapping at all + vals := map[string]any{} + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + keyMapping, ok := childVals["keyMapping"].(map[string]any) + is.True(ok, "keyMapping should be a map") + + // The nil "password" key from chart defaults should be cleaned up + _, ok = keyMapping["password"] + is.False(ok, "Expected keyMapping.password (nil from chart defaults) to be removed, but it is still present") +} + +// TestCoalesceValuesUserNullErasesSubchartDefault tests that a user-supplied null +// value erases a subchart's default value during coalescing. +// Regression test for issue #31919. +func TestCoalesceValuesUserNullErasesSubchartDefault(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "someKey": "default", + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // User explicitly nullifies the subchart key via parent values + vals := map[string]any{ + "child": map[string]any{ + "someKey": nil, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + // someKey should be erased — user null overrides subchart default + _, ok = childVals["someKey"] + is.False(ok, "Expected someKey to be removed by user null override, but it is still present") +} + +// TestCoalesceValuesSubchartNilDoesNotShadowGlobal tests that a nil value in +// subchart defaults doesn't shadow a global value accessible via pluck-like access. +// Regression test for issue #31971. +func TestCoalesceValuesSubchartNilDoesNotShadowGlobal(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "ingress": map[string]any{ + "feature": nil, // nil in subchart defaults + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // Parent sets the global value + vals := map[string]any{ + "global": map[string]any{ + "ingress": map[string]any{ + "feature": true, + }, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + ingress, ok := childVals["ingress"].(map[string]any) + is.True(ok, "ingress should be a map") + + // The nil "feature" from subchart defaults should be cleaned up, + // so that pluck can fall through to the global value + _, ok = ingress["feature"] + is.False(ok, "Expected ingress.feature (nil from chart defaults) to be removed so global can be used via pluck, but it is still present") +} + +// TestCoalesceValuesSubchartNilCleanedWhenUserPartiallyOverrides tests that nil +// values in subchart defaults are cleaned even when the user partially overrides +// the same map. Regression test for the coalesceTablesFullKey merge path. +func TestCoalesceValuesSubchartNilCleanedWhenUserPartiallyOverrides(t *testing.T) { + is := assert.New(t) + + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, + "format": "bcrypt", + }, + }, + } + + parent := withDeps(&chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + }, subchart) + + // User overrides format but doesn't mention password + vals := map[string]any{ + "child": map[string]any{ + "keyMapping": map[string]any{ + "format": "sha256", + }, + }, + } + + v, err := CoalesceValues(parent, vals) + is.NoError(err) + + childVals, ok := v["child"].(map[string]any) + is.True(ok, "child values should be a map") + + keyMapping, ok := childVals["keyMapping"].(map[string]any) + is.True(ok, "keyMapping should be a map") + + is.Equal("sha256", keyMapping["format"], "User override should be preserved") + + _, ok = keyMapping["password"] + is.False(ok, "Expected keyMapping.password (nil from chart defaults) to be removed even when user partially overrides the map") +} diff --git a/pkg/chart/common/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go index 873c08fdd..624143993 100644 --- a/pkg/chart/common/util/jsonschema.go +++ b/pkg/chart/common/util/jsonschema.go @@ -18,6 +18,7 @@ package util import ( "bytes" + "context" "crypto/tls" "errors" "fmt" @@ -41,7 +42,7 @@ type HTTPURLLoader http.Client func (l *HTTPURLLoader) Load(urlStr string) (any, error) { client := (*http.Client)(l) - req, err := http.NewRequest(http.MethodGet, urlStr, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, urlStr, http.NoBody) if err != nil { return nil, fmt.Errorf("failed to create HTTP request for %s: %w", urlStr, err) } @@ -73,7 +74,7 @@ func newHTTPURLLoader() *HTTPURLLoader { } // ValidateAgainstSchema checks that values does not violate the structure laid out in schema -func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) error { +func ValidateAgainstSchema(ch chart.Charter, values map[string]any) error { chrt, err := chart.NewAccessor(ch) if err != nil { return err diff --git a/pkg/chart/common/util/jsonschema_test.go b/pkg/chart/common/util/jsonschema_test.go index 24073175c..838d152a1 100644 --- a/pkg/chart/common/util/jsonschema_test.go +++ b/pkg/chart/common/util/jsonschema_test.go @@ -54,7 +54,7 @@ func TestValidateAgainstInvalidSingleSchema(t *testing.T) { var errString string if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -78,7 +78,7 @@ func TestValidateAgainstSingleSchemaNegative(t *testing.T) { var errString string if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -172,7 +172,7 @@ func TestValidateAgainstSchemaNegative(t *testing.T) { var errString string if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -236,7 +236,7 @@ func TestValidateAgainstSchema2020Negative(t *testing.T) { var errString string if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") + t.Fatal("Expected an error, but got nil") } else { errString = err.Error() } @@ -386,6 +386,6 @@ func TestValidateAgainstSchema_InvalidSubchartValuesType_NoPanic(t *testing.T) { // We expect a non-nil error (invalid type), but crucially no panic. if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("expected an error when subchart values have invalid type, got nil") + t.Fatal("expected an error when subchart values have invalid type, got nil") } } diff --git a/pkg/chart/common/util/values_test.go b/pkg/chart/common/util/values_test.go index 1dea04132..1882fd0c4 100644 --- a/pkg/chart/common/util/values_test.go +++ b/pkg/chart/common/util/values_test.go @@ -25,7 +25,6 @@ import ( ) func TestToRenderValues(t *testing.T) { - chartValues := map[string]any{ "name": "al Rashid", "where": map[string]any{ @@ -85,7 +84,7 @@ func TestToRenderValues(t *testing.T) { t.Error("Expected upgrade to be false.") } if !relmap["IsInstall"].(bool) { - t.Errorf("Expected install to be true.") + t.Error("Expected install to be true.") } if !res["Capabilities"].(*common.Capabilities).APIVersions.Has("v1") { t.Error("Expected Capabilities to have v1 as an API") diff --git a/pkg/chart/common/values.go b/pkg/chart/common/values.go index 94958a779..17a067790 100644 --- a/pkg/chart/common/values.go +++ b/pkg/chart/common/values.go @@ -29,7 +29,7 @@ import ( const GlobalKey = "global" // Values represents a collection of chart values. -type Values map[string]interface{} +type Values map[string]any // YAML encodes the Values into a YAML string. func (v Values) YAML() (string, error) { @@ -64,9 +64,9 @@ func (v Values) Table(name string) (Values, error) { // AsMap is a utility function for converting Values to a map[string]interface{}. // // It protects against nil map panics. -func (v Values) AsMap() map[string]interface{} { +func (v Values) AsMap() map[string]any { if len(v) == 0 { - return map[string]interface{}{} + return map[string]any{} } return v } @@ -86,7 +86,7 @@ func tableLookup(v Values, simple string) (Values, error) { if !ok { return v, ErrNoTable{simple} } - if vv, ok := v2.(map[string]interface{}); ok { + if vv, ok := v2.(map[string]any); ok { return vv, nil } @@ -113,7 +113,7 @@ func ReadValues(data []byte) (vals Values, err error) { func ReadValuesFile(filename string) (Values, error) { data, err := os.ReadFile(filename) if err != nil { - return map[string]interface{}{}, err + return map[string]any{}, err } return ReadValues(data) } @@ -129,8 +129,8 @@ type ReleaseOptions struct { } // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } @@ -141,14 +141,14 @@ func istable(v interface{}) bool { // chapter: // one: // title: "Loomings" -func (v Values) PathValue(path string) (interface{}, error) { +func (v Values) PathValue(path string) (any, error) { if path == "" { return nil, errors.New("YAML path cannot be empty") } return v.pathValue(parsePath(path)) } -func (v Values) pathValue(path []string) (interface{}, error) { +func (v Values) pathValue(path []string) (any, error) { if len(path) == 1 { // if exists must be root key not table if _, ok := v[path[0]]; ok && !istable(v[path[0]]) { diff --git a/pkg/chart/common/values_test.go b/pkg/chart/common/values_test.go index 3cceeb2b5..9743869ec 100644 --- a/pkg/chart/common/values_test.go +++ b/pkg/chart/common/values_test.go @@ -113,7 +113,7 @@ chapter: } if _, err := d.Table("title"); err == nil { - t.Fatalf("Title is not a table.") + t.Fatal("Title is not a table.") } if _, err := d.Table("chapter"); err != nil { @@ -131,11 +131,11 @@ chapter: } if _, err := d.Table("chapter.OneHundredThirtySix"); err == nil { - t.Errorf("I think you mean 'Epilogue'") + t.Error("I think you mean 'Epilogue'") } } -func matchValues(t *testing.T, data map[string]interface{}) { +func matchValues(t *testing.T, data map[string]any) { t.Helper() if data["poet"] != "Coleridge" { t.Errorf("Unexpected poet: %s", data["poet"]) @@ -150,17 +150,17 @@ func matchValues(t *testing.T, data map[string]interface{}) { if o, err := ttpl("{{.mariner.shot}}", data); err != nil { t.Errorf(".mariner.shot: %s", err) } else if o != "ALBATROSS" { - t.Errorf("Expected that mariner shot ALBATROSS") + t.Error("Expected that mariner shot ALBATROSS") } if o, err := ttpl("{{.water.water.where}}", data); err != nil { t.Errorf(".water.water.where: %s", err) } else if o != "everywhere" { - t.Errorf("Expected water water everywhere") + t.Error("Expected water water everywhere") } } -func ttpl(tpl string, v map[string]interface{}) (string, error) { +func ttpl(tpl string, v map[string]any) (string, error) { var b bytes.Buffer tt := template.Must(template.New("t").Parse(tpl)) err := tt.Execute(&b, v) @@ -199,7 +199,7 @@ chapter: } if v, err := d.PathValue("title"); err == nil { if v != "Moby Dick" { - t.Errorf("Failed to return values for root key title") + t.Error("Failed to return values for root key title") } } } diff --git a/pkg/chart/loader/load.go b/pkg/chart/loader/load.go index 3fd381825..6fe246474 100644 --- a/pkg/chart/loader/load.go +++ b/pkg/chart/loader/load.go @@ -100,7 +100,6 @@ func LoadDir(dir string) (chart.Charter, error) { default: return nil, errors.New("unsupported chart version") } - } // FileLoader loads a chart from a file diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index d77a53ddc..9d798b4de 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -48,11 +48,11 @@ type Chart struct { // Templates for this chart. Templates []*common.File `json:"templates"` // Values are default config for this chart. - Values map[string]interface{} `json:"values"` + Values map[string]any `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // SchemaModTime the schema was last modified - SchemaModTime time.Time `json:"schemamodtime,omitempty"` + SchemaModTime time.Time `json:"schemamodtime"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. Files []*common.File `json:"files"` @@ -143,6 +143,7 @@ func (ch *Chart) AppVersion() string { } // CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. +// // Deprecated: use CRDObjects() func (ch *Chart) CRDs() []*common.File { files := []*common.File{} diff --git a/pkg/chart/v2/chart_test.go b/pkg/chart/v2/chart_test.go index d0837eb16..813669366 100644 --- a/pkg/chart/v2/chart_test.go +++ b/pkg/chart/v2/chart_test.go @@ -59,7 +59,7 @@ func TestCRDs(t *testing.T) { is := assert.New(t) crds := chrt.CRDs() - is.Equal(2, len(crds)) + is.Len(crds, 2) is.Equal("crds/foo.yaml", crds[0].Name) is.Equal("crds/foo/bar/baz.yaml", crds[1].Name) } @@ -104,7 +104,7 @@ func TestMetadata(t *testing.T) { is.Equal("foo.yaml", chrt.Name()) is.Equal("1.0.0", chrt.AppVersion()) - is.Equal(nil, chrt.Validate()) + is.NoError(chrt.Validate()) } func TestIsRoot(t *testing.T) { @@ -124,8 +124,8 @@ func TestIsRoot(t *testing.T) { is := assert.New(t) - is.Equal(false, chrt1.IsRoot()) - is.Equal(true, chrt2.IsRoot()) + is.False(chrt1.IsRoot()) + is.True(chrt2.IsRoot()) } func TestChartPath(t *testing.T) { diff --git a/pkg/chart/v2/dependency.go b/pkg/chart/v2/dependency.go index 8a590a036..5a92ef305 100644 --- a/pkg/chart/v2/dependency.go +++ b/pkg/chart/v2/dependency.go @@ -44,7 +44,7 @@ type Dependency struct { Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` // ImportValues holds the mapping of source values to parent key to be imported. Each item can be a // string or pair of child/parent sublist items. - ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"` + ImportValues []any `json:"import-values,omitempty" yaml:"import-values,omitempty"` // Alias usable alias to be used for the chart Alias string `json:"alias,omitempty" yaml:"alias,omitempty"` } diff --git a/pkg/chart/v2/errors.go b/pkg/chart/v2/errors.go index eeef75315..5f7f5e738 100644 --- a/pkg/chart/v2/errors.go +++ b/pkg/chart/v2/errors.go @@ -25,6 +25,6 @@ func (v ValidationError) Error() string { } // ValidationErrorf takes a message and formatting options and creates a ValidationError -func ValidationErrorf(msg string, args ...interface{}) ValidationError { +func ValidationErrorf(msg string, args ...any) ValidationError { return ValidationError(fmt.Sprintf(msg, args...)) } diff --git a/pkg/chart/v2/lint/lint.go b/pkg/chart/v2/lint/lint.go index 1c871d936..23a93c551 100644 --- a/pkg/chart/v2/lint/lint.go +++ b/pkg/chart/v2/lint/lint.go @@ -43,8 +43,7 @@ func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { } } -func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { - +func RunAll(baseDir string, values map[string]any, namespace string, options ...LinterOption) support.Linter { chartDir, _ := filepath.Abs(baseDir) lo := linterOptions{} diff --git a/pkg/chart/v2/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go index 80dcef932..4256281e0 100644 --- a/pkg/chart/v2/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -100,7 +100,7 @@ func TestInvalidYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { - t.Errorf("All didn't have the error for deliberateSyntaxError") + t.Error("All didn't have the error for deliberateSyntaxError") } } @@ -111,7 +111,7 @@ func TestInvalidChartYaml(t *testing.T) { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { - t.Errorf("All didn't have the error for duplicate YAML keys") + t.Error("All didn't have the error for duplicate YAML keys") } } @@ -235,13 +235,13 @@ func TestMalformedTemplate(t *testing.T) { }() select { case <-c: - t.Fatalf("lint malformed template timeout") + t.Fatal("lint malformed template timeout") case <-ch: if len(m) != 1 { t.Fatalf("All didn't fail with expected errors, got %#v", m) } if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { - t.Errorf("All didn't have the error for invalid character '{'") + t.Error("All didn't have the error for invalid character '{'") } } } diff --git a/pkg/chart/v2/lint/rules/chartfile_test.go b/pkg/chart/v2/lint/rules/chartfile_test.go index 692358426..c9e202770 100644 --- a/pkg/chart/v2/lint/rules/chartfile_test.go +++ b/pkg/chart/v2/lint/rules/chartfile_test.go @@ -50,26 +50,26 @@ func TestValidateChartYamlNotDirectory(t *testing.T) { err := validateChartYamlNotDirectory(nonExistingChartFilePath) if err == nil { - t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + t.Error("validateChartYamlNotDirectory to return a linter error, got no error") } } func TestValidateChartYamlFormat(t *testing.T) { err := validateChartYamlFormat(errors.New("Read error")) if err == nil { - t.Errorf("validateChartYamlFormat to return a linter error, got no error") + t.Error("validateChartYamlFormat to return a linter error, got no error") } err = validateChartYamlFormat(nil) if err != nil { - t.Errorf("validateChartYamlFormat to return no error, got a linter error") + t.Error("validateChartYamlFormat to return no error, got a linter error") } } func TestValidateChartName(t *testing.T) { err := validateChartName(badChart) if err == nil { - t.Errorf("validateChartName to return a linter error, got no error") + t.Error("validateChartName to return a linter error, got no error") } err = validateChartName(badChartName) @@ -176,7 +176,7 @@ func TestValidateChartMaintainer(t *testing.T) { badChart.Maintainers = []*chart.Maintainer{nil} err := validateChartMaintainer(badChart) if err == nil { - t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + t.Error("validateChartMaintainer did not return error for nil maintainer as expected") } if err.Error() != "a maintainer entry is empty" { t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) @@ -212,7 +212,7 @@ func TestValidateChartIconPresence(t *testing.T) { err := validateChartIconPresence(testChart) if err == nil { - t.Errorf("validateChartIconPresence to return a linter error, got no error") + t.Error("validateChartIconPresence to return a linter error, got no error") } else if !strings.Contains(err.Error(), "icon is recommended") { t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) } diff --git a/pkg/chart/v2/lint/rules/crds.go b/pkg/chart/v2/lint/rules/crds.go index 4bb4d370b..1cf16b42d 100644 --- a/pkg/chart/v2/lint/rules/crds.go +++ b/pkg/chart/v2/lint/rules/crds.go @@ -19,7 +19,6 @@ package rules import ( "bytes" "errors" - "fmt" "io" "io/fs" "os" @@ -102,14 +101,14 @@ func validateCrdsDir(crdsPath string) error { func validateCrdAPIVersion(obj *k8sYamlStruct) error { if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { - return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + return errors.New("apiVersion is not in 'apiextensions.k8s.io'") } return nil } func validateCrdKind(obj *k8sYamlStruct) error { if obj.Kind != "CustomResourceDefinition" { - return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + return errors.New("object kind is not 'CustomResourceDefinition'") } return nil } diff --git a/pkg/chart/v2/lint/rules/crds_test.go b/pkg/chart/v2/lint/rules/crds_test.go index 228f40a66..759bb54fa 100644 --- a/pkg/chart/v2/lint/rules/crds_test.go +++ b/pkg/chart/v2/lint/rules/crds_test.go @@ -62,5 +62,5 @@ metadata: linter := support.Linter{ChartDir: chartDir} Crds(&linter) - assert.Len(t, linter.Messages, 0) + assert.Empty(t, linter.Messages) } diff --git a/pkg/chart/v2/lint/rules/dependencies_test.go b/pkg/chart/v2/lint/rules/dependencies_test.go index 08a6646cd..c50fc4b2d 100644 --- a/pkg/chart/v2/lint/rules/dependencies_test.go +++ b/pkg/chart/v2/lint/rules/dependencies_test.go @@ -72,7 +72,7 @@ func TestValidateDependencyInMetadata(t *testing.T) { c := chartWithBadDependencies() if err := validateDependencyInMetadata(&c); err == nil { - t.Errorf("chart should have been flagged for missing deps in chart metadata") + t.Error("chart should have been flagged for missing deps in chart metadata") } } @@ -132,7 +132,7 @@ func TestValidateDependenciesUnique(t *testing.T) { for _, tt := range tests { if err := validateDependenciesUnique(&tt.chart); err == nil { - t.Errorf("chart should have been flagged for dependency shadowing") + t.Error("chart should have been flagged for dependency shadowing") } } } diff --git a/pkg/chart/v2/lint/rules/deprecations.go b/pkg/chart/v2/lint/rules/deprecations.go index 7d5245869..76626659b 100644 --- a/pkg/chart/v2/lint/rules/deprecations.go +++ b/pkg/chart/v2/lint/rules/deprecations.go @@ -28,7 +28,7 @@ import ( kscheme "k8s.io/client-go/kubernetes/scheme" ) -// deprecatedAPIError indicates than an API is deprecated in Kubernetes +// deprecatedAPIError indicates that an API is deprecated in Kubernetes type deprecatedAPIError struct { Deprecated string Message string diff --git a/pkg/chart/v2/lint/rules/deprecations_test.go b/pkg/chart/v2/lint/rules/deprecations_test.go index e153f67e6..86643f1c9 100644 --- a/pkg/chart/v2/lint/rules/deprecations_test.go +++ b/pkg/chart/v2/lint/rules/deprecations_test.go @@ -36,6 +36,6 @@ func TestValidateNoDeprecations(t *testing.T) { APIVersion: "v1", Kind: "Pod", }, nil); err != nil { - t.Errorf("Expected a v1 Pod to not be deprecated") + t.Error("Expected a v1 Pod to not be deprecated") } } diff --git a/pkg/chart/v2/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go index 43665aa3a..47209112e 100644 --- a/pkg/chart/v2/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -19,6 +19,7 @@ package rules import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -28,8 +29,8 @@ import ( "slices" "strings" + "k8s.io/apimachinery/pkg/api/validate/content" "k8s.io/apimachinery/pkg/api/validation" - apipath "k8s.io/apimachinery/pkg/api/validation/path" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/yaml" @@ -62,7 +63,6 @@ func TemplateLinterSkipSchemaValidation(skipSchemaValidation bool) TemplateLinte } func newTemplateLinter(linter *support.Linter, namespace string, values map[string]any, options ...TemplateLinterOption) templateLinter { - result := templateLinter{ linter: linter, values: values, @@ -135,7 +135,7 @@ func (t *templateLinter) Lint() { } var e engine.Engine e.LintMode = true - renderedContentMap, err := e.Render(chart, valuesToRender) + renderedContentMap, err := e.RenderWithContext(context.Background(), chart, valuesToRender) renderOk := t.linter.RunLinterRule(support.ErrorSev, templatesDir, err) @@ -323,7 +323,7 @@ func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc { case "role", "clusterrole", "rolebinding", "clusterrolebinding": // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 return func(name string, _ bool) []string { - return apipath.IsValidPathSegmentName(name) + return content.IsPathSegmentName(name) } default: return validation.NameIsDNSSubdomain diff --git a/pkg/chart/v2/lint/rules/template_test.go b/pkg/chart/v2/lint/rules/template_test.go index c08ba6cc3..f0eb008b3 100644 --- a/pkg/chart/v2/lint/rules/template_test.go +++ b/pkg/chart/v2/lint/rules/template_test.go @@ -49,7 +49,7 @@ func TestValidateAllowedExtension(t *testing.T) { } } -var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} +var values = map[string]any{"nameOverride": "", "httpPort": 80} const namespace = "testNamespace" @@ -257,14 +257,13 @@ data: // // See https://github.com/helm/helm/issues/7483 func TestStrictTemplateParsingMapError(t *testing.T) { - ch := chart.Chart{ Metadata: &chart.Metadata{ Name: "regression7483", APIVersion: "v2", Version: "0.1.0", }, - Values: map[string]interface{}{ + Values: map[string]any{ "mymap": map[string]string{ "key1": "val1", }, @@ -390,7 +389,6 @@ func TestValidateTopIndentLevel(t *testing.T) { t.Errorf("Expected %t for %q", shouldFail, doc) } } - } // TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments @@ -486,5 +484,4 @@ func TestIsYamlFileExtension(t *testing.T) { t.Errorf("isYamlFileExtension(%s) = %v; want %v", test.filename, result, test.expected) } } - } diff --git a/pkg/chart/v2/lint/rules/values.go b/pkg/chart/v2/lint/rules/values.go index 8fe849c7a..2c766068c 100644 --- a/pkg/chart/v2/lint/rules/values.go +++ b/pkg/chart/v2/lint/rules/values.go @@ -17,6 +17,7 @@ limitations under the License. package rules import ( + "errors" "fmt" "os" "path/filepath" @@ -47,7 +48,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]any, func validateValuesFileExistence(valuesPath string) error { _, err := os.Stat(valuesPath) if err != nil { - return fmt.Errorf("file does not exist") + return errors.New("file does not exist") } return nil } diff --git a/pkg/chart/v2/lint/rules/values_test.go b/pkg/chart/v2/lint/rules/values_test.go index afc544ebd..54c7e6457 100644 --- a/pkg/chart/v2/lint/rules/values_test.go +++ b/pkg/chart/v2/lint/rules/values_test.go @@ -57,7 +57,7 @@ func TestValidateValuesYamlNotDirectory(t *testing.T) { err := validateValuesFileExistence(nonExistingValuesFilePath) if err == nil { - t.Errorf("validateValuesFileExistence to return a linter error, got no error") + t.Error("validateValuesFileExistence to return a linter error, got no error") } } diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index e3c5ce325..28115d062 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -120,7 +120,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { c.Metadata = new(chart.Metadata) } if c.Metadata.APIVersion != chart.APIVersionV1 { - log.Printf("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") + log.Print("Warning: Dependencies are handled in Chart.yaml since apiVersion \"v2\". We recommend migrating dependencies to Chart.yaml.") } if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil { return c, fmt.Errorf("cannot load requirements.yaml: %w", err) @@ -138,7 +138,7 @@ func LoadFiles(files []*archive.BufferedFile) (*chart.Chart, error) { c.Metadata = new(chart.Metadata) } if c.Metadata.APIVersion != chart.APIVersionV1 { - log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") + log.Print("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") } if c.Metadata.APIVersion == chart.APIVersionV1 { c.Files = append(c.Files, &common.File{Name: f.Name, ModTime: f.ModTime, Data: f.Data}) diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index 397745dd6..be7041386 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -61,7 +61,7 @@ func TestLoadDirWithDevNull(t *testing.T) { t.Fatalf("Failed to load testdata: %s", err) } if _, err := l.Load(); err == nil { - t.Errorf("packages with an irregular file (/dev/null) should not load") + t.Error("packages with an irregular file (/dev/null) should not load") } } @@ -390,7 +390,6 @@ icon: https://example.com/64x64.png if text.String() != "" { t.Errorf("Expected no message to Stderr, got %s", text.String()) } - } // Packaging the chart on a Windows machine will produce an @@ -508,7 +507,7 @@ func TestLoadInvalidArchive(t *testing.T) { func TestLoadValues(t *testing.T) { testCases := map[string]struct { data []byte - expctedValues map[string]interface{} + expctedValues map[string]any }{ "It should load values correctly": { data: []byte(` @@ -517,11 +516,11 @@ foo: bar: version: v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v1", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -536,11 +535,11 @@ bar: foo: image: foo:v2 `), - expctedValues: map[string]interface{}{ - "foo": map[string]interface{}{ + expctedValues: map[string]any{ + "foo": map[string]any{ "image": "foo:v2", }, - "bar": map[string]interface{}{ + "bar": map[string]any{ "version": "v2", }, }, @@ -560,24 +559,24 @@ foo: } func TestMergeValuesV2(t *testing.T) { - nestedMap := map[string]interface{}{ + nestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "stuff", }, } - anotherNestedMap := map[string]interface{}{ + anotherNestedMap := map[string]any{ "foo": "bar", "baz": map[string]string{ "cool": "things", "awesome": "stuff", }, } - flatMap := map[string]interface{}{ + flatMap := map[string]any{ "foo": "bar", "baz": "stuff", } - anotherFlatMap := map[string]interface{}{ + anotherFlatMap := map[string]any{ "testing": "fun", } @@ -600,7 +599,7 @@ func TestMergeValuesV2(t *testing.T) { } testMap = MergeMaps(anotherFlatMap, anotherNestedMap) - expectedMap := map[string]interface{}{ + expectedMap := map[string]any{ "testing": "fun", "foo": "bar", "baz": map[string]string{ @@ -660,7 +659,6 @@ func verifyChart(t *testing.T, c *chart.Chart) { t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version) } } - } func verifyDependencies(t *testing.T, c *chart.Chart) { diff --git a/pkg/chart/v2/metadata.go b/pkg/chart/v2/metadata.go index c46007863..5df307321 100644 --- a/pkg/chart/v2/metadata.go +++ b/pkg/chart/v2/metadata.go @@ -112,6 +112,9 @@ func (md *Metadata) Validate() error { return ValidationError("chart.metadata.name is required") } + if md.Name == "." || md.Name == ".." { + return ValidationErrorf("chart.metadata.name %q is not allowed", md.Name) + } if md.Name != filepath.Base(md.Name) { return ValidationErrorf("chart.metadata.name %q is invalid", md.Name) } diff --git a/pkg/chart/v2/metadata_test.go b/pkg/chart/v2/metadata_test.go index 7892f0209..63dd99e52 100644 --- a/pkg/chart/v2/metadata_test.go +++ b/pkg/chart/v2/metadata_test.go @@ -16,6 +16,7 @@ limitations under the License. package v2 import ( + "errors" "testing" ) @@ -40,6 +41,16 @@ func TestValidate(t *testing.T) { &Metadata{APIVersion: "v2", Version: "1.0"}, ValidationError("chart.metadata.name is required"), }, + { + "chart with dot name", + &Metadata{Name: ".", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \".\" is not allowed"), + }, + { + "chart with dotdot name", + &Metadata{Name: "..", APIVersion: "v2", Version: "1.0"}, + ValidationError("chart.metadata.name \"..\" is not allowed"), + }, { "chart without name", &Metadata{Name: "../../test", APIVersion: "v2", Version: "1.0"}, @@ -181,7 +192,7 @@ func TestValidate(t *testing.T) { for _, tt := range tests { result := tt.md.Validate() - if result != tt.err { + if !errors.Is(result, tt.err) { t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name) } } diff --git a/pkg/chart/v2/util/chartfile_test.go b/pkg/chart/v2/util/chartfile_test.go index 00c530b8a..068977eef 100644 --- a/pkg/chart/v2/util/chartfile_test.go +++ b/pkg/chart/v2/util/chartfile_test.go @@ -35,11 +35,11 @@ func TestLoadChartfile(t *testing.T) { func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { t.Helper() - if f == nil { //nolint:staticcheck + if f == nil { t.Fatal("Failed verifyChartfile because f is nil") } - if f.APIVersion != chart.APIVersionV1 { //nolint:staticcheck + if f.APIVersion != chart.APIVersionV1 { t.Errorf("Expected API Version %q, got %q", chart.APIVersionV1, f.APIVersion) } @@ -60,15 +60,15 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if f.Maintainers[0].Name != "The Helm Team" { - t.Errorf("Unexpected maintainer name.") + t.Error("Unexpected maintainer name.") } if f.Maintainers[1].Email != "nobody@example.com" { - t.Errorf("Unexpected maintainer email.") + t.Error("Unexpected maintainer email.") } if len(f.Sources) != 1 { - t.Fatalf("Unexpected number of sources") + t.Fatal("Unexpected number of sources") } if f.Sources[0] != "https://example.com/foo/bar" { @@ -88,7 +88,7 @@ func verifyChartfile(t *testing.T, f *chart.Metadata, name string) { } if len(f.Annotations) != 2 { - t.Fatalf("Unexpected annotations") + t.Fatal("Unexpected annotations") } if want, got := "extravalue", f.Annotations["extrakey"]; want != got { @@ -115,7 +115,7 @@ func TestIsChartDir(t *testing.T) { } validChartDir, err = IsChartDir("testdata") if validChartDir || err == nil { - t.Errorf("expected error but did not get any") + t.Error("expected error but did not get any") return } } diff --git a/pkg/chart/v2/util/create.go b/pkg/chart/v2/util/create.go index 0d7ae8d5c..36e3bfe72 100644 --- a/pkg/chart/v2/util/create.go +++ b/pkg/chart/v2/util/create.go @@ -701,7 +701,6 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { // error. In such a case, this will attempt to clean up by removing the // new chart directory. func Create(name, dir string) (string, error) { - // Sanity-check the name of a chart so user doesn't create one that causes problems. if err := validateChartName(name); err != nil { return "", err diff --git a/pkg/chart/v2/util/create_test.go b/pkg/chart/v2/util/create_test.go index 086c4e5c8..967972fc8 100644 --- a/pkg/chart/v2/util/create_test.go +++ b/pkg/chart/v2/util/create_test.go @@ -140,7 +140,7 @@ func TestCreate_Overwrite(t *testing.T) { } if errlog.Len() == 0 { - t.Errorf("Expected warnings about overwriting files.") + t.Error("Expected warnings about overwriting files.") } } diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index 38ca554b3..dd17a6730 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "helm.sh/helm/v4/internal/copystructure" @@ -142,7 +143,7 @@ func copyMetadata(metadata *chart.Metadata) *chart.Metadata { } // processDependencyEnabled removes disabled charts from dependencies -func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error { +func processDependencyEnabled(c *chart.Chart, v map[string]any, path string) error { if c.Metadata.Dependencies == nil { return nil } @@ -246,7 +247,7 @@ Loop: } // pathToMap creates a nested map given a YAML path in dot notation. -func pathToMap(path string, data map[string]interface{}) map[string]interface{} { +func pathToMap(path string, data map[string]any) map[string]any { if path == "." { return data } @@ -255,13 +256,13 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} func parsePath(key string) []string { return strings.Split(key, ".") } -func set(path []string, data map[string]interface{}) map[string]interface{} { +func set(path []string, data map[string]any) map[string]any { if len(path) == 0 { return nil } cur := data - for i := len(path) - 1; i >= 0; i-- { - cur = map[string]interface{}{path[i]: cur} + for _, v := range slices.Backward(path) { + cur = map[string]any{v: cur} } return cur } @@ -282,13 +283,13 @@ func processImportValues(c *chart.Chart, merge bool) error { if err != nil { return err } - b := make(map[string]interface{}) + b := make(map[string]any) // import values from each dependency if specified in import-values for _, r := range c.Metadata.Dependencies { - var outiv []interface{} + var outiv []any for _, riv := range r.ImportValues { switch iv := riv.(type) { - case map[string]interface{}: + case map[string]any: child := fmt.Sprintf("%v", iv["child"]) parent := fmt.Sprintf("%v", iv["parent"]) @@ -355,27 +356,27 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } -func deepCopyMap(vals map[string]interface{}) map[string]interface{} { +func deepCopyMap(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - return valsCopy.(map[string]interface{}) + return valsCopy.(map[string]any) } -func trimNilValues(vals map[string]interface{}) map[string]interface{} { +func trimNilValues(vals map[string]any) map[string]any { valsCopy, err := copystructure.Copy(vals) if err != nil { return vals } - valsCopyMap := valsCopy.(map[string]interface{}) + valsCopyMap := valsCopy.(map[string]any) for key, val := range valsCopyMap { if val == nil { // Iterate over the values and remove nil keys delete(valsCopyMap, key) } else if istable(val) { // Recursively call into ourselves to remove keys from inner tables - valsCopyMap[key] = trimNilValues(val.(map[string]interface{})) + valsCopyMap[key] = trimNilValues(val.(map[string]any)) } } @@ -383,8 +384,8 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { } // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) +func istable(v any) bool { + _, ok := v.(map[string]any) return ok } diff --git a/pkg/chart/v2/util/dependencies_test.go b/pkg/chart/v2/util/dependencies_test.go index 23d4b8c32..da7e05778 100644 --- a/pkg/chart/v2/util/dependencies_test.go +++ b/pkg/chart/v2/util/dependencies_test.go @@ -63,7 +63,7 @@ func TestLoadDependency(t *testing.T) { } func TestDependencyEnabled(t *testing.T) { - type M = map[string]interface{} + type M = map[string]any tests := []struct { name string v M @@ -455,7 +455,7 @@ func TestGetAliasDependency(t *testing.T) { req := c.Metadata.Dependencies if len(req) == 0 { - t.Fatalf("there are no dependencies to test") + t.Fatal("there are no dependencies to test") } // Success case @@ -473,7 +473,7 @@ func TestGetAliasDependency(t *testing.T) { if req[0].Version != "" { if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version is not in the compatible range") + t.Fatal("dependency chart version is not in the compatible range") } } @@ -485,7 +485,7 @@ func TestGetAliasDependency(t *testing.T) { req[0].Version = "something else which is not in the compatible range" if IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) { - t.Fatalf("dependency chart version which is not in the compatible range should cause a failure other than a success ") + t.Fatal("dependency chart version outside the compatible range should fail, but it succeeded") } } @@ -529,7 +529,6 @@ func TestDependentChartAliases(t *testing.T) { if aliasChart := getAliasDependency(c.Dependencies(), req[2]); aliasChart != nil { t.Fatalf("expected no chart but got %s", aliasChart.Name()) } - } func TestDependentChartWithSubChartsAbsentInDependency(t *testing.T) { diff --git a/pkg/chart/v2/util/doc.go b/pkg/chart/v2/util/doc.go index 141062074..ed741a83d 100644 --- a/pkg/chart/v2/util/doc.go +++ b/pkg/chart/v2/util/doc.go @@ -42,4 +42,4 @@ into a Chart. When creating charts in memory, use the 'helm.sh/helm/pkg/chart' package directly. */ -package util // import chartutil "helm.sh/helm/v4/pkg/chart/v2/util" +package util // import "helm.sh/helm/v4/pkg/chart/v2/util" diff --git a/pkg/chart/v2/util/expand.go b/pkg/chart/v2/util/expand.go index 077dfbf38..6022d5869 100644 --- a/pkg/chart/v2/util/expand.go +++ b/pkg/chart/v2/util/expand.go @@ -52,6 +52,17 @@ func Expand(dir string, r io.Reader) error { return errors.New("chart name not specified") } + // Reject chart names that are POSIX path dot-segments or dot-dot segments or contain path separators. + // A dot-segment name (e.g. ".") causes SecureJoin to resolve to the root + // directory and extraction then to write files directly into that extraction root + // instead of a per-chart subdirectory. + if chartName == "." || chartName == ".." { + return fmt.Errorf("chart name %q is not allowed", chartName) + } + if chartName != filepath.Base(chartName) { + return fmt.Errorf("chart name %q must not contain path separators", chartName) + } + // Find the base directory // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up // being wrong or returning an error. This was introduced in v0.4.0. @@ -61,6 +72,12 @@ func Expand(dir string, r io.Reader) error { return err } + // Defense-in-depth: the chart directory must be a subdirectory of dir, + // never dir itself. + if chartdir == dir { + return fmt.Errorf("chart name %q resolves to the extraction root", chartName) + } + // Copy all files verbatim. We don't parse these files because parsing can remove // comments. for _, file := range files { diff --git a/pkg/chart/v2/util/expand_test.go b/pkg/chart/v2/util/expand_test.go index 280995f7e..e9e298b81 100644 --- a/pkg/chart/v2/util/expand_test.go +++ b/pkg/chart/v2/util/expand_test.go @@ -17,11 +17,73 @@ limitations under the License. package util import ( + "archive/tar" + "bytes" + "compress/gzip" + "io/fs" "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// makeTestChartArchive builds a gzipped tar archive from the given sourceDir directory, file entries are prefixed with the given chartName +func makeTestChartArchive(t *testing.T, chartName, sourceDir string) *bytes.Buffer { + t.Helper() + + var result bytes.Buffer + gw := gzip.NewWriter(&result) + tw := tar.NewWriter(gw) + + dir := os.DirFS(sourceDir) + + writeFile := func(relPath string) { + t.Helper() + f, err := dir.Open(relPath) + require.NoError(t, err) + + fStat, err := f.Stat() + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: filepath.Join(chartName, relPath), + Mode: int64(fStat.Mode()), + Size: fStat.Size(), + }) + require.NoError(t, err) + + data, err := fs.ReadFile(dir, relPath) + require.NoError(t, err) + tw.Write(data) + } + + err := fs.WalkDir(dir, ".", func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if d.IsDir() { + return nil + } + + writeFile(path) + + return nil + }) + if err != nil { + t.Fatal(err) + } + + err = tw.Close() + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + return &result +} + func TestExpand(t *testing.T) { dest := t.TempDir() @@ -75,6 +137,28 @@ func TestExpand(t *testing.T) { } } +func TestExpandError(t *testing.T) { + tests := map[string]struct { + chartName string + chartDir string + wantErr string + }{ + "dot name": {"dotname", "testdata/dotname", "not allowed"}, + "dotdot name": {"dotdotname", "testdata/dotdotname", "not allowed"}, + "slash in name": {"slashinname", "testdata/slashinname", "must not contain path separators"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + archive := makeTestChartArchive(t, tt.chartName, tt.chartDir) + + dest := t.TempDir() + err := Expand(dest, archive) + assert.ErrorContains(t, err, tt.wantErr) + }) + } +} + func TestExpandFile(t *testing.T) { dest := t.TempDir() diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index 6d4e2c8cd..2f2b73efd 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -21,8 +21,8 @@ import ( "bytes" "compress/gzip" "crypto/sha256" + "encoding/hex" "errors" - "fmt" "io" "os" "path" @@ -90,7 +90,7 @@ func TestSave(t *testing.T) { t.Fatalf("Schema data did not match.\nExpected:\n%s\nActual:\n%s", formattedExpected, formattedActual) } if _, err := Save(&chartWithInvalidJSON, dest); err == nil { - t.Fatalf("Invalid JSON was not caught while saving chart") + t.Fatal("Invalid JSON was not caught while saving chart") } c.Metadata.APIVersion = chart.APIVersionV2 @@ -157,7 +157,7 @@ func TestSavePreservesTimestamps(t *testing.T) { Version: "1.2.3", }, ModTime: initialCreateTime, - Values: map[string]interface{}{ + Values: map[string]any{ "imageName": "testimage", "imageId": 42, }, @@ -357,5 +357,5 @@ func sha256Sum(filePath string) (string, error) { return "", err } - return fmt.Sprintf("%x", h.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml b/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml new file mode 100644 index 000000000..9b081f27b --- /dev/null +++ b/pkg/chart/v2/util/testdata/dotdotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: .. +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/chart/v2/util/testdata/dotname/Chart.yaml b/pkg/chart/v2/util/testdata/dotname/Chart.yaml new file mode 100644 index 000000000..597c16290 --- /dev/null +++ b/pkg/chart/v2/util/testdata/dotname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: . +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/chart/v2/util/testdata/slashinname/Chart.yaml b/pkg/chart/v2/util/testdata/slashinname/Chart.yaml new file mode 100644 index 000000000..0c522a4b6 --- /dev/null +++ b/pkg/chart/v2/util/testdata/slashinname/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v3 +name: a/../b +description: A Helm chart for Kubernetes +version: 0.1.0 \ No newline at end of file diff --git a/pkg/chart/v2/util/validate_name.go b/pkg/chart/v2/util/validate_name.go index 6595e085d..7d85a5b88 100644 --- a/pkg/chart/v2/util/validate_name.go +++ b/pkg/chart/v2/util/validate_name.go @@ -79,7 +79,6 @@ func ValidateReleaseName(name string) error { // This case is preserved for backwards compatibility if name == "" { return errMissingName - } if len(name) > maxReleaseNameLen || !validName.MatchString(name) { return errInvalidName diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 5c19734aa..45d773eb4 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -24,7 +24,6 @@ These dependencies are expressed as interfaces so that alternate implementations package cli import ( - "fmt" "net/http" "os" "strconv" @@ -36,7 +35,7 @@ import ( "helm.sh/helm/v4/internal/version" "helm.sh/helm/v4/pkg/helmpath" - "helm.sh/helm/v4/pkg/kube" + "helm.sh/helm/v4/pkg/kubeenv" ) // defaultMaxHistory sets the maximum number of releases to 0: unlimited @@ -135,7 +134,7 @@ func New() *EnvSettings { config.Burst = env.BurstLimit config.QPS = env.QPS config.Wrap(func(rt http.RoundTripper) http.RoundTripper { - return &kube.RetryingRoundTripper{Wrapped: rt} + return &kubeenv.RetryingRoundTripper{Wrapped: rt} }) config.UserAgent = version.GetUserAgent() return config @@ -246,7 +245,7 @@ func (s *EnvSettings) EnvVars() map[string]string { "HELM_CACHE_HOME": helmpath.CachePath(""), "HELM_CONFIG_HOME": helmpath.ConfigPath(""), "HELM_DATA_HOME": helmpath.DataPath(""), - "HELM_DEBUG": fmt.Sprint(s.Debug), + "HELM_DEBUG": strconv.FormatBool(s.Debug), "HELM_PLUGINS": s.PluginsDirectory, "HELM_REGISTRY_CONFIG": s.RegistryConfig, "HELM_REPOSITORY_CACHE": s.RepositoryCache, diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index 52326eeff..9303389a3 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -18,11 +18,12 @@ package cli import ( "os" - "reflect" "strings" "testing" "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "helm.sh/helm/v4/internal/version" ) @@ -30,14 +31,10 @@ import ( func TestSetNamespace(t *testing.T) { settings := New() - if settings.namespace != "" { - t.Errorf("Expected empty namespace, got %s", settings.namespace) - } + assert.Empty(t, settings.namespace) settings.SetNamespace("testns") - if settings.namespace != "testns" { - t.Errorf("Expected namespace testns, got %s", settings.namespace) - } + assert.Equal(t, "testns", settings.namespace) } func TestEnvSettings(t *testing.T) { @@ -134,36 +131,16 @@ func TestEnvSettings(t *testing.T) { settings.AddFlags(flags) flags.Parse(strings.Split(tt.args, " ")) - if settings.Debug != tt.debug { - t.Errorf("expected debug %t, got %t", tt.debug, settings.Debug) - } - if settings.Namespace() != tt.ns { - t.Errorf("expected namespace %q, got %q", tt.ns, settings.Namespace()) - } - if settings.KubeContext != tt.kcontext { - t.Errorf("expected kube-context %q, got %q", tt.kcontext, settings.KubeContext) - } - if settings.MaxHistory != tt.maxhistory { - t.Errorf("expected maxHistory %d, got %d", tt.maxhistory, settings.MaxHistory) - } - if tt.kubeAsUser != settings.KubeAsUser { - t.Errorf("expected kAsUser %q, got %q", tt.kubeAsUser, settings.KubeAsUser) - } - if !reflect.DeepEqual(tt.kubeAsGroups, settings.KubeAsGroups) { - t.Errorf("expected kAsGroups %+v, got %+v", len(tt.kubeAsGroups), len(settings.KubeAsGroups)) - } - if tt.kubeCaFile != settings.KubeCaFile { - t.Errorf("expected kCaFile %q, got %q", tt.kubeCaFile, settings.KubeCaFile) - } - if tt.burstLimit != settings.BurstLimit { - t.Errorf("expected BurstLimit %d, got %d", tt.burstLimit, settings.BurstLimit) - } - if tt.kubeInsecure != settings.KubeInsecureSkipTLSVerify { - t.Errorf("expected kubeInsecure %t, got %t", tt.kubeInsecure, settings.KubeInsecureSkipTLSVerify) - } - if tt.kubeTLSServer != settings.KubeTLSServerName { - t.Errorf("expected kubeTLSServer %q, got %q", tt.kubeTLSServer, settings.KubeTLSServerName) - } + assert.Equal(t, tt.debug, settings.Debug, "debug") + assert.Equal(t, tt.ns, settings.Namespace(), "namespace") + assert.Equal(t, tt.kcontext, settings.KubeContext, "kube-context") + assert.Equal(t, tt.maxhistory, settings.MaxHistory, "maxHistory") + assert.Equal(t, tt.kubeAsUser, settings.KubeAsUser, "kubeAsUser") + assert.Equal(t, tt.kubeAsGroups, settings.KubeAsGroups, "kubeAsGroups") + assert.Equal(t, tt.kubeCaFile, settings.KubeCaFile, "kubeCaFile") + assert.Equal(t, tt.burstLimit, settings.BurstLimit, "burstLimit") + assert.Equal(t, tt.kubeInsecure, settings.KubeInsecureSkipTLSVerify, "kubeInsecure") + assert.Equal(t, tt.kubeTLSServer, settings.KubeTLSServerName, "kubeTLSServer") }) } } @@ -235,9 +212,7 @@ func TestEnvOrBool(t *testing.T) { t.Setenv(tt.env, tt.val) } actual := envBoolOr(tt.env, tt.def) - if actual != tt.expected { - t.Errorf("expected result %t, got %t", tt.expected, actual) - } + assert.Equal(t, tt.expected, actual) }) } } @@ -247,14 +222,10 @@ func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { settings := New() restConfig, err := settings.RESTClientGetter().ToRESTConfig() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) expectedUserAgent := version.GetUserAgent() - if restConfig.UserAgent != expectedUserAgent { - t.Errorf("expected User-Agent header %q in K8s REST client config, got %q", expectedUserAgent, restConfig.UserAgent) - } + assert.Equal(t, expectedUserAgent, restConfig.UserAgent) } func resetEnv() func() { diff --git a/pkg/cli/output/output.go b/pkg/cli/output/output.go index 28d503741..a9bd846fe 100644 --- a/pkg/cli/output/output.go +++ b/pkg/cli/output/output.go @@ -18,6 +18,7 @@ package output import ( "encoding/json" + "errors" "fmt" "io" @@ -50,7 +51,7 @@ func FormatsWithDesc() map[string]string { } // ErrInvalidFormatType is returned when an unsupported format type is used -var ErrInvalidFormatType = fmt.Errorf("invalid format type") +var ErrInvalidFormatType = errors.New("invalid format type") // String returns the string representation of the Format func (o Format) String() string { @@ -102,7 +103,7 @@ type Writer interface { // EncodeJSON is a helper function to decorate any error message with a bit more // context and avoid writing the same code over and over for printers. -func EncodeJSON(out io.Writer, obj interface{}) error { +func EncodeJSON(out io.Writer, obj any) error { enc := json.NewEncoder(out) err := enc.Encode(obj) if err != nil { @@ -113,7 +114,7 @@ func EncodeJSON(out io.Writer, obj interface{}) error { // EncodeYAML is a helper function to decorate any error message with a bit more // context and avoid writing the same code over and over for printers -func EncodeYAML(out io.Writer, obj interface{}) error { +func EncodeYAML(out io.Writer, obj any) error { raw, err := yaml.Marshal(obj) if err != nil { return fmt.Errorf("unable to write YAML output: %w", err) diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go index fe1afc5d2..f6a116f18 100644 --- a/pkg/cli/values/options_test.go +++ b/pkg/cli/values/options_test.go @@ -19,13 +19,13 @@ package values import ( "bytes" "errors" - "fmt" "os" "path/filepath" - "reflect" - "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/getter" ) @@ -91,10 +91,7 @@ func TestReadFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "test.txt") content := []byte("local file content") - err := os.WriteFile(filePath, content, 0644) - if err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(filePath, content, 0644)) return filePath, func() {} // cleanup handled by t.TempDir() }, expectError: false, @@ -155,10 +152,7 @@ func TestReadFile(t *testing.T) { fileName := "ftp_file.txt" // Valid filename for filesystem filePath := filepath.Join(tmpDir, fileName) content := []byte("local fallback content") - err := os.WriteFile(filePath, content, 0644) - if err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(filePath, content, 0644)) return filePath, func() {} }, expectError: false, @@ -203,9 +197,7 @@ func TestReadFile(t *testing.T) { // Create a pipe for stdin r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) defer r.Close() defer w.Close() @@ -221,28 +213,21 @@ func TestReadFile(t *testing.T) { // Test the function got, err := readFile(actualFilePath, tt.providers) - if err != nil { - t.Errorf("readFile() error = %v, expected no error for stdin", err) - return - } - - if !bytes.Equal(got, testData) { - t.Errorf("readFile() = %v, want %v", got, testData) - } + require.NoError(t, err, "readFile() expected no error for stdin") + assert.Equal(t, testData, got) return } // Regular test cases got, err := readFile(actualFilePath, tt.providers) - if (err != nil) != tt.expectError { - t.Errorf("readFile() error = %v, expectError %v", err, tt.expectError) + if tt.expectError { + assert.Error(t, err) return } + assert.NoError(t, err) - if !tt.expectError && tt.expectedData != nil { - if !bytes.Equal(got, tt.expectedData) { - t.Errorf("readFile() = %v, want %v", got, tt.expectedData) - } + if tt.expectedData != nil { + assert.Equal(t, tt.expectedData, got) } }) } @@ -265,7 +250,7 @@ func TestReadFileErrorMessages(t *testing.T) { { name: "getter error with message", filePath: "http://example.com/file", - providers: getter.Providers{mockProvider([]string{"http"}, nil, fmt.Errorf("connection refused"))}, + providers: getter.Providers{mockProvider([]string{"http"}, nil, errors.New("connection refused"))}, wantErr: "connection refused", }, } @@ -273,13 +258,8 @@ func TestReadFileErrorMessages(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := readFile(tt.filePath, tt.providers) - if err == nil { - t.Errorf("readFile() expected error containing %q, got nil", tt.wantErr) - return - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("readFile() error = %v, want error containing %q", err, tt.wantErr) - } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) }) } } @@ -289,16 +269,14 @@ func TestReadFileOriginal(t *testing.T) { var p getter.Providers filePath := "%a.txt" _, err := readFile(filePath, p) - if err == nil { - t.Errorf("Expected error when has special strings") - } + assert.Error(t, err, "Expected error when has special strings") } func TestMergeValuesCLI(t *testing.T) { tests := []struct { name string opts Options - expected map[string]interface{} + expected map[string]any wantErr bool }{ { @@ -306,8 +284,8 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ JSONValues: []string{`{"foo": {"bar": "baz"}}`}, }, - expected: map[string]interface{}{ - "foo": map[string]interface{}{ + expected: map[string]any{ + "foo": map[string]any{ "bar": "baz", }, }, @@ -317,9 +295,9 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ JSONValues: []string{"foo.bar=[1,2,3]"}, }, - expected: map[string]interface{}{ - "foo": map[string]interface{}{ - "bar": []interface{}{1.0, 2.0, 3.0}, + expected: map[string]any{ + "foo": map[string]any{ + "bar": []any{1.0, 2.0, 3.0}, }, }, }, @@ -328,7 +306,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ Values: []string{"foo=bar"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "bar", }, }, @@ -337,7 +315,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ StringValues: []string{"foo=123"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "123", }, }, @@ -346,7 +324,7 @@ func TestMergeValuesCLI(t *testing.T) { opts: Options{ LiteralValues: []string{"foo=true"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "foo": "true", }, }, @@ -358,7 +336,7 @@ func TestMergeValuesCLI(t *testing.T) { JSONValues: []string{`{"c": "foo1"}`}, LiteralValues: []string{"d=bar1"}, }, - expected: map[string]interface{}{ + expected: map[string]any{ "a": "foo", "b": "bar", "c": "foo1", @@ -377,13 +355,12 @@ func TestMergeValuesCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.opts.MergeValues(getter.Providers{}) - if (err != nil) != tt.wantErr { - t.Errorf("MergeValues() error = %v, wantErr %v", err, tt.wantErr) + if tt.wantErr { + assert.Error(t, err) return } - if !tt.wantErr && !reflect.DeepEqual(got, tt.expected) { - t.Errorf("MergeValues() = %v, want %v", got, tt.expected) - } + require.NoError(t, err) + assert.Equal(t, tt.expected, got) }) } } diff --git a/pkg/cmd/completion.go b/pkg/cmd/completion.go index 6f6dbd25d..c02462d9d 100644 --- a/pkg/cmd/completion.go +++ b/pkg/cmd/completion.go @@ -193,7 +193,7 @@ compdef _helm %[1]s } // Cobra doesn't source zsh completion file, explicitly doing it here - fmt.Fprintf(out, "compdef _helm helm") + fmt.Fprint(out, "compdef _helm helm") return err } diff --git a/pkg/cmd/completion_test.go b/pkg/cmd/completion_test.go index 81c1ee2ad..399ff1f0c 100644 --- a/pkg/cmd/completion_test.go +++ b/pkg/cmd/completion_test.go @@ -51,7 +51,6 @@ func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) { if shouldBePerformed { t.Errorf("Unexpected directive ShellCompDirectiveNoFileComp when completing '%s'", cmdName) } else { - t.Errorf("Did not receive directive ShellCompDirectiveNoFileComp when completing '%s'", cmdName) } t.Log(out) diff --git a/pkg/cmd/create_test.go b/pkg/cmd/create_test.go index 57e2aaf5f..1a1289775 100644 --- a/pkg/cmd/create_test.go +++ b/pkg/cmd/create_test.go @@ -17,9 +17,9 @@ limitations under the License. package cmd import ( - "fmt" "os" "path/filepath" + "slices" "testing" chartv3 "helm.sh/helm/v4/internal/chart/v3" @@ -47,7 +47,7 @@ func TestCreateCmd(t *testing.T) { if fi, err := os.Stat(cname); err != nil { t.Fatalf("no chart directory: %s", err) } else if !fi.IsDir() { - t.Fatalf("chart is not directory") + t.Fatal("chart is not directory") } c, err := chartloader.LoadDir(cname) @@ -138,11 +138,11 @@ func TestCreateStarterCmd(t *testing.T) { if tt.useAbsolutePath { starterArg = filepath.Join(starterchart, "starterchart") } - cmd := fmt.Sprintf("create --starter=%s", starterArg) + cmd := "create --starter=" + starterArg if tt.chartAPIVersion == "v3" { - cmd += fmt.Sprintf(" --chart-api-version=%s", chartv3.APIVersionV3) + cmd += " --chart-api-version=" + chartv3.APIVersionV3 } else { - cmd += fmt.Sprintf(" --chart-api-version=%s", chartv2.APIVersionV2) + cmd += " --chart-api-version=" + chartv2.APIVersionV2 } cmd += " " + cname @@ -155,7 +155,7 @@ func TestCreateStarterCmd(t *testing.T) { if fi, err := os.Stat(cname); err != nil { t.Fatalf("no chart directory: %s", err) } else if !fi.IsDir() { - t.Fatalf("chart is not directory") + t.Fatal("chart is not directory") } // Load and verify the chart @@ -188,13 +188,7 @@ func TestCreateStarterCmd(t *testing.T) { } // Verify custom template exists - found := false - for _, name := range templates { - if name == "templates/foo.tpl" { - found = true - break - } - } + found := slices.Contains(templates, "templates/foo.tpl") if !found { t.Error("Did not find foo.tpl") } @@ -221,7 +215,7 @@ func TestCreateCmdChartAPIVersionV2(t *testing.T) { if fi, err := os.Stat(cname); err != nil { t.Fatalf("no chart directory: %s", err) } else if !fi.IsDir() { - t.Fatalf("chart is not directory") + t.Fatal("chart is not directory") } c, err := chartloader.LoadDir(cname) @@ -262,7 +256,7 @@ func TestCreateCmdChartAPIVersionV3(t *testing.T) { if fi, err := os.Stat(cname); err != nil { t.Fatalf("no chart directory: %s", err) } else if !fi.IsDir() { - t.Fatalf("chart is not directory") + t.Fatal("chart is not directory") } c, err := chartloader.LoadDir(cname) diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index b8ac16e60..f572fbae1 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -55,7 +55,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { if len(args) > 0 { chartpath = filepath.Clean(args[0]) } - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) diff --git a/pkg/cmd/dependency_update.go b/pkg/cmd/dependency_update.go index 7f805c37b..a7131e587 100644 --- a/pkg/cmd/dependency_update.go +++ b/pkg/cmd/dependency_update.go @@ -58,7 +58,7 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma if len(args) > 0 { chartpath = filepath.Clean(args[0]) } - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) diff --git a/pkg/cmd/dependency_update_test.go b/pkg/cmd/dependency_update_test.go index 3eaa51df1..f9ff68097 100644 --- a/pkg/cmd/dependency_update_test.go +++ b/pkg/cmd/dependency_update_test.go @@ -208,7 +208,7 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { // Make sure tmpcharts-x is deleted tmpPath := filepath.Join(dir(chartname), fmt.Sprintf("tmpcharts-%d", os.Getpid())) if _, err := os.Stat(tmpPath); !errors.Is(err, fs.ErrNotExist) { - t.Fatalf("tmpcharts dir still exists") + t.Fatal("tmpcharts dir still exists") } } diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 6d9d117f8..e7db3dde2 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -17,6 +17,7 @@ limitations under the License. package cmd import ( + "errors" "flag" "fmt" "log" @@ -119,7 +120,7 @@ func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) { // value to the given format pointer func bindOutputFlag(cmd *cobra.Command, varRef *output.Format) { cmd.Flags().VarP(newOutputValue(output.Table, varRef), outputFlag, "o", - fmt.Sprintf("prints the output in the specified format. Allowed values: %s", strings.Join(output.Formats(), ", "))) + "prints the output in the specified format. Allowed values: "+strings.Join(output.Formats(), ", ")) err := cmd.RegisterFlagCompletionFunc(outputFlag, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { var formatNames []string @@ -195,7 +196,7 @@ func (p *postRendererString) Set(val string) error { return nil } if p.options.pluginName != "" { - return fmt.Errorf("cannot specify --post-renderer flag more than once") + return errors.New("cannot specify --post-renderer flag more than once") } p.options.pluginName = val pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) @@ -219,7 +220,6 @@ func (p *postRendererArgsSlice) Type() string { } func (p *postRendererArgsSlice) Set(val string) error { - // a post-renderer defined by a user may accept empty arguments p.options.args = append(p.options.args, val) diff --git a/pkg/cmd/get_all.go b/pkg/cmd/get_all.go index 32744796c..bce89d7d3 100644 --- a/pkg/cmd/get_all.go +++ b/pkg/cmd/get_all.go @@ -53,7 +53,7 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } if template != "" { - data := map[string]interface{}{ + data := map[string]any{ "Release": res, } return tpl(template, data, out) diff --git a/pkg/cmd/get_metadata.go b/pkg/cmd/get_metadata.go index eb90b6e44..c26af646f 100644 --- a/pkg/cmd/get_metadata.go +++ b/pkg/cmd/get_metadata.go @@ -77,7 +77,6 @@ func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command } func (w metadataWriter) WriteTable(out io.Writer) error { - formatApplyMethod := func(applyMethod string) string { switch applyMethod { case "": diff --git a/pkg/cmd/get_values.go b/pkg/cmd/get_values.go index 02b195551..c6a89b00c 100644 --- a/pkg/cmd/get_values.go +++ b/pkg/cmd/get_values.go @@ -33,7 +33,7 @@ This command downloads a values file for a given release. ` type valuesWriter struct { - vals map[string]interface{} + vals map[string]any allValues bool } diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go index e555dd18b..8866b8650 100644 --- a/pkg/cmd/helpers.go +++ b/pkg/cmd/helpers.go @@ -43,7 +43,6 @@ func addDryRunFlag(cmd *cobra.Command) { // Determine the `action.DryRunStrategy` given -dry-run=` flag (or absence of) // Legacy usage of the flag: boolean values, and `--dry-run` (without value) are supported, and log warnings emitted func cmdGetDryRunFlagStrategy(cmd *cobra.Command, isTemplate bool) (action.DryRunStrategy, error) { - f := cmd.Flag("dry-run") v := f.Value.String() diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 08065499e..37ceb6a85 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -157,7 +157,6 @@ func resetEnv() func() { } func TestCmdGetDryRunFlagStrategy(t *testing.T) { - type testCaseExpectedLog struct { Level string Msg string @@ -274,7 +273,6 @@ func TestCmdGetDryRunFlagStrategy(t *testing.T) { } for name, tc := range testCases { - logBuf := new(bytes.Buffer) logger := slog.New(slog.NewJSONHandler(logBuf, nil)) slog.SetDefault(logger) @@ -290,14 +288,14 @@ func TestCmdGetDryRunFlagStrategy(t *testing.T) { if tc.ExpectedError { assert.Error(t, err) } else { - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, tc.ExpectedStrategy, dryRunStrategy) } if tc.ExpectedLog != nil { logResult := map[string]string{} err = json.Unmarshal(logBuf.Bytes(), &logResult) - require.Nil(t, err) + require.NoError(t, err) assert.Equal(t, tc.ExpectedLog.Level, logResult["level"]) assert.Equal(t, tc.ExpectedLog.Msg, logResult["msg"]) diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index b294a9da7..f53a2f1a6 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -20,6 +20,7 @@ import ( "encoding/json" "fmt" "io" + "slices" "strconv" "time" @@ -48,11 +49,21 @@ The historical release set is printed as a formatted table, e.g: 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Rolled back to 2 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully + +Use '--show-rollback-revision' to include a column showing the revision that was rolled back to: + + $ helm history angry-bird --show-rollback-revision + REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION + 1 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Initial install + 2 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 Upgraded successfully + 3 Mon Oct 3 10:15:13 2016 superseded alpine-0.1.0 1.0 2 Rolled back to 2 + 4 Mon Oct 3 10:15:13 2016 deployed alpine-0.1.0 1.0 Upgraded successfully ` func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewHistory(cfg) var outfmt output.Format + var showRollback bool cmd := &cobra.Command{ Use: "history RELEASE_NAME", @@ -72,41 +83,47 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } + if showRollback { + return outfmt.Write(out, releaseHistoryWithRollback(history)) + } return outfmt.Write(out, history) }, } f := cmd.Flags() f.IntVar(&client.Max, "max", 256, "maximum number of revision to include in history") + f.BoolVar(&showRollback, "show-rollback-revision", false, "show the rollback revision column in table output") bindOutputFlag(cmd, &outfmt) return cmd } type releaseInfo struct { - Revision int `json:"revision"` - Updated time.Time `json:"updated,omitzero"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated time.Time `json:"updated,omitzero"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Description string `json:"description"` } // releaseInfoJSON is used for custom JSON marshaling/unmarshaling type releaseInfoJSON struct { - Revision int `json:"revision"` - Updated *time.Time `json:"updated,omitempty"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated *time.Time `json:"updated,omitempty"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Description string `json:"description"` } // UnmarshalJSON implements the json.Unmarshaler interface. // It handles empty string time fields by treating them as zero values. func (r *releaseInfo) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -138,6 +155,7 @@ func (r *releaseInfo) UnmarshalJSON(data []byte) error { r.Status = tmp.Status r.Chart = tmp.Chart r.AppVersion = tmp.AppVersion + r.RollbackRevision = tmp.RollbackRevision r.Description = tmp.Description return nil @@ -147,11 +165,12 @@ func (r *releaseInfo) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (r releaseInfo) MarshalJSON() ([]byte, error) { tmp := releaseInfoJSON{ - Revision: r.Revision, - Status: r.Status, - Chart: r.Chart, - AppVersion: r.AppVersion, - Description: r.Description, + Revision: r.Revision, + Status: r.Status, + Chart: r.Chart, + AppVersion: r.AppVersion, + RollbackRevision: r.RollbackRevision, + Description: r.Description, } if !r.Updated.IsZero() { @@ -180,6 +199,30 @@ func (r releaseHistory) WriteTable(out io.Writer) error { return output.EncodeTable(out, tbl) } +// releaseHistoryWithRollback wraps releaseHistory to include the rollback column in table output. +type releaseHistoryWithRollback releaseHistory + +func (r releaseHistoryWithRollback) WriteJSON(out io.Writer) error { + return output.EncodeJSON(out, releaseHistory(r)) +} + +func (r releaseHistoryWithRollback) WriteYAML(out io.Writer) error { + return output.EncodeYAML(out, releaseHistory(r)) +} + +func (r releaseHistoryWithRollback) WriteTable(out io.Writer) error { + tbl := uitable.New() + tbl.AddRow("REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION", "ROLLBACK", "DESCRIPTION") + for _, item := range r { + rollback := "" + if item.RollbackRevision > 0 { + rollback = strconv.Itoa(item.RollbackRevision) + } + tbl.AddRow(item.Revision, item.Updated.Format(time.ANSIC), item.Status, item.Chart, item.AppVersion, rollback, item.Description) + } + return output.EncodeTable(out, tbl) +} + func getHistory(client *action.History, name string) (releaseHistory, error) { histi, err := client.Run(name) if err != nil { @@ -207,8 +250,8 @@ func getHistory(client *action.History, name string) (releaseHistory, error) { } func getReleaseHistory(rls []*release.Release) (history releaseHistory) { - for i := len(rls) - 1; i >= 0; i-- { - r := rls[i] + for _, v := range slices.Backward(rls) { + r := v c := formatChartName(r.Chart) s := r.Info.Status.String() v := r.Version @@ -216,15 +259,15 @@ func getReleaseHistory(rls []*release.Release) (history releaseHistory) { a := formatAppVersion(r.Chart) rInfo := releaseInfo{ - Revision: v, - Status: s, - Chart: c, - AppVersion: a, - Description: d, + Revision: v, + Status: s, + Chart: c, + AppVersion: a, + RollbackRevision: r.Info.RollbackRevision, + Description: d, } if !r.Info.LastDeployed.IsZero() { rInfo.Updated = r.Info.LastDeployed - } history = append(history, rInfo) } @@ -260,7 +303,7 @@ func compListRevisions(_ string, cfg *action.Configuration, releaseName string) return nil, cobra.ShellCompDirectiveError } for _, version := range hist { - appVersion := fmt.Sprintf("App: %s", version.Chart.Metadata.AppVersion) + appVersion := "App: " + version.Chart.Metadata.AppVersion chartDesc := fmt.Sprintf("Chart: %s-%s", version.Chart.Metadata.Name, version.Chart.Metadata.Version) revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(version.Version), appVersion, chartDesc)) } diff --git a/pkg/cmd/history_test.go b/pkg/cmd/history_test.go index d8adc2d19..3ec59a99f 100644 --- a/pkg/cmd/history_test.go +++ b/pkg/cmd/history_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -76,6 +77,77 @@ func TestHistoryCmd(t *testing.T) { runTestCmd(t, tests) } +func TestHistoryWithRollback(t *testing.T) { + date := time.Unix(242085845, 0).UTC() + ch := &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "foo", + Version: "0.1.0-beta.1", + AppVersion: "1.0", + }, + } + + rels := []*release.Release{ + { + Name: "angry-bird", + Version: 1, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusSuperseded, + Description: "Install complete", + }, + Chart: ch, + }, + { + Name: "angry-bird", + Version: 2, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusSuperseded, + Description: "Upgrade complete", + }, + Chart: ch, + }, + { + Name: "angry-bird", + Version: 3, + Info: &release.Info{ + FirstDeployed: date, + LastDeployed: date, + Status: common.StatusDeployed, + RollbackRevision: 1, + Description: "Rollback to 1", + }, + Chart: ch, + }, + } + + tests := []cmdTestCase{{ + name: "history with rollback revision (default, no rollback column)", + cmd: "history angry-bird", + rels: rels, + golden: "output/history-with-rollback-no-flag.txt", + }, { + name: "history with rollback revision and --show-rollback-revision flag", + cmd: "history angry-bird --show-rollback-revision", + rels: rels, + golden: "output/history-with-rollback.txt", + }, { + name: "history with rollback revision json", + cmd: "history angry-bird --output json", + rels: rels, + golden: "output/history-with-rollback.json", + }, { + name: "history with rollback revision yaml", + cmd: "history angry-bird --output yaml", + rels: rels, + golden: "output/history-with-rollback.yaml", + }} + runTestCmd(t, tests) +} + func TestHistoryOutputCompletion(t *testing.T) { outputFlagCompletionTest(t, "history") } @@ -173,6 +245,31 @@ func TestReleaseInfoMarshalJSON(t *testing.T) { }, expected: `{"revision":0,"updated":"2025-10-08T12:00:00Z","status":"failed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install failed"}`, }, + { + name: "with rollback revision", + info: releaseInfo{ + Revision: 3, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + RollbackRevision: 1, + Description: "Rollback to 1", + }, + expected: `{"revision":3,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","rollback_revision":1,"description":"Rollback to 1"}`, + }, + { + name: "without rollback revision", + info: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Initial install", + }, + expected: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Initial install"}`, + }, } for _, tt := range tests { @@ -255,6 +352,31 @@ func TestReleaseInfoUnmarshalJSON(t *testing.T) { Description: "Installing", }, }, + { + name: "with rollback revision", + input: `{"revision":3,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","rollback_revision":1,"description":"Rollback to 1"}`, + expected: releaseInfo{ + Revision: 3, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + RollbackRevision: 1, + Description: "Rollback to 1", + }, + }, + { + name: "without rollback revision field", + input: `{"revision":1,"updated":"2025-10-08T12:00:00Z","status":"deployed","chart":"mychart-1.0.0","app_version":"1.0.0","description":"Install"}`, + expected: releaseInfo{ + Revision: 1, + Updated: updated, + Status: "deployed", + Chart: "mychart-1.0.0", + AppVersion: "1.0.0", + Description: "Install", + }, + }, } for _, tt := range tests { @@ -271,6 +393,7 @@ func TestReleaseInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Status, info.Status) assert.Equal(t, tt.expected.Chart, info.Chart) assert.Equal(t, tt.expected.AppVersion, info.AppVersion) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Description, info.Description) }) } @@ -300,6 +423,7 @@ func TestReleaseInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Status, decoded.Status) assert.Equal(t, original.Chart, decoded.Chart) assert.Equal(t, original.AppVersion, decoded.AppVersion) + assert.Equal(t, original.RollbackRevision, decoded.RollbackRevision) assert.Equal(t, original.Description, decoded.Description) } @@ -321,7 +445,7 @@ func TestReleaseInfoEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&info) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index d36cd9e34..832968ff0 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -143,7 +143,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return compInstall(args, toComplete, client) }, RunE: func(cmd *cobra.Command, args []string) error { - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) @@ -201,6 +201,7 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the installation process will not validate rendered templates against the Kubernetes OpenAPI Schema") f.BoolVar(&client.RollbackOnFailure, "rollback-on-failure", false, "if set, Helm will rollback (uninstall) the installation upon failure. The --wait flag will be default to \"watcher\" if --rollback-on-failure is set") + f.BoolVar(&client.RollbackOnFailure, "atomic", false, "deprecated") f.MarkDeprecated("atomic", "use --rollback-on-failure instead") f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed. By default, CRDs are installed if not already present") f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent") @@ -209,6 +210,26 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") f.BoolVar(&client.HideNotes, "hide-notes", false, "if set, do not show notes in install output. Does not affect presence in chart metadata") f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, install will ignore the check for helm annotations and take ownership of the existing resources") + + // For `helm template`, these notes flags are legacy, unused, and should not show in help, but + // must remain accepted for backwards compatibility in Helm 4. Deprecate and hide them for now + // TODO remove these from template command in Helm 5 + if cmd.Name() == "template" { + if err := cmd.Flags().MarkDeprecated("hide-notes", "this flag has no effect for 'helm template' and will be removed in Helm 5"); err != nil { + log.Fatal(err) + } + if err := cmd.Flags().MarkHidden("hide-notes"); err != nil { + log.Fatal(err) + } + + if err := cmd.Flags().MarkDeprecated("render-subchart-notes", "this flag has no effect for 'helm template' and will be removed in Helm 5"); err != nil { + log.Fatal(err) + } + if err := cmd.Flags().MarkHidden("render-subchart-notes"); err != nil { + log.Fatal(err) + } + } + addValueOptionsFlags(f, valueOpts) addChartPathOptionsFlags(f, &client.ChartPathOptions) AddWaitFlag(cmd, &client.WaitStrategy) @@ -301,7 +322,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options return nil, fmt.Errorf("failed reloading chart after repo update: %w", err) } } else { - return nil, fmt.Errorf("an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: %w", err) + return nil, fmt.Errorf("an error occurred while checking for chart dependencies. You may need to run 'helm dependency build' to fetch missing dependencies: %w", err) } } } diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 5fa3c1340..8d3435e03 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -291,27 +291,27 @@ func TestInstallVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for install version flag with release name", - cmd: fmt.Sprintf("%s __complete install releasename testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete install releasename testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for install version flag with generate-name", - cmd: fmt.Sprintf("%s __complete install --generate-name testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete install --generate-name testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for install version flag, no filter", - cmd: fmt.Sprintf("%s __complete install releasename testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete install releasename testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for install version flag too few args", - cmd: fmt.Sprintf("%s __complete install testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete install testing/alpine --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for install version flag too many args", - cmd: fmt.Sprintf("%s __complete install releasename testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete install releasename testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for install version flag invalid chart", - cmd: fmt.Sprintf("%s __complete install releasename invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete install releasename invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index ccc53ddd0..1b5c3b212 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -59,7 +59,7 @@ func newLintCmd(out io.Writer) *cobra.Command { if kubeVersion != "" { parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) if err != nil { - return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err) + return fmt.Errorf("invalid kube version '%s': %w", kubeVersion, err) } client.KubeVersion = parsedKubeVersion } diff --git a/pkg/cmd/lint_test.go b/pkg/cmd/lint_test.go index f825e36e2..0670e9f4d 100644 --- a/pkg/cmd/lint_test.go +++ b/pkg/cmd/lint_test.go @@ -25,12 +25,12 @@ func TestLintCmdWithSubchartsFlag(t *testing.T) { testChart := "testdata/testcharts/chart-with-bad-subcharts" tests := []cmdTestCase{{ name: "lint good chart with bad subcharts", - cmd: fmt.Sprintf("lint %s", testChart), + cmd: "lint " + testChart, golden: "output/lint-chart-with-bad-subcharts.txt", wantError: true, }, { name: "lint good chart with bad subcharts using --with-subcharts flag", - cmd: fmt.Sprintf("lint --with-subcharts %s", testChart), + cmd: "lint --with-subcharts " + testChart, golden: "output/lint-chart-with-bad-subcharts-with-subcharts.txt", wantError: true, }} @@ -42,7 +42,7 @@ func TestLintCmdWithQuietFlag(t *testing.T) { testChart2 := "testdata/testcharts/chart-bad-requirements" tests := []cmdTestCase{{ name: "lint good chart using --quiet flag", - cmd: fmt.Sprintf("lint --quiet %s", testChart1), + cmd: "lint --quiet " + testChart1, golden: "output/lint-quiet.txt", }, { name: "lint two charts, one with error using --quiet flag", @@ -60,31 +60,30 @@ func TestLintCmdWithQuietFlag(t *testing.T) { wantError: true, }} runTestCmd(t, tests) - } func TestLintCmdWithKubeVersionFlag(t *testing.T) { testChart := "testdata/testcharts/chart-with-deprecated-api" tests := []cmdTestCase{{ name: "lint chart with deprecated api version using kube version flag", - cmd: fmt.Sprintf("lint --kube-version 1.22.0 %s", testChart), + cmd: "lint --kube-version 1.22.0 " + testChart, golden: "output/lint-chart-with-deprecated-api.txt", wantError: false, }, { name: "lint chart with deprecated api version using kube version and strict flag", - cmd: fmt.Sprintf("lint --kube-version 1.22.0 --strict %s", testChart), + cmd: "lint --kube-version 1.22.0 --strict " + testChart, golden: "output/lint-chart-with-deprecated-api-strict.txt", wantError: true, }, { // 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), + cmd: "lint " + testChart, golden: "output/lint-chart-with-deprecated-api-old-k8s.txt", wantError: false, }, { name: "lint chart with deprecated api version with older kube version", - cmd: fmt.Sprintf("lint --kube-version 1.21.0 --strict %s", testChart), + cmd: "lint --kube-version 1.20.0 --strict " + testChart, golden: "output/lint-chart-with-deprecated-api-old-k8s.txt", wantError: false, }} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index 3c15a0954..54becddca 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -254,7 +254,7 @@ func filterReleases(releases []*release.Release, ignoredReleaseNames []string) [ // Provide dynamic auto-completion for release names func compListReleases(toComplete string, ignoredReleaseNames []string, cfg *action.Configuration) ([]string, cobra.ShellCompDirective) { - cobra.CompDebugln(fmt.Sprintf("compListReleases with toComplete %s", toComplete), settings.Debug) + cobra.CompDebugln("compListReleases with toComplete "+toComplete, settings.Debug) client := action.NewList(cfg) client.All = true diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index 029dd04f5..b6cc38ce6 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -333,7 +333,6 @@ func loadFile(path string) (*pluginCommand, error) { // to obtain the dynamic completion choices. It must pass all the flags and sub-commands // specified in the command-line to the plugin.complete executable (except helm's global flags) func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - subprocessPlug, ok := plug.(*plugin.SubprocessPluginRuntime) if !ok { // Completion only supported for subprocess plugins (TODO: fix this) diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 96c0c47b2..46133d971 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -57,7 +57,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { Long: packageDesc, RunE: func(_ *cobra.Command, args []string) error { if len(args) == 0 { - return fmt.Errorf("need at least one argument, the path to the chart") + return errors.New("need at least one argument, the path to the chart") } if client.Sign { if client.Key == "" { @@ -75,7 +75,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { return err } - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index efa9b466c..c248ed818 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -16,6 +16,7 @@ limitations under the License. package cmd import ( + "errors" "fmt" "io" "log/slog" @@ -50,11 +51,11 @@ const pluginInstallDesc = ` This command allows you to install a plugin from a url to a VCS repo or a local path. By default, plugin signatures are verified before installation when installing from -tarballs (.tgz or .tar.gz). This requires a corresponding .prov file to be available -alongside the tarball. +tarballs (.tgz or .tar.gz). A corresponding .prov file must be available alongside +the tarball; installation will fail if it is missing or invalid. For local development, plugins installed from local directories are automatically treated as "local dev" and do not require signatures. -Use --verify=false to skip signature verification for remote plugins. +Use --verify=false to explicitly skip signature verification (NOT recommended). ` func newPluginInstallCmd(out io.Writer) *cobra.Command { @@ -102,7 +103,7 @@ func (o *pluginInstallOptions) complete(args []string) error { func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, error) { // Check if source is an OCI registry reference - if strings.HasPrefix(o.source, fmt.Sprintf("%s://", registry.OCIScheme)) { + if strings.HasPrefix(o.source, registry.OCIScheme+"://") { // Build getter options for OCI options := []getter.Option{ getter.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), @@ -131,15 +132,15 @@ func (o *pluginInstallOptions) run(out io.Writer) error { if localInst, ok := i.(*installer.LocalInstaller); ok && !localInst.SupportsVerification() { // Local directory installations are allowed without verification shouldVerify = false - fmt.Fprintf(out, "Installing plugin from local directory (development mode)\n") + fmt.Fprint(out, "Installing plugin from local directory (development mode)\n") } else if shouldVerify { // For remote installations, check if verification is supported if verifier, ok := i.(installer.Verifier); !ok || !verifier.SupportsVerification() { - return fmt.Errorf("plugin source does not support verification. Use --verify=false to skip verification") + return errors.New("plugin source does not support verification. Use --verify=false to skip verification") } } else { // User explicitly disabled verification - fmt.Fprintf(out, "WARNING: Skipping plugin signature verification\n") + fmt.Fprint(out, "WARNING: Skipping plugin signature verification\n") } // Set up installation options @@ -150,7 +151,7 @@ func (o *pluginInstallOptions) run(out io.Writer) error { // If verify is requested, show verification output if shouldVerify { - fmt.Fprintf(out, "Verifying plugin signature...\n") + fmt.Fprint(out, "Verifying plugin signature...\n") } // Install the plugin with options diff --git a/pkg/cmd/plugin_package.go b/pkg/cmd/plugin_package.go index 05f8bb5ad..d82f4d2cc 100644 --- a/pkg/cmd/plugin_package.go +++ b/pkg/cmd/plugin_package.go @@ -81,7 +81,7 @@ func (o *pluginPackageOptions) run(out io.Writer) error { return err } if !fi.IsDir() { - return fmt.Errorf("plugin package only supports directories, not tarballs") + return errors.New("plugin package only supports directories, not tarballs") } // Load and validate plugin metadata @@ -119,7 +119,7 @@ func (o *pluginPackageOptions) run(out io.Writer) error { } } else { // User explicitly disabled signing - fmt.Fprintf(out, "WARNING: Skipping plugin signing. This is not recommended for plugins intended for distribution.\n") + fmt.Fprint(out, "WARNING: Skipping plugin signing. This is not recommended for plugins intended for distribution.\n") } // Now create the tarball (only after signing prerequisites are met) diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index a250ba221..0a6435d99 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -100,7 +100,7 @@ func TestLoadCLIPlugins(t *testing.T) { "HELM_PLUGINS=testdata/helmhome/helm/plugins", "HELM_REPOSITORY_CONFIG=testdata/helmhome/helm/repositories.yaml", "HELM_REPOSITORY_CACHE=testdata/helmhome/helm/repository", - fmt.Sprintf("HELM_BIN=%s", os.Args[0]), + "HELM_BIN=" + os.Args[0], }, "\n") + "\n" // Test that the YAML file was correctly converted to a command. @@ -117,6 +117,7 @@ func TestLoadCLIPlugins(t *testing.T) { {"exitwith", "exitwith code", "This exits with the specified exit code", "", []string{"2"}, 2}, {"fullenv", "show env vars", "show all env vars", fullEnvOutput, []string{}, 0}, {"shortenv", "env stuff", "show the env", "HELM_PLUGIN_NAME=shortenv\n", []string{}, 0}, + // "noversion": plugin is invalid, and should not be loaded } pluginCmds := cmd.Commands() diff --git a/pkg/cmd/plugin_uninstall.go b/pkg/cmd/plugin_uninstall.go index 85eb46219..81d9b31ee 100644 --- a/pkg/cmd/plugin_uninstall.go +++ b/pkg/cmd/plugin_uninstall.go @@ -62,7 +62,7 @@ func (o *pluginUninstallOptions) complete(args []string) error { func (o *pluginUninstallOptions) run(out io.Writer) error { slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory) - plugins, err := plugin.LoadAll(settings.PluginsDirectory) + plugins, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc) if err != nil { return err } @@ -70,7 +70,7 @@ func (o *pluginUninstallOptions) run(out io.Writer) error { for _, name := range o.names { if found := findPlugin(plugins, name); found != nil { if err := uninstallPlugin(found); err != nil { - errorPlugins = append(errorPlugins, fmt.Errorf("failed to uninstall plugin %s, got error (%v)", name, err)) + errorPlugins = append(errorPlugins, fmt.Errorf("failed to uninstall plugin %s, got error (%w)", name, err)) } else { fmt.Fprintf(out, "Uninstalled plugin: %s\n", name) } diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 6cc2729fc..1cba7ffa9 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -62,7 +62,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { func (o *pluginUpdateOptions) run(out io.Writer) error { slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) - plugins, err := plugin.LoadAll(settings.PluginsDirectory) + plugins, err := plugin.LoadAllDir(settings.PluginsDirectory, plugin.LogIgnorePluginLoadErrorFilterFunc) if err != nil { return err } @@ -71,7 +71,7 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { for _, name := range o.names { if found := findPlugin(plugins, name); found != nil { if err := updatePlugin(found); err != nil { - errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err)) + errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%w)", name, err)) } else { fmt.Fprintf(out, "Updated plugin: %s\n", name) } diff --git a/pkg/cmd/plugin_verify.go b/pkg/cmd/plugin_verify.go index 5f89e743e..fc54a9d77 100644 --- a/pkg/cmd/plugin_verify.go +++ b/pkg/cmd/plugin_verify.go @@ -16,6 +16,7 @@ limitations under the License. package cmd import ( + "errors" "fmt" "io" "os" @@ -75,12 +76,12 @@ func (o *pluginVerifyOptions) run(out io.Writer) error { // Only support tarball verification if fi.IsDir() { - return fmt.Errorf("directory verification not supported - only plugin tarballs can be verified") + return errors.New("directory verification not supported - only plugin tarballs can be verified") } // Verify it's a tarball if !plugin.IsTarball(o.pluginPath) { - return fmt.Errorf("plugin file must be a gzipped tarball (.tar.gz or .tgz)") + return errors.New("plugin file must be a gzipped tarball (.tar.gz or .tgz)") } // Look for provenance file diff --git a/pkg/cmd/printer.go b/pkg/cmd/printer.go index 30238f5bb..3a3840cfc 100644 --- a/pkg/cmd/printer.go +++ b/pkg/cmd/printer.go @@ -21,7 +21,7 @@ import ( "text/template" ) -func tpl(t string, vals map[string]interface{}, out io.Writer) error { +func tpl(t string, vals map[string]any, out io.Writer) error { tt, err := template.New("_").Parse(t) if err != nil { return err diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index bb7a8d1c0..d371db4ad 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -65,7 +65,7 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.Version = ">0.0.0-0" } - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) diff --git a/pkg/cmd/pull_test.go b/pkg/cmd/pull_test.go index 6d5eb1482..511061dcd 100644 --- a/pkg/cmd/pull_test.go +++ b/pkg/cmd/pull_test.go @@ -260,7 +260,6 @@ func TestPullCmd(t *testing.T) { if out != outString { t.Errorf("%q: expected verification output %q, got %q", tt.name, outString, out) } - } ef := filepath.Join(outdir, tt.expectFile) @@ -342,7 +341,7 @@ func runPullTests(t *testing.T, tests []struct { func buildOCIURL(registryURL, chartName, version, username, password string) string { baseURL := fmt.Sprintf("oci://%s/u/ocitestuser/%s", registryURL, chartName) if version != "" { - baseURL += fmt.Sprintf(" --version %s", version) + baseURL += " --version " + version } if username != "" && password != "" { baseURL += fmt.Sprintf(" --username %s --password %s", username, password) @@ -416,23 +415,23 @@ func TestPullVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for pull version flag", - cmd: fmt.Sprintf("%s __complete pull testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for pull version flag, no filter", - cmd: fmt.Sprintf("%s __complete pull testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for pull version flag too few args", - cmd: fmt.Sprintf("%s __complete pull --version ''", repoSetup), + cmd: repoSetup + " __complete pull --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for pull version flag too many args", - cmd: fmt.Sprintf("%s __complete pull testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete pull testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for pull version flag invalid chart", - cmd: fmt.Sprintf("%s __complete pull invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete pull invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/cmd/push.go b/pkg/cmd/push.go index f57a7c52f..f32ce92be 100644 --- a/pkg/cmd/push.go +++ b/pkg/cmd/push.go @@ -62,7 +62,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { var comps []string for _, p := range providers { for _, scheme := range p.Schemes { - comps = append(comps, fmt.Sprintf("%s://", scheme)) + comps = append(comps, scheme+"://") } } return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace @@ -71,7 +71,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, RunE: func(_ *cobra.Command, args []string) error { registryClient, err := newRegistryClient( - o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSVerify, o.plainHTTP, o.username, o.password, + out, o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSVerify, o.plainHTTP, o.username, o.password, ) if err != nil { diff --git a/pkg/cmd/repo_add_test.go b/pkg/cmd/repo_add_test.go index df9451d34..8f8e36d75 100644 --- a/pkg/cmd/repo_add_test.go +++ b/pkg/cmd/repo_add_test.go @@ -130,7 +130,7 @@ func TestRepoAdd(t *testing.T) { } if err := o.run(io.Discard); err != nil { - t.Errorf("Duplicate repository name was added") + t.Error("Duplicate repository name was added") } } @@ -162,7 +162,7 @@ func TestRepoAddCheckLegalName(t *testing.T) { t.Fatalf("Actual error %s, not equal to expected error %s", err, wantErrorMsg) } } else { - t.Fatalf("expect reported an error.") + t.Fatal("expect reported an error.") } } diff --git a/pkg/cmd/repo_index_test.go b/pkg/cmd/repo_index_test.go index c8959f21e..68f105d6d 100644 --- a/pkg/cmd/repo_index_test.go +++ b/pkg/cmd/repo_index_test.go @@ -28,7 +28,6 @@ import ( ) func TestRepoIndexCmd(t *testing.T) { - dir := t.TempDir() comp := filepath.Join(dir, "compressedchart-0.1.0.tgz") diff --git a/pkg/cmd/repo_remove_test.go b/pkg/cmd/repo_remove_test.go index fce15bb73..79778e75e 100644 --- a/pkg/cmd/repo_remove_test.go +++ b/pkg/cmd/repo_remove_test.go @@ -103,7 +103,6 @@ func TestRepoRemove(t *testing.T) { cacheIndex, cacheChart := createCacheFiles(rootDir, repoName) cacheFiles[repoName] = []string{cacheIndex, cacheChart} - } // Create repo remove command @@ -196,15 +195,15 @@ func TestRepoRemoveCompletion(t *testing.T) { // and that port changes each time we run the test. tests := []cmdTestCase{{ name: "completion for repo remove", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove ''", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove ''", golden: "output/repo_list_comp.txt", }, { name: "completion for repo remove, no filter", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove fo", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove fo", golden: "output/repo_list_comp.txt", }, { name: "completion for repo remove repetition", - cmd: fmt.Sprintf("%s __completeNoDesc repo remove foo ''", repoSetup), + cmd: repoSetup + " __completeNoDesc repo remove foo ''", golden: "output/repo_repeat_comp.txt", }} for _, test := range tests { diff --git a/pkg/cmd/require/args_test.go b/pkg/cmd/require/args_test.go index b6c430fc0..89403140e 100644 --- a/pkg/cmd/require/args_test.go +++ b/pkg/cmd/require/args_test.go @@ -16,8 +16,8 @@ limitations under the License. package require import ( - "fmt" "io" + "strconv" "strings" "testing" @@ -65,7 +65,7 @@ type testCase struct { func runTestCases(t *testing.T, testCases []testCase) { t.Helper() for i, tc := range testCases { - t.Run(fmt.Sprint(i), func(t *testing.T) { + t.Run(strconv.Itoa(i), func(t *testing.T) { cmd := &cobra.Command{ Use: "root", Run: func(*cobra.Command, []string) {}, diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 2cabbb8ef..01d8b1866 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -76,7 +76,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } - fmt.Fprintf(out, "Rollback was a success! Happy Helming!\n") + fmt.Fprint(out, "Rollback was a success! Happy Helming!\n") return nil }, } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 04ba91c1f..3203ced25 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -257,7 +257,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg log.Fatal(err) } - registryClient, err := newDefaultRegistryClient(false, "", "") + registryClient, err := newDefaultRegistryClient(out, false, "", "") if err != nil { return nil, err } @@ -355,7 +355,6 @@ func hookOutputWriter(_, _, _ string) io.Writer { } func checkForExpiredRepos(repofile string) { - expiredRepos := []struct { name string old string @@ -399,31 +398,30 @@ func checkForExpiredRepos(repofile string) { ) } } - } func newRegistryClient( - certFile, keyFile, caFile string, insecureSkipTLSVerify, plainHTTP bool, username, password string, + out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSVerify, plainHTTP bool, username, password string, ) (*registry.Client, error) { if certFile != "" && keyFile != "" || caFile != "" || insecureSkipTLSVerify { - registryClient, err := newRegistryClientWithTLS(certFile, keyFile, caFile, insecureSkipTLSVerify, username, password) + registryClient, err := newRegistryClientWithTLS(out, certFile, keyFile, caFile, insecureSkipTLSVerify, username, password) if err != nil { return nil, err } return registryClient, nil } - registryClient, err := newDefaultRegistryClient(plainHTTP, username, password) + registryClient, err := newDefaultRegistryClient(out, plainHTTP, username, password) if err != nil { return nil, err } return registryClient, nil } -func newDefaultRegistryClient(plainHTTP bool, username, password string) (*registry.Client, error) { +func newDefaultRegistryClient(out io.Writer, plainHTTP bool, username, password string) (*registry.Client, error) { opts := []registry.ClientOption{ registry.ClientOptDebug(settings.Debug), registry.ClientOptEnableCache(true), - registry.ClientOptWriter(os.Stderr), + registry.ClientOptWriter(out), registry.ClientOptCredentialsFile(settings.RegistryConfig), registry.ClientOptBasicAuth(username, password), } @@ -440,7 +438,7 @@ func newDefaultRegistryClient(plainHTTP bool, username, password string) (*regis } func newRegistryClientWithTLS( - certFile, keyFile, caFile string, insecureSkipTLSVerify bool, username, password string, + out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSVerify bool, username, password string, ) (*registry.Client, error) { tlsConf, err := tlsutil.NewTLSConfig( tlsutil.WithInsecureSkipVerify(insecureSkipTLSVerify), @@ -456,7 +454,7 @@ func newRegistryClientWithTLS( registryClient, err := registry.NewClient( registry.ClientOptDebug(settings.Debug), registry.ClientOptEnableCache(true), - registry.ClientOptWriter(os.Stderr), + registry.ClientOptWriter(out), registry.ClientOptCredentialsFile(settings.RegistryConfig), registry.ClientOptHTTPClient(&http.Client{ Transport: &http.Transport{ diff --git a/pkg/cmd/search.go b/pkg/cmd/search.go index 4d110286d..eb6c6e0b5 100644 --- a/pkg/cmd/search.go +++ b/pkg/cmd/search.go @@ -29,7 +29,6 @@ Use search subcommands to search different locations for charts. ` func newSearchCmd(out io.Writer) *cobra.Command { - cmd := &cobra.Command{ Use: "search [keyword]", Short: "search for a keyword in charts", diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go index 1c7bb1d06..cb630709f 100644 --- a/pkg/cmd/search/search.go +++ b/pkg/cmd/search/search.go @@ -123,7 +123,6 @@ func (i *Index) Search(term string, threshold int, regexp bool) ([]*Result, erro // calcScore calculates a score for a match. func (i *Index) calcScore(index int, matchline string) int { - // This is currently tied to the fact that sep is a single char. splits := []int{} s := rune(sep[0]) diff --git a/pkg/cmd/search/search_test.go b/pkg/cmd/search/search_test.go index b3220394f..46394e77f 100644 --- a/pkg/cmd/search/search_test.go +++ b/pkg/cmd/search/search_test.go @@ -149,7 +149,6 @@ func TestAddRepo_Sort(t *testing.T) { } func TestSearchByName(t *testing.T) { - tests := []struct { name string query string @@ -245,7 +244,6 @@ func TestSearchByName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - charts, err := i.Search(tt.query, 100, tt.regexp) if err != nil { if tt.fail { @@ -274,7 +272,6 @@ func TestSearchByName(t *testing.T) { t.Errorf("[%d]: Expected name %q, got %q", i, ex.Name, got.Name) } } - }) } } diff --git a/pkg/cmd/search_hub.go b/pkg/cmd/search_hub.go index 1fde69394..3f7afa3f4 100644 --- a/pkg/cmd/search_hub.go +++ b/pkg/cmd/search_hub.go @@ -17,6 +17,8 @@ limitations under the License. package cmd import ( + "context" + "errors" "fmt" "io" "log/slog" @@ -64,8 +66,8 @@ func newSearchHubCmd(out io.Writer) *cobra.Command { Use: "hub [KEYWORD]", Short: "search for charts in the Artifact Hub or your own hub instance", Long: searchHubDesc, - RunE: func(_ *cobra.Command, args []string) error { - return o.run(out, args) + RunE: func(c *cobra.Command, args []string) error { + return o.run(c.Context(), out, args) }, } @@ -80,14 +82,14 @@ func newSearchHubCmd(out io.Writer) *cobra.Command { return cmd } -func (o *searchHubOptions) run(out io.Writer, args []string) error { +func (o *searchHubOptions) run(ctx context.Context, out io.Writer, args []string) error { c, err := monocular.New(o.searchEndpoint) if err != nil { return fmt.Errorf("unable to create connection to %q: %w", o.searchEndpoint, err) } q := strings.Join(args, " ") - results, err := c.Search(q) + results, err := c.SearchWithContext(ctx, q) if err != nil { slog.Debug("search failed", slog.Any("error", err)) return fmt.Errorf("unable to perform search against %q", o.searchEndpoint) @@ -136,7 +138,7 @@ func (h *hubSearchWriter) WriteTable(out io.Writer) error { if len(h.elements) == 0 { // Fail if no results found and --fail-on-no-result is enabled if h.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } _, err := out.Write([]byte("No results found\n")) @@ -175,7 +177,7 @@ func (h *hubSearchWriter) WriteYAML(out io.Writer) error { func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { // Fail if no results found and --fail-on-no-result is enabled if len(h.elements) == 0 && h.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } // Initialize the array so no results returns an empty array instead of null @@ -195,5 +197,4 @@ func (h *hubSearchWriter) encodeByFormat(out io.Writer, format output.Format) er // WriteJSON and WriteYAML, we shouldn't get invalid types return nil } - } diff --git a/pkg/cmd/search_hub_test.go b/pkg/cmd/search_hub_test.go index 8e056f771..98bf052f2 100644 --- a/pkg/cmd/search_hub_test.go +++ b/pkg/cmd/search_hub_test.go @@ -24,7 +24,6 @@ import ( ) func TestSearchHubCmd(t *testing.T) { - // Setup a mock search service var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -54,7 +53,6 @@ func TestSearchHubCmd(t *testing.T) { } func TestSearchHubListRepoCmd(t *testing.T) { - // Setup a mock search service var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"stable","url":"https://charts.helm.sh/stable"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/stable/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T17:57:31.38Z","digest":"119c499251bffd4b06ff0cd5ac98c2ce32231f84899fb4825be6c2d90971c742","urls":["https://charts.helm.sh/stable/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/stable/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/stable/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/stable/phpmyadmin/versions/3.0.0"}}}},{"id":"bitnami/phpmyadmin","type":"chart","attributes":{"name":"phpmyadmin","repo":{"name":"bitnami","url":"https://charts.bitnami.com"},"description":"phpMyAdmin is an mysql administration frontend","home":"https://www.phpmyadmin.net/","keywords":["mariadb","mysql","phpmyadmin"],"maintainers":[{"name":"Bitnami","email":"containers@bitnami.com"}],"sources":["https://github.com/bitnami/bitnami-docker-phpmyadmin"],"icon":""},"links":{"self":"/v1/charts/bitnami/phpmyadmin"},"relationships":{"latestChartVersion":{"data":{"version":"3.0.0","app_version":"4.9.0-1","created":"2019-08-08T18:34:13.341Z","digest":"66d77cf6d8c2b52c488d0a294cd4996bd5bad8dc41d3829c394498fb401c008a","urls":["https://charts.bitnami.com/bitnami/phpmyadmin-3.0.0.tgz"],"readme":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/README.md","values":"/v1/assets/bitnami/phpmyadmin/versions/3.0.0/values.yaml"},"links":{"self":"/v1/charts/bitnami/phpmyadmin/versions/3.0.0"}}}}]}` ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -168,7 +166,7 @@ func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) { _, out, err := executeActionCommandC(storage, tt.cmd) if tt.wantErr { if err == nil { - t.Errorf("expected error due to no record in response, got nil") + t.Error("expected error due to no record in response, got nil") } } else { if err != nil { diff --git a/pkg/cmd/search_repo.go b/pkg/cmd/search_repo.go index 68c7543ab..53626f1b6 100644 --- a/pkg/cmd/search_repo.go +++ b/pkg/cmd/search_repo.go @@ -216,7 +216,7 @@ func (r *repoSearchWriter) WriteTable(out io.Writer) error { if len(r.results) == 0 { // Fail if no results found and --fail-on-no-result is enabled if r.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } _, err := out.Write([]byte("No results found\n")) @@ -245,7 +245,7 @@ func (r *repoSearchWriter) WriteYAML(out io.Writer) error { func (r *repoSearchWriter) encodeByFormat(out io.Writer, format output.Format) error { // Fail if no results found and --fail-on-no-result is enabled if len(r.results) == 0 && r.failOnNoResult { - return fmt.Errorf("no results found") + return errors.New("no results found") } // Initialize the array so no results returns an empty array instead of null @@ -307,7 +307,7 @@ func compListChartsOfRepo(repoName string, prefix string) []string { // Provide dynamic auto-completion for commands that operate on charts (e.g., helm show) // When true, the includeFiles argument indicates that completion should include local files (e.g., local charts) func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.ShellCompDirective) { - cobra.CompDebugln(fmt.Sprintf("compListCharts with toComplete %s", toComplete), settings.Debug) + cobra.CompDebugln("compListCharts with toComplete "+toComplete, settings.Debug) noSpace := false noFile := false @@ -323,7 +323,7 @@ func compListCharts(toComplete string, includeFiles bool) ([]string, cobra.Shell if len(repoInfo) > 1 { repoDesc = repoInfo[1] } - repoWithSlash := fmt.Sprintf("%s/", repo) + repoWithSlash := repo + "/" if strings.HasPrefix(toComplete, repoWithSlash) { // Must complete with charts within the specified repo. // Don't filter on toComplete to allow for shell fuzzy matching diff --git a/pkg/cmd/show.go b/pkg/cmd/show.go index d7249c3fe..a92ca75b4 100644 --- a/pkg/cmd/show.go +++ b/pkg/cmd/show.go @@ -84,7 +84,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: validArgsFunc, RunE: func(_ *cobra.Command, args []string) error { client.OutputFormat = action.ShowAll - err := addRegistryClient(client) + err := addRegistryClient(out, client) if err != nil { return err } @@ -105,7 +105,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: validArgsFunc, RunE: func(_ *cobra.Command, args []string) error { client.OutputFormat = action.ShowValues - err := addRegistryClient(client) + err := addRegistryClient(out, client) if err != nil { return err } @@ -126,7 +126,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: validArgsFunc, RunE: func(_ *cobra.Command, args []string) error { client.OutputFormat = action.ShowChart - err := addRegistryClient(client) + err := addRegistryClient(out, client) if err != nil { return err } @@ -147,7 +147,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: validArgsFunc, RunE: func(_ *cobra.Command, args []string) error { client.OutputFormat = action.ShowReadme - err := addRegistryClient(client) + err := addRegistryClient(out, client) if err != nil { return err } @@ -168,7 +168,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { ValidArgsFunction: validArgsFunc, RunE: func(_ *cobra.Command, args []string) error { client.OutputFormat = action.ShowCRDs - err := addRegistryClient(client) + err := addRegistryClient(out, client) if err != nil { return err } @@ -225,8 +225,8 @@ func runShow(args []string, client *action.Show) (string, error) { return client.Run(cp) } -func addRegistryClient(client *action.Show) error { - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, +func addRegistryClient(out io.Writer, client *action.Show) error { + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) diff --git a/pkg/cmd/show_test.go b/pkg/cmd/show_test.go index ff3671dbc..5da2626d3 100644 --- a/pkg/cmd/show_test.go +++ b/pkg/cmd/show_test.go @@ -99,35 +99,35 @@ func TestShowVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for show version flag", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag, no filter", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for show version flag too few args", - cmd: fmt.Sprintf("%s __complete show chart --version ''", repoSetup), + cmd: repoSetup + " __complete show chart --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag too many args", - cmd: fmt.Sprintf("%s __complete show chart testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete show chart testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag invalid chart", - cmd: fmt.Sprintf("%s __complete show chart invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete show chart invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for show version flag with all", - cmd: fmt.Sprintf("%s __complete show all testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show all testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag with readme", - cmd: fmt.Sprintf("%s __complete show readme testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show readme testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for show version flag with values", - cmd: fmt.Sprintf("%s __complete show values testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete show values testing/alpine --version ''", golden: "output/version-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index f68316c6c..705691aaf 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -197,9 +197,9 @@ func (s statusPrinter) WriteTable(out io.Writer) error { } _, _ = fmt.Fprintf(out, "TEST SUITE: %s\n%s\n%s\n%s\n", h.Name, - fmt.Sprintf("Last Started: %s", h.LastRun.StartedAt.Format(time.ANSIC)), - fmt.Sprintf("Last Completed: %s", h.LastRun.CompletedAt.Format(time.ANSIC)), - fmt.Sprintf("Phase: %s", h.LastRun.Phase), + "Last Started: "+h.LastRun.StartedAt.Format(time.ANSIC), + "Last Completed: "+h.LastRun.CompletedAt.Format(time.ANSIC), + "Phase: "+h.LastRun.Phase, ) } } diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index 047fd60df..bb364231b 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -85,7 +85,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.KubeVersion = parsedKubeVersion } - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) @@ -113,6 +113,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } return err } + installErr := err // We ignore a potential error here because, when the --debug flag was specified, // we always want to print the YAML, even if it is not valid. The error is still returned afterwards. @@ -142,7 +143,6 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } } - } } @@ -187,6 +187,10 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { missing = false } if missing { + if installErr != nil && settings.Debug { + // assume the manifest itself is too malformed to be rendered + return installErr + } return fmt.Errorf("could not find template %s in chart", f) } } @@ -198,7 +202,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } } - return err + return installErr }, } diff --git a/pkg/cmd/template_test.go b/pkg/cmd/template_test.go index 5bcccf5d0..b43151413 100644 --- a/pkg/cmd/template_test.go +++ b/pkg/cmd/template_test.go @@ -133,6 +133,12 @@ func TestTemplateCmd(t *testing.T) { wantError: true, golden: "output/template-with-invalid-yaml-debug.txt", }, + { + name: "chart with template with invalid template expression (--debug, --show-only)", + cmd: fmt.Sprintf("template '%s' --debug --show-only %s", "testdata/testcharts/chart-with-template-with-invalid-template-expr", "templates/alpine-pod.yaml"), + wantError: true, + golden: "output/template-with-invalid-template-expr-debug-show-only.txt", + }, { name: "template skip-tests", cmd: fmt.Sprintf(`template '%s' --skip-tests`, chartPath), @@ -178,23 +184,23 @@ func TestTemplateVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for template version flag with release name", - cmd: fmt.Sprintf("%s __complete template releasename testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete template releasename testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for template version flag with generate-name", - cmd: fmt.Sprintf("%s __complete template --generate-name testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete template --generate-name testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for template version flag too few args", - cmd: fmt.Sprintf("%s __complete template testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete template testing/alpine --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for template version flag too many args", - cmd: fmt.Sprintf("%s __complete template releasename testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete template releasename testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for template version flag invalid chart", - cmd: fmt.Sprintf("%s __complete template releasename invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete template releasename invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }} runTestCmd(t, tests) diff --git a/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml index a58544b03..c99e5122b 100644 --- a/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml +++ b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: fullenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml index 4156e7f17..24d79ac7e 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -1,4 +1,5 @@ name: args +version: 0.1.0 type: cli/v1 apiVersion: v1 runtime: subprocess diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml index a0a0b5255..a707c3373 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -1,4 +1,5 @@ name: echo +version: 0.1.0 type: cli/v1 apiVersion: v1 runtime: subprocess diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml index ba9508255..93930219b 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: exitwith +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml index a58544b03..c99e5122b 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: fullenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml new file mode 100644 index 000000000..70c356dea --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/noversion/plugin.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +name: noversion +type: cli/v1 +runtime: subprocess +runtimeConfig: + platformCommand: + - command: "echo hello" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml index 5fe053ed0..3f935db4b 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/shortenv/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: shortenv +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt b/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt new file mode 100644 index 000000000..8861b7572 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback-no-flag.txt @@ -0,0 +1,4 @@ +REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 Rollback to 1 diff --git a/pkg/cmd/testdata/output/history-with-rollback.json b/pkg/cmd/testdata/output/history-with-rollback.json new file mode 100644 index 000000000..b4175b062 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.json @@ -0,0 +1 @@ +[{"revision":1,"updated":"1977-09-02T22:04:05Z","status":"superseded","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Install complete"},{"revision":2,"updated":"1977-09-02T22:04:05Z","status":"superseded","chart":"foo-0.1.0-beta.1","app_version":"1.0","description":"Upgrade complete"},{"revision":3,"updated":"1977-09-02T22:04:05Z","status":"deployed","chart":"foo-0.1.0-beta.1","app_version":"1.0","rollback_revision":1,"description":"Rollback to 1"}] diff --git a/pkg/cmd/testdata/output/history-with-rollback.txt b/pkg/cmd/testdata/output/history-with-rollback.txt new file mode 100644 index 000000000..26dd210af --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.txt @@ -0,0 +1,4 @@ +REVISION UPDATED STATUS CHART APP VERSION ROLLBACK DESCRIPTION +1 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Install complete +2 Fri Sep 2 22:04:05 1977 superseded foo-0.1.0-beta.1 1.0 Upgrade complete +3 Fri Sep 2 22:04:05 1977 deployed foo-0.1.0-beta.1 1.0 1 Rollback to 1 diff --git a/pkg/cmd/testdata/output/history-with-rollback.yaml b/pkg/cmd/testdata/output/history-with-rollback.yaml new file mode 100644 index 000000000..19dc681c4 --- /dev/null +++ b/pkg/cmd/testdata/output/history-with-rollback.yaml @@ -0,0 +1,19 @@ +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Install complete + revision: 1 + status: superseded + updated: "1977-09-02T22:04:05Z" +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Upgrade complete + revision: 2 + status: superseded + updated: "1977-09-02T22:04:05Z" +- app_version: "1.0" + chart: foo-0.1.0-beta.1 + description: Rollback to 1 + revision: 3 + rollback_revision: 1 + status: deployed + updated: "1977-09-02T22:04:05Z" diff --git a/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt b/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt index eb770967f..c2219d8c4 100644 --- a/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt +++ b/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt @@ -19,3 +19,4 @@ metadata: data: foo: bar + diff --git a/pkg/cmd/testdata/output/install-dry-run-with-secret.txt b/pkg/cmd/testdata/output/install-dry-run-with-secret.txt index d22c1437f..62bd78018 100644 --- a/pkg/cmd/testdata/output/install-dry-run-with-secret.txt +++ b/pkg/cmd/testdata/output/install-dry-run-with-secret.txt @@ -15,6 +15,7 @@ metadata: name: test-secret stringData: foo: bar + --- # Source: chart-with-secret/templates/configmap.yaml apiVersion: v1 @@ -24,3 +25,4 @@ metadata: data: foo: bar + diff --git a/pkg/cmd/testdata/output/issue-9027.txt b/pkg/cmd/testdata/output/issue-9027.txt index eb19fc383..f43032499 100644 --- a/pkg/cmd/testdata/output/issue-9027.txt +++ b/pkg/cmd/testdata/output/issue-9027.txt @@ -2,30 +2,37 @@ # Source: issue-9027/charts/subchart/templates/values.yaml global: hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 + --- # Source: issue-9027/templates/values.yaml global: hash: - key1: null - key2: null key3: 13 subchart: global: hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 key6: 6 hash: + key1: 1 + key2: 2 key3: 13 key4: 4 key5: 5 diff --git a/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt index a1ec4394e..c25efc1df 100644 --- a/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt +++ b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt @@ -1,5 +1,5 @@ ==> Linting testdata/testcharts/chart-with-deprecated-api [INFO] Chart.yaml: icon is recommended -[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler +[WARNING] templates/poddisruptionbudget.yaml: policy/v1beta1 PodDisruptionBudget is deprecated in v1.21+, unavailable in v1.25+; use policy/v1 PodDisruptionBudget Error: 1 chart(s) linted, 1 chart(s) failed diff --git a/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt index dac54620c..08dbde95d 100644 --- a/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt +++ b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt @@ -1,5 +1,5 @@ ==> Linting testdata/testcharts/chart-with-deprecated-api [INFO] Chart.yaml: icon is recommended -[WARNING] templates/horizontalpodautoscaler.yaml: autoscaling/v2beta1 HorizontalPodAutoscaler is deprecated in v1.22+, unavailable in v1.25+; use autoscaling/v2 HorizontalPodAutoscaler +[WARNING] templates/poddisruptionbudget.yaml: policy/v1beta1 PodDisruptionBudget is deprecated in v1.21+, unavailable in v1.25+; use policy/v1 PodDisruptionBudget 1 chart(s) linted, 0 chart(s) failed diff --git a/pkg/cmd/testdata/output/object-order.txt b/pkg/cmd/testdata/output/object-order.txt index 307f928f2..1ff39f33c 100644 --- a/pkg/cmd/testdata/output/object-order.txt +++ b/pkg/cmd/testdata/output/object-order.txt @@ -155,6 +155,7 @@ spec: policyTypes: - Egress - Ingress + --- # Source: object-order/templates/01-a.yml # 4 (Deployment should come after all NetworkPolicy manifests, since 'helm template' outputs in install order) diff --git a/pkg/cmd/testdata/output/template-name-template.txt b/pkg/cmd/testdata/output/template-name-template.txt index 9406048dd..b1077012e 100644 --- a/pkg/cmd/testdata/output/template-name-template.txt +++ b/pkg/cmd/testdata/output/template-name-template.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -93,6 +98,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -112,3 +118,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-set.txt b/pkg/cmd/testdata/output/template-set.txt index 4040991cf..1ecb8707b 100644 --- a/pkg/cmd/testdata/output/template-set.txt +++ b/pkg/cmd/testdata/output/template-set.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -93,6 +98,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -112,3 +118,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-skip-tests.txt b/pkg/cmd/testdata/output/template-skip-tests.txt index 5c907b563..4c5af8df3 100644 --- a/pkg/cmd/testdata/output/template-skip-tests.txt +++ b/pkg/cmd/testdata/output/template-skip-tests.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 diff --git a/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt b/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt index 56844e292..227d05903 100644 --- a/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt +++ b/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/configmap.yaml apiVersion: v1 @@ -22,6 +23,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -36,6 +38,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -53,6 +56,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -70,6 +74,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -101,6 +106,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -120,3 +126,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-subchart-cm-set.txt b/pkg/cmd/testdata/output/template-subchart-cm-set.txt index e52f7c234..dd8be4db9 100644 --- a/pkg/cmd/testdata/output/template-subchart-cm-set.txt +++ b/pkg/cmd/testdata/output/template-subchart-cm-set.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/configmap.yaml apiVersion: v1 @@ -22,6 +23,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -36,6 +38,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -53,6 +56,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -70,6 +74,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -101,6 +106,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -120,3 +126,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-subchart-cm.txt b/pkg/cmd/testdata/output/template-subchart-cm.txt index 9cc9e2296..c4600a798 100644 --- a/pkg/cmd/testdata/output/template-subchart-cm.txt +++ b/pkg/cmd/testdata/output/template-subchart-cm.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/configmap.yaml apiVersion: v1 @@ -22,6 +23,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -36,6 +38,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -53,6 +56,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -70,6 +74,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -101,6 +106,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -120,3 +126,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-values-files.txt b/pkg/cmd/testdata/output/template-values-files.txt index 4040991cf..1ecb8707b 100644 --- a/pkg/cmd/testdata/output/template-values-files.txt +++ b/pkg/cmd/testdata/output/template-values-files.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -93,6 +98,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -112,3 +118,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-with-api-version.txt b/pkg/cmd/testdata/output/template-with-api-version.txt index 8b6074cdb..ae726e624 100644 --- a/pkg/cmd/testdata/output/template-with-api-version.txt +++ b/pkg/cmd/testdata/output/template-with-api-version.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -95,6 +100,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -114,3 +120,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-with-crds.txt b/pkg/cmd/testdata/output/template-with-crds.txt index 256fc7c3b..1d63265ec 100644 --- a/pkg/cmd/testdata/output/template-with-crds.txt +++ b/pkg/cmd/testdata/output/template-with-crds.txt @@ -21,6 +21,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -31,6 +32,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -45,6 +47,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -62,6 +65,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -79,6 +83,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -110,6 +115,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -129,3 +135,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template-with-invalid-template-expr-debug-show-only.txt b/pkg/cmd/testdata/output/template-with-invalid-template-expr-debug-show-only.txt new file mode 100644 index 000000000..89d518bbb --- /dev/null +++ b/pkg/cmd/testdata/output/template-with-invalid-template-expr-debug-show-only.txt @@ -0,0 +1,3 @@ +Error: chart-with-template-with-invalid-template-expr/templates/alpine-pod.yaml:7:38 + executing "chart-with-template-with-invalid-template-expr/templates/alpine-pod.yaml" at : + invalid value; expected string diff --git a/pkg/cmd/testdata/output/template-with-kube-version.txt b/pkg/cmd/testdata/output/template-with-kube-version.txt index 9d326f328..2c42e2e84 100644 --- a/pkg/cmd/testdata/output/template-with-kube-version.txt +++ b/pkg/cmd/testdata/output/template-with-kube-version.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -93,6 +98,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -112,3 +118,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/template.txt b/pkg/cmd/testdata/output/template.txt index 58c480b47..ddbfebe9d 100644 --- a/pkg/cmd/testdata/output/template.txt +++ b/pkg/cmd/testdata/output/template.txt @@ -4,6 +4,7 @@ apiVersion: v1 kind: ServiceAccount metadata: name: subchart-sa + --- # Source: subchart/templates/subdir/role.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -14,6 +15,7 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","list","watch"] + --- # Source: subchart/templates/subdir/rolebinding.yaml apiVersion: rbac.authorization.k8s.io/v1 @@ -28,6 +30,7 @@ subjects: - kind: ServiceAccount name: subchart-sa namespace: default + --- # Source: subchart/charts/subcharta/templates/service.yaml apiVersion: v1 @@ -45,6 +48,7 @@ spec: name: apache selector: app.kubernetes.io/name: subcharta + --- # Source: subchart/charts/subchartb/templates/service.yaml apiVersion: v1 @@ -62,6 +66,7 @@ spec: name: nginx selector: app.kubernetes.io/name: subchartb + --- # Source: subchart/templates/service.yaml apiVersion: v1 @@ -93,6 +98,7 @@ metadata: "helm.sh/hook": test data: message: Hello World + --- # Source: subchart/templates/tests/test-nothing.yaml apiVersion: v1 @@ -112,3 +118,4 @@ spec: - echo - "$message" restartPolicy: Never + diff --git a/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt b/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt index b2c154a80..cb0a3a167 100644 --- a/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt +++ b/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt @@ -1 +1 @@ -Error: an error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 +Error: an error occurred while checking for chart dependencies. You may need to run 'helm dependency build' to fetch missing dependencies: found in Chart.yaml, but missing in charts/ directory: reqsubchart2 diff --git a/pkg/cmd/testdata/output/version-short.txt b/pkg/cmd/testdata/output/version-short.txt index 8cf4318fb..2fa2c5705 100644 --- a/pkg/cmd/testdata/output/version-short.txt +++ b/pkg/cmd/testdata/output/version-short.txt @@ -1 +1 @@ -v4.1 +v4.2 diff --git a/pkg/cmd/testdata/output/version-template.txt b/pkg/cmd/testdata/output/version-template.txt index 8fd8b4962..8f2491e54 100644 --- a/pkg/cmd/testdata/output/version-template.txt +++ b/pkg/cmd/testdata/output/version-template.txt @@ -1 +1 @@ -Version: v4.1 \ No newline at end of file +Version: v4.2 \ No newline at end of file diff --git a/pkg/cmd/testdata/output/version.txt b/pkg/cmd/testdata/output/version.txt index 1f4cf4d4a..331c6d5e9 100644 --- a/pkg/cmd/testdata/output/version.txt +++ b/pkg/cmd/testdata/output/version.txt @@ -1 +1 @@ -version.BuildInfo{Version:"v4.1", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} +version.BuildInfo{Version:"v4.2", GitCommit:"", GitTreeState:"", GoVersion:"", KubeClientVersion:"v1.20"} diff --git a/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml deleted file mode 100644 index b77a4beeb..000000000 --- a/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: autoscaling/v2beta1 -kind: HorizontalPodAutoscaler -metadata: - name: deprecated -spec: - scaleTargetRef: - kind: Pod - name: pod - maxReplicas: 3 \ No newline at end of file diff --git a/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml new file mode 100644 index 000000000..214d3cb68 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/poddisruptionbudget.yaml @@ -0,0 +1,9 @@ +apiVersion: policy/v1beta1 +kind: PodDisruptionBudget +metadata: + name: deprecated +spec: + maxUnavailable: 1 + selector: + matchLabels: + app: deprecated diff --git a/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/Chart.yaml new file mode 100644 index 000000000..83541d4dd --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +description: Deploy a basic Alpine Linux pod +home: https://helm.sh/helm +name: chart-with-template-with-invalid-template-expr +sources: + - https://github.com/helm/helm +version: 0.1.0 +type: application diff --git a/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/README.md b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/README.md new file mode 100644 index 000000000..fcf7ee017 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/README.md @@ -0,0 +1,13 @@ +#Alpine: A simple Helm chart + +Run a single pod of Alpine Linux. + +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install ./alpine`. diff --git a/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/templates/alpine-pod.yaml new file mode 100644 index 000000000..bdc0fe39b --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/templates/alpine-pod.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{.Release.Name}}-{{.Values.Name}}" +spec: + containers: + - name: {{ .Values.nonExistentKey | b64enc }} + image: "alpine:3.9" + command: ["/bin/sleep","9000"] diff --git a/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/values.yaml new file mode 100644 index 000000000..807e12aea --- /dev/null +++ b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-template-expr/values.yaml @@ -0,0 +1 @@ +Name: my-alpine diff --git a/pkg/cmd/testdata/testplugin/plugin.yaml b/pkg/cmd/testdata/testplugin/plugin.yaml index 3ee5d04f6..fb1d82062 100644 --- a/pkg/cmd/testdata/testplugin/plugin.yaml +++ b/pkg/cmd/testdata/testplugin/plugin.yaml @@ -1,6 +1,7 @@ --- apiVersion: v1 name: testplugin +version: 0.1.0 type: cli/v1 runtime: subprocess config: diff --git a/pkg/cmd/uninstall.go b/pkg/cmd/uninstall.go index 49f7bd19d..bfecd4741 100644 --- a/pkg/cmd/uninstall.go +++ b/pkg/cmd/uninstall.go @@ -59,7 +59,6 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return validationErr } for i := range args { - res, err := client.Run(args[i]) if err != nil { return err diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index b71c4ae2d..9ec4f4ef2 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -105,7 +105,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { client.Namespace = settings.Namespace() - registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile, + registryClient, err := newRegistryClient(out, client.CertFile, client.KeyFile, client.CaFile, client.InsecureSkipTLSVerify, client.PlainHTTP, client.Username, client.Password) if err != nil { return fmt.Errorf("missing registry client: %w", err) @@ -205,7 +205,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } 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) + 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 { man := &downloader.Manager{ Out: out, diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index 0ae1e3561..30188d3d9 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -34,7 +34,6 @@ import ( ) func TestUpgradeCmd(t *testing.T) { - tmpChart := t.TempDir() cfile := &chart.Chart{ Metadata: &chart.Metadata{ @@ -149,7 +148,7 @@ func TestUpgradeCmd(t *testing.T) { }, { name: "upgrade a release with missing dependencies", - cmd: fmt.Sprintf("upgrade bonkers-bunny %s", missingDepsPath), + cmd: "upgrade bonkers-bunny " + missingDepsPath, golden: "output/upgrade-with-missing-dependencies.txt", wantError: true, }, @@ -161,7 +160,7 @@ func TestUpgradeCmd(t *testing.T) { }, { name: "upgrade a release with resolving missing dependencies", - cmd: fmt.Sprintf("upgrade --dependency-update funny-bunny %s", presentDepsPath), + cmd: "upgrade --dependency-update funny-bunny " + presentDepsPath, golden: "output/upgrade-with-dependency-update.txt", rels: []*release.Release{relMock("funny-bunny", 2, ch2)}, }, @@ -222,7 +221,6 @@ func TestUpgradeWithValue(t *testing.T) { if !strings.Contains(updatedRel.Manifest, "drink: tea") { t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) } - } func TestUpgradeWithStringValue(t *testing.T) { @@ -253,11 +251,9 @@ func TestUpgradeWithStringValue(t *testing.T) { if !strings.Contains(updatedRel.Manifest, "drink: coffee") { t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) } - } func TestUpgradeInstallWithSubchartNotes(t *testing.T) { - releaseName := "wacky-bunny-v1" relMock, ch, _ := prepareMockRelease(t, releaseName) @@ -289,11 +285,9 @@ func TestUpgradeInstallWithSubchartNotes(t *testing.T) { if !strings.Contains(upgradedRel.Info.Notes, "SUBCHART NOTES") { t.Errorf("The subchart notes are not set correctly. NOTES: %s", upgradedRel.Info.Notes) } - } func TestUpgradeWithValuesFile(t *testing.T) { - releaseName := "funny-bunny-v4" relMock, ch, chartPath := prepareMockRelease(t, releaseName) @@ -321,11 +315,9 @@ func TestUpgradeWithValuesFile(t *testing.T) { if !strings.Contains(updatedRel.Manifest, "drink: beer") { t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) } - } func TestUpgradeWithValuesFromStdin(t *testing.T) { - releaseName := "funny-bunny-v5" relMock, ch, chartPath := prepareMockRelease(t, releaseName) @@ -361,7 +353,6 @@ func TestUpgradeWithValuesFromStdin(t *testing.T) { } func TestUpgradeInstallWithValuesFromStdin(t *testing.T) { - releaseName := "funny-bunny-v6" _, _, chartPath := prepareMockRelease(t, releaseName) @@ -392,7 +383,6 @@ func TestUpgradeInstallWithValuesFromStdin(t *testing.T) { if !strings.Contains(updatedRel.Manifest, "drink: beer") { t.Errorf("The value is not set correctly. manifest: %s", updatedRel.Manifest) } - } func prepareMockRelease(t *testing.T, releaseName string) (func(n string, v int, ch *chart.Chart) *release.Release, *chart.Chart, string) { @@ -443,23 +433,23 @@ func TestUpgradeVersionCompletion(t *testing.T) { tests := []cmdTestCase{{ name: "completion for upgrade version flag", - cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine --version ''", repoSetup), + cmd: repoSetup + " __complete upgrade releasename testing/alpine --version ''", golden: "output/version-comp.txt", }, { name: "completion for upgrade version flag, no filter", - cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine --version 0.3", repoSetup), + cmd: repoSetup + " __complete upgrade releasename testing/alpine --version 0.3", golden: "output/version-comp.txt", }, { name: "completion for upgrade version flag too few args", - cmd: fmt.Sprintf("%s __complete upgrade releasename --version ''", repoSetup), + cmd: repoSetup + " __complete upgrade releasename --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for upgrade version flag too many args", - cmd: fmt.Sprintf("%s __complete upgrade releasename testing/alpine badarg --version ''", repoSetup), + cmd: repoSetup + " __complete upgrade releasename testing/alpine badarg --version ''", golden: "output/version-invalid-comp.txt", }, { name: "completion for upgrade version flag invalid chart", - cmd: fmt.Sprintf("%s __complete upgrade releasename invalid/invalid --version ''", repoSetup), + cmd: repoSetup + " __complete upgrade releasename invalid/invalid --version ''", golden: "output/version-invalid-comp.txt", }} runTestCmd(t, tests) @@ -636,7 +626,7 @@ func TestUpgradeInstallServerSideApply(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { store := storageFixture() - releaseName := fmt.Sprintf("ssa-test-%s", tt.expectedApplyMethod) + releaseName := "ssa-test-" + tt.expectedApplyMethod cmd := fmt.Sprintf("upgrade %s --install %s '%s'", releaseName, tt.serverSideFlag, chartPath) _, _, err := executeActionCommandC(store, cmd) diff --git a/pkg/cmd/verify_test.go b/pkg/cmd/verify_test.go index ae373afd2..050d799c9 100644 --- a/pkg/cmd/verify_test.go +++ b/pkg/cmd/verify_test.go @@ -22,7 +22,6 @@ import ( ) func TestVerifyCmd(t *testing.T) { - statExe := "stat" statPathMsg := "no such file or directory" statFileMsg := statPathMsg diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 80fb0d712..1099d46ea 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -30,7 +30,7 @@ import ( const versionDesc = ` Show the version for Helm. -This will print a representation the version of Helm. +This will print a representation of the version of Helm. The output will look something like this: version.BuildInfo{Version:"v3.2.1", GitCommit:"fe51cd1e31e6a202cba7dead9552a6d418ded79a", GitTreeState:"clean", GoVersion:"go1.13.10"} diff --git a/pkg/downloader/cache.go b/pkg/downloader/cache.go index cecfc8bd7..92d477e49 100644 --- a/pkg/downloader/cache.go +++ b/pkg/downloader/cache.go @@ -17,6 +17,7 @@ package downloader import ( "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -58,15 +59,17 @@ func (c *DiskCache) Get(key [sha256.Size]byte, cacheType string) (string, error) if err != nil { return "", err } - // Empty files treated as not exist because there is no content. - if fi.Size() == 0 { - return p, os.ErrNotExist - } // directories should never happen unless something outside helm is operating // on this content. if fi.IsDir() { return p, errors.New("is a directory") } + // Empty files are treated as non-existent because there is no content. + // IsDir must be checked first: some filesystems (e.g. overlayfs) report + // directory size as 0. + if fi.Size() == 0 { + return p, os.ErrNotExist + } return p, nil } @@ -85,5 +88,5 @@ func (c *DiskCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) // fileName generates the filename in a structured manner where the first part is the // directory and the full hash is the filename. func (c *DiskCache) fileName(id [sha256.Size]byte, cacheType string) string { - return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+cacheType) + return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), hex.EncodeToString(id[:])+cacheType) } diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index d21a79335..22c6c71a3 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -282,7 +282,6 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena // If provenance is requested, verify it. ver := &provenance.Verification{} if c.Verify > VerifyNever { - ppth, err := c.Cache.Get(digest32, CacheProv) if err == nil { slog.Debug("found provenance in cache", "id", digestString) @@ -308,7 +307,6 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena } if c.Verify != VerifyLater { - // provenance files pin to a specific name so this needs to be accounted for // when verifying. // Note, this does make an assumption that the name/version is unique to a @@ -597,8 +595,8 @@ func loadRepoConfig(file string) (*repo.File, error) { // stripDigestAlgorithm removes the algorithm prefix (e.g., "sha256:") from a digest string. // If no prefix is present, the original string is returned unchanged. func stripDigestAlgorithm(digest string) string { - if idx := strings.Index(digest, ":"); idx >= 0 { - return digest[idx+1:] + if _, after, ok := strings.Cut(digest, ":"); ok { + return after } return digest } diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 6043fbaaa..ac4f2207e 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -53,7 +53,7 @@ type ErrRepoNotFound struct { // Error implements the error interface. func (e ErrRepoNotFound) Error() string { - return fmt.Sprintf("no repository definition for %s", strings.Join(e.Repos, ", ")) + return "no repository definition for " + strings.Join(e.Repos, ", ") } // Manager handles the lifecycle of fetching, resolving, and storing dependencies. @@ -127,7 +127,7 @@ func (m *Manager) Build() error { return errors.New("the lock file (requirements.lock) is out of sync with the dependencies file (requirements.yaml). Please update the dependencies") } } else { - return errors.New("the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies") + return errors.New("the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml). Please update the dependencies with 'helm dependency update'") } } @@ -261,7 +261,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { return err } } else { - return fmt.Errorf("unable to retrieve file info for '%s': %v", destPath, err) + return fmt.Errorf("unable to retrieve file info for '%s': %w", destPath, err) } // Prepare tmpPath @@ -281,17 +281,17 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { chartPath := filepath.Join(destPath, dep.Name) ch, err := loader.LoadDir(chartPath) if err != nil { - return fmt.Errorf("unable to load chart '%s': %v", chartPath, err) + return fmt.Errorf("unable to load chart '%s': %w", chartPath, err) } constraint, err := semver.NewConstraint(dep.Version) if err != nil { - return fmt.Errorf("dependency %s has an invalid version/constraint format: %s", dep.Name, err) + return fmt.Errorf("dependency %s has an invalid version/constraint format: %w", dep.Name, err) } v, err := semver.NewVersion(ch.Metadata.Version) if err != nil { - return fmt.Errorf("invalid version %s for dependency %s: %s", dep.Version, dep.Name, err) + return fmt.Errorf("invalid version %s for dependency %s: %w", dep.Version, dep.Name, err) } if !constraint.Check(v) { @@ -502,11 +502,9 @@ Loop: // in a known repo and attempt to ensure the data is present for steps like // version resolution. func (m *Manager) ensureMissingRepos(repoNames map[string]string, deps []*chart.Dependency) (map[string]string, error) { - var ru []*repo.Entry for _, dd := range deps { - // If the chart is in the local charts directory no repository needs // to be specified. if dd.Repository == "" { @@ -679,7 +677,6 @@ func dedupeRepos(repos []*repo.Entry) []*repo.Entry { } func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error { - var wg sync.WaitGroup localRepos := dedupeRepos(repos) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index f5db7e158..4f7fa4590 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -17,6 +17,7 @@ limitations under the License. package engine import ( + "context" "errors" "fmt" "log/slog" @@ -76,9 +77,34 @@ func New(config *rest.Config) Engine { // that section of the values will be passed into the "foo" chart. And if that // section contains a value named "bar", that value will be passed on to the // bar chart during render time. +// +// Deprecated: Use RenderWithContext instead. func (e Engine) Render(chrt ci.Charter, values common.Values) (map[string]string, error) { + return e.RenderWithContext(context.Background(), chrt, values) +} + +// RenderWithContext takes a chart, optional values, and value overrides, and attempts to render the Go templates. +// +// RenderWithContext can be called repeatedly on the same engine. +// +// This will look in the chart's 'templates' data (e.g. the 'templates/' directory) +// and attempt to render the templates there using the values passed in. +// +// Values are scoped to their templates. A dependency template will not have +// access to the values set for its parent. If chart "foo" includes chart "bar", +// "bar" will not have access to the values for "foo". +// +// Values should be prepared with something like `chartutils.ReadValues`. +// +// Values are passed through the templates according to scope. If the top layer +// chart includes the chart foo, which includes the chart bar, the values map +// will be examined for a table called "foo". If "foo" is found in vals, +// that section of the values will be passed into the "foo" chart. And if that +// section contains a value named "bar", that value will be passed on to the +// bar chart during render time. +func (e Engine) RenderWithContext(ctx context.Context, chrt ci.Charter, values common.Values) (map[string]string, error) { tmap := allTemplates(chrt, values) - return e.render(tmap) + return e.render(ctx, tmap) } // Render takes a chart, optional values, and value overrides, and attempts to @@ -129,8 +155,8 @@ func warnWrap(warn string) string { // 'include' needs to be defined in the scope of a 'tpl' template as // well as regular file-loaded templates. -func includeFun(t *template.Template, includedNames map[string]int) func(string, interface{}) (string, error) { - return func(name string, data interface{}) (string, error) { +func includeFun(t *template.Template, includedNames map[string]int) func(string, any) (string, error) { + return func(name string, data any) (string, error) { var buf strings.Builder if v, ok := includedNames[name]; ok { if v > recursionMaxNums { @@ -150,8 +176,8 @@ func includeFun(t *template.Template, includedNames map[string]int) func(string, // As does 'tpl', so that nested calls to 'tpl' see the templates // defined by their enclosing contexts. -func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, interface{}) (string, error) { - return func(tpl string, vals interface{}) (string, error) { +func tplFun(parent *template.Template, includedNames map[string]int, strict bool) func(string, any) (string, error) { + return func(tpl string, vals any) (string, error) { t, err := parent.Clone() if err != nil { return "", fmt.Errorf("cannot clone template: %w", err) @@ -195,7 +221,7 @@ func tplFun(parent *template.Template, includedNames map[string]int, strict bool } // initFunMap creates the Engine's FuncMap and adds context-specific functions. -func (e Engine) initFunMap(t *template.Template) { +func (e Engine) initFunMap(ctx context.Context, t *template.Template) { funcMap := funcMap() includedNames := make(map[string]int) @@ -204,7 +230,7 @@ func (e Engine) initFunMap(t *template.Template) { funcMap["tpl"] = tplFun(t, includedNames, e.Strict) // Add the `required` function here so we can use lintMode - funcMap["required"] = func(warn string, val interface{}) (interface{}, error) { + funcMap["required"] = func(warn string, val any) (any, error) { if val == nil { if e.LintMode { // Don't fail on missing required values when linting @@ -238,7 +264,7 @@ func (e Engine) initFunMap(t *template.Template) { // If we are not linting and have a cluster connection, provide a Kubernetes-backed // implementation. if !e.LintMode && e.clientProvider != nil { - funcMap["lookup"] = newLookupFunction(*e.clientProvider) + funcMap["lookup"] = newLookupFunction(ctx, *e.clientProvider) } // When DNS lookups are not enabled override the sprig function and return @@ -256,7 +282,7 @@ func (e Engine) initFunMap(t *template.Template) { } // render takes a map of templates/values and renders them. -func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, err error) { +func (e Engine) render(ctx context.Context, tpls map[string]renderable) (rendered map[string]string, err error) { // Basically, what we do here is start with an empty parent template and then // build up a list of templates -- one for each file. Once all of the templates // have been parsed, we loop through again and execute every template. @@ -278,7 +304,7 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, t.Option("missingkey=zero") } - e.initFunMap(t) + e.initFunMap(ctx, t) // We want to parse the templates in a predictable order. The order favors // higher-level (in file system) templates over deeply nested templates. @@ -319,7 +345,7 @@ func cleanupParseError(filename string, err error) error { tokens := strings.Split(err.Error(), ": ") if len(tokens) == 1 { // This might happen if a non-templating error occurs - return fmt.Errorf("parse error in (%s): %s", filename, err) + return fmt.Errorf("parse error in (%s): %w", filename, err) } // The first token is "template" // The second token is either "filename:lineno" or "filename:lineNo:columnNo" @@ -410,9 +436,7 @@ func parseTemplateSimpleErrorString(remainder string) (TraceableError, bool) { // Executing form: ": executing \"\" at <>: [ template:...]" // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) { - if idx := strings.Index(remainder, ": executing "); idx != -1 { - templateName := remainder[:idx] - after := remainder[idx+len(": executing "):] + if templateName, after, found := strings.Cut(remainder, ": executing "); found { if len(after) == 0 || after[0] != '"' { return TraceableError{}, false } @@ -431,12 +455,10 @@ func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) return TraceableError{}, false } afterAt := afterFunc[len(atPrefix):] - endLoc := strings.Index(afterAt, ">: ") - if endLoc == -1 { + locationName, errMsg, found := strings.Cut(afterAt, ">: ") + if !found { return TraceableError{}, false } - locationName := afterAt[:endLoc] - errMsg := afterAt[endLoc+len(">: "):] // trim chained next error starting with space + "template:" if present if cut := strings.Index(errMsg, " template:"); cut != -1 { @@ -466,7 +488,7 @@ func reformatExecErrorMsg(filename string, err error) error { tokens := strings.SplitN(err.Error(), ": ", 3) if len(tokens) != 3 { // This might happen if a non-templating error occurs - return fmt.Errorf("execution error in (%s): %s", filename, err) + return fmt.Errorf("execution error in (%s): %w", filename, err) } // The first token is "template" @@ -535,9 +557,9 @@ func allTemplates(c ci.Charter, vals common.Values) map[string]renderable { // // As it recurses, it also sets the values to be appropriate for the template // scope. -func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Values) map[string]interface{} { +func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Values) map[string]any { vals := values.AsMap() - subCharts := make(map[string]interface{}) + subCharts := make(map[string]any) accessor, err := ci.NewAccessor(c) if err != nil { slog.Error("error accessing chart", "error", err) @@ -545,7 +567,7 @@ func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Val chartMetaData := accessor.MetadataAsMap() chartMetaData["IsRoot"] = accessor.IsRoot() - next := map[string]interface{}{ + next := map[string]any{ "Chart": chartMetaData, "Files": newFiles(accessor.Files()), "Release": vals["Release"], diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index c9cdf79c3..f666b63a6 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -17,6 +17,7 @@ limitations under the License. package engine import ( + "errors" "fmt" "path" "strings" @@ -26,6 +27,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -104,14 +106,14 @@ func TestRender(t *testing.T) { {Name: "templates/test4", ModTime: modTime, Data: []byte("{{toJson .Values}}")}, {Name: "templates/test5", ModTime: modTime, Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, - Values: map[string]interface{}{"outer": "DEFAULT", "inner": "DEFAULT"}, + Values: map[string]any{"outer": "DEFAULT", "inner": "DEFAULT"}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{ + vals := map[string]any{ + "Values": map[string]any{ "outer": "spouter", "inner": "inn", - "global": map[string]interface{}{ + "global": map[string]any{ "callme": "Ishmael", }, }, @@ -195,7 +197,7 @@ func TestRenderInternals(t *testing.T) { "three": {tpl: `{{template "two" dict "Value" "three"}}`, vals: vals}, } - out, err := new(Engine).render(tpls) + out, err := new(Engine).render(t.Context(), tpls) if err != nil { t.Fatalf("Failed template rendering: %s", err) } @@ -226,11 +228,11 @@ func TestRenderWithDNS(t *testing.T) { Templates: []*common.File{ {Name: "templates/test1", ModTime: time.Now(), Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -277,15 +279,15 @@ var _ ClientProvider = &testClientProvider{} // makeUnstructured is a convenience function for single-line creation of Unstructured objects. func makeUnstructured(apiVersion, kind, name, namespace string) *unstructured.Unstructured { - ret := &unstructured.Unstructured{Object: map[string]interface{}{ + ret := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": apiVersion, "kind": kind, - "metadata": map[string]interface{}{ + "metadata": map[string]any{ "name": name, }, }} if namespace != "" { - ret.Object["metadata"].(map[string]interface{})["namespace"] = namespace + ret.Object["metadata"].(map[string]any)["namespace"] = namespace } return ret } @@ -356,7 +358,7 @@ func TestRenderWithClientProvider(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } modTime := time.Now() @@ -368,8 +370,8 @@ func TestRenderWithClientProvider(t *testing.T) { }) } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -401,11 +403,11 @@ func TestRenderWithClientProvider_error(t *testing.T) { Templates: []*common.File{ {Name: "templates/error", ModTime: time.Now(), Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, }, - Values: map[string]interface{}{}, + Values: map[string]any{}, } - vals := map[string]interface{}{ - "Values": map[string]interface{}{}, + vals := map[string]any{ + "Values": map[string]any{}, } v, err := util.CoalesceValues(c, vals) @@ -417,7 +419,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { t: t, scheme: map[string]kindProps{ "v1/Error": { - shouldErr: fmt.Errorf("kaboom"), + shouldErr: errors.New("kaboom"), }, }, } @@ -438,10 +440,10 @@ func TestParallelRenderInternals(t *testing.T) { tpls := map[string]renderable{ "t": { tpl: `{{.val}}`, - vals: map[string]interface{}{"val": tt}, + vals: map[string]any{"val": tt}, }, } - out, err := e.render(tpls) + out, err := e.render(t.Context(), tpls) if err != nil { t.Errorf("Failed to render %s: %s", tt, err) } @@ -455,12 +457,12 @@ func TestParallelRenderInternals(t *testing.T) { } func TestParseErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} tplsUndefinedFunction := map[string]renderable{ "undefined_function": {tpl: `{{foo}}`, vals: vals}, } - _, err := new(Engine).render(tplsUndefinedFunction) + _, err := new(Engine).render(t.Context(), tplsUndefinedFunction) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -471,7 +473,7 @@ func TestParseErrors(t *testing.T) { } func TestExecErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} cases := []struct { name string tpls map[string]renderable @@ -523,7 +525,7 @@ linebreak`, for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - _, err := new(Engine).render(tt.tpls) + _, err := new(Engine).render(t.Context(), tt.tpls) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -535,13 +537,13 @@ linebreak`, } func TestFailErrors(t *testing.T) { - vals := common.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]any{}} failtpl := `All your base are belong to us{{ fail "This is an error" }}` tplsFailed := map[string]renderable{ "failtpl": {tpl: failtpl, vals: vals}, } - _, err := new(Engine).render(tplsFailed) + _, err := new(Engine).render(t.Context(), tplsFailed) if err == nil { t.Fatalf("Expected failures while rendering: %s", err) } @@ -552,7 +554,7 @@ func TestFailErrors(t *testing.T) { var e Engine e.LintMode = true - out, err := e.render(tplsFailed) + out, err := e.render(t.Context(), tplsFailed) if err != nil { t.Fatal(err) } @@ -643,7 +645,7 @@ func TestRenderDependency(t *testing.T) { }, }) - out, err := Render(ch, map[string]interface{}{}) + out, err := Render(ch, map[string]any{}) if err != nil { t.Fatalf("failed to render chart: %s", err) } @@ -656,7 +658,6 @@ func TestRenderDependency(t *testing.T) { if out["outerchart/templates/outer"] != expect { t.Errorf("Expected %q, got %q", expect, out["outer"]) } - } func TestRenderNestedValues(t *testing.T) { @@ -675,7 +676,7 @@ func TestRenderNestedValues(t *testing.T) { {Name: deepestpath, ModTime: modTime, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, {Name: checkrelease, ModTime: modTime, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, }, - Values: map[string]interface{}{"what": "milkshake", "where": "here"}, + Values: map[string]any{"what": "milkshake", "where": "here"}, } inner := &chart.Chart{ @@ -683,7 +684,7 @@ func TestRenderNestedValues(t *testing.T) { Templates: []*common.File{ {Name: innerpath, ModTime: modTime, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, - Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, + Values: map[string]any{"who": "Robert", "what": "glasses"}, } inner.AddDependency(deepest) @@ -693,10 +694,10 @@ func TestRenderNestedValues(t *testing.T) { {Name: outerpath, ModTime: modTime, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, {Name: subchartspath, ModTime: modTime, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, }, - Values: map[string]interface{}{ + Values: map[string]any{ "what": "stinkweed", "who": "me", - "herrick": map[string]interface{}{ + "herrick": map[string]any{ "who": "time", "what": "Sun", }, @@ -704,15 +705,15 @@ func TestRenderNestedValues(t *testing.T) { } outer.AddDependency(inner) - injValues := map[string]interface{}{ + injValues := map[string]any{ "what": "rosebuds", - "herrick": map[string]interface{}{ - "deepest": map[string]interface{}{ + "herrick": map[string]any{ + "deepest": map[string]any{ "what": "flower", "where": "Heaven", }, }, - "global": map[string]interface{}{ + "global": map[string]any{ "when": "to-day", }, } @@ -812,7 +813,6 @@ func TestRenderBuiltinValues(t *testing.T) { t.Errorf("Expected %q, got %q", expect, out[file]) } } - } func TestAlterFuncMap_include(t *testing.T) { @@ -1008,7 +1008,6 @@ func TestAlterFuncMap_tplinclude(t *testing.T) { if got := out["TplFunction/templates/base"]; got != expect { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } - } func TestRenderRecursionLimit(t *testing.T) { @@ -1059,13 +1058,14 @@ func TestRenderRecursionLimit(t *testing.T) { } var expect string + var expectSb1062 strings.Builder for range times { - expect += phrase + "\n" + expectSb1062.WriteString(phrase + "\n") } + expect += expectSb1062.String() if got := out["overlook/templates/quote"]; got != expect { t.Errorf("Expected %q, got %q (%v)", expect, got, out) } - } func TestRenderLoadTemplateForTplFromFile(t *testing.T) { @@ -1316,7 +1316,6 @@ func TestRenderTplMissingKeyString(t *testing.T) { if !strings.Contains(errTxt, "noSuchKey") { t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt) } - } func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) { @@ -1349,12 +1348,12 @@ NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 v := common.Values{} val, _ := util.CoalesceValues(c, v) - vals := map[string]interface{}{ + vals := map[string]any{ "Values": val.AsMap(), } _, err := Render(c, vals) - assert.NotNil(t, err) + require.Error(t, err) assert.Equal(t, expectedErrorMessage, err.Error()) } @@ -1383,12 +1382,12 @@ template: no template "nested_helper.name" associated with template "gotpl"` v := common.Values{} val, _ := util.CoalesceValues(c, v) - vals := map[string]interface{}{ + vals := map[string]any{ "Values": val.AsMap(), } _, err := Render(c, vals) - assert.NotNil(t, err) + require.Error(t, err) assert.Equal(t, expectedErrorMessage, err.Error()) } @@ -1463,7 +1462,7 @@ func TestTraceableError_SimpleForm(t *testing.T) { for _, errString := range testStrings { trace, done := parseTemplateSimpleErrorString(errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.message != "error calling include" { t.Errorf("Expected %q, got %q", errString, trace.message) @@ -1480,7 +1479,7 @@ func TestTraceableError_ExecutingForm(t *testing.T) { expectedLocation := errTuple[1] trace, done := parseTemplateExecutingAtErrorType(errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.location != expectedLocation { t.Errorf("Expected %q, got %q", expectedLocation, trace.location) @@ -1495,10 +1494,72 @@ func TestTraceableError_NoTemplateForm(t *testing.T) { for _, errString := range testStrings { trace, done := parseTemplateNoTemplateError(errString, errString) if !done { - t.Errorf("Expected parse to pass but did not") + t.Error("Expected parse to pass but did not") } if trace.message != errString { t.Errorf("Expected %q, got %q", errString, trace.message) } } } + +// TestRenderSubchartDefaultNilNoStringify tests the full pipeline: subchart default +// nil values should not produce "%!s()" in rendered template output. +// Regression test for the Bitnami common.secrets.key issue. +func TestRenderSubchartDefaultNilNoStringify(t *testing.T) { + modTime := time.Now() + + // Subchart has a default with nil values + subchart := &chart.Chart{ + Metadata: &chart.Metadata{Name: "child"}, + Templates: []*common.File{ + { + Name: "templates/test.yaml", + ModTime: modTime, + Data: []byte(`{{- if hasKey .Values.keyMapping "password" -}}{{- printf "subPath: %s" (index .Values.keyMapping "password") -}}{{- else -}}subPath: fallback{{- end -}}`), + }, + }, + Values: map[string]any{ + "keyMapping": map[string]any{ + "password": nil, // nil in chart defaults + }, + }, + } + + parent := &chart.Chart{ + Metadata: &chart.Metadata{Name: "parent"}, + Values: map[string]any{}, + } + parent.AddDependency(subchart) + + // Parent user values don't set keyMapping + injValues := map[string]any{} + + tmp, err := util.CoalesceValues(parent, injValues) + if err != nil { + t.Fatalf("Failed to coalesce values: %s", err) + } + + inject := common.Values{ + "Values": tmp, + "Chart": parent.Metadata, + "Release": common.Values{ + "Name": "test-release", + }, + } + + out, err := Render(parent, inject) + if err != nil { + t.Fatalf("Failed to render templates: %s", err) + } + + rendered := out["parent/charts/child/templates/test.yaml"] + + if strings.Contains(rendered, "%!s()") { + t.Errorf("Rendered output contains %%!s(), got: %q", rendered) + } + + expected := "subPath: fallback" + if rendered != expected { + t.Errorf("Expected %q, got %q", expected, rendered) + } +} diff --git a/pkg/engine/files_test.go b/pkg/engine/files_test.go index e53263c76..f07f6afeb 100644 --- a/pkg/engine/files_test.go +++ b/pkg/engine/files_test.go @@ -107,5 +107,5 @@ func TestBlankLines(t *testing.T) { as.Len(out, 4) as.Equal("bar", out[0]) - as.Equal("", out[3]) + as.Empty(out[3]) } diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index a97f8f104..431f82f63 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -19,9 +19,15 @@ package engine import ( "bytes" "encoding/json" + "errors" + "fmt" "maps" + "math" + "reflect" + "strconv" "strings" "text/template" + "time" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" @@ -50,6 +56,7 @@ func funcMap() template.FuncMap { // Add some extra functionality extra := template.FuncMap{ "toToml": toTOML, + "mustToToml": mustToTOML, "fromToml": fromTOML, "toYaml": toYAML, "mustToYaml": mustToYAML, @@ -61,16 +68,29 @@ func funcMap() template.FuncMap { "fromJson": fromJSON, "fromJsonArray": fromJSONArray, + // Duration helpers + "mustToDuration": mustToDuration, + "durationSeconds": durationSeconds, + "durationMilliseconds": durationMilliseconds, + "durationMicroseconds": durationMicroseconds, + "durationNanoseconds": durationNanoseconds, + "durationMinutes": durationMinutes, + "durationHours": durationHours, + "durationDays": durationDays, + "durationWeeks": durationWeeks, + "durationRoundTo": durationRoundTo, + "durationTruncateTo": durationTruncateTo, + // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the // integrity of the linter. - "include": func(string, interface{}) string { return "not implemented" }, - "tpl": func(string, interface{}) interface{} { return "not implemented" }, - "required": func(string, interface{}) (interface{}, error) { return "not implemented", nil }, + "include": func(string, any) string { return "not implemented" }, + "tpl": func(string, any) any { return "not implemented" }, + "required": func(string, any) (any, error) { return "not implemented", nil }, // Provide a placeholder for the "lookup" function, which requires a kubernetes // connection. - "lookup": func(string, string, string, string) (map[string]interface{}, error) { - return map[string]interface{}{}, nil + "lookup": func(string, string, string, string) (map[string]any, error) { + return map[string]any{}, nil }, } @@ -83,7 +103,7 @@ func funcMap() template.FuncMap { // always return a string, even on marshal error (empty string). // // This is designed to be called from a template. -func toYAML(v interface{}) string { +func toYAML(v any) string { data, err := yaml.Marshal(v) if err != nil { // Swallow errors inside of a template. @@ -97,7 +117,7 @@ func toYAML(v interface{}) string { // // This is designed to be called from a template when need to ensure that the // output YAML is valid. -func mustToYAML(v interface{}) string { +func mustToYAML(v any) string { data, err := yaml.Marshal(v) if err != nil { panic(err) @@ -105,7 +125,7 @@ func mustToYAML(v interface{}) string { return strings.TrimSuffix(string(data), "\n") } -func toYAMLPretty(v interface{}) string { +func toYAMLPretty(v any) string { var data bytes.Buffer encoder := goYaml.NewEncoder(&data) encoder.SetIndent(2) @@ -124,8 +144,8 @@ func toYAMLPretty(v interface{}) string { // YAML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromYAML(str string) map[string]interface{} { - m := map[string]interface{}{} +func fromYAML(str string) map[string]any { + m := map[string]any{} if err := yaml.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -139,20 +159,21 @@ func fromYAML(str string) map[string]interface{} { // YAML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. -func fromYAMLArray(str string) []interface{} { - a := []interface{}{} +func fromYAMLArray(str string) []any { + a := []any{} if err := yaml.Unmarshal([]byte(str), &a); err != nil { - a = []interface{}{err.Error()} + a = []any{err.Error()} } return a } -// toTOML takes an interface, marshals it to toml, and returns a string. It will -// always return a string, even on marshal error (empty string). +// toTOML takes an interface, marshals it to toml, and returns a string. +// On marshal error it returns the error string. // -// This is designed to be called from a template. -func toTOML(v interface{}) string { +// This is designed to be called from a template. Use mustToToml if you need +// the template to fail hard on marshal errors. +func toTOML(v any) string { b := bytes.NewBuffer(nil) e := toml.NewEncoder(b) err := e.Encode(v) @@ -162,14 +183,29 @@ func toTOML(v interface{}) string { return b.String() } +// mustToTOML takes an interface, marshals it to toml, and returns a string. +// It will panic if there is an error. +// +// This is designed to be called from a template when you need to ensure that the +// output TOML is valid. +func mustToTOML(v any) string { + b := bytes.NewBuffer(nil) + e := toml.NewEncoder(b) + err := e.Encode(v) + if err != nil { + panic(err) + } + return b.String() +} + // fromTOML converts a TOML document into a map[string]interface{}. // // This is not a general-purpose TOML parser, and will not parse all valid // TOML documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromTOML(str string) map[string]interface{} { - m := make(map[string]interface{}) +func fromTOML(str string) map[string]any { + m := make(map[string]any) if err := toml.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -181,7 +217,7 @@ func fromTOML(str string) map[string]interface{} { // always return a string, even on marshal error (empty string). // // This is designed to be called from a template. -func toJSON(v interface{}) string { +func toJSON(v any) string { data, err := json.Marshal(v) if err != nil { // Swallow errors inside of a template. @@ -195,7 +231,7 @@ func toJSON(v interface{}) string { // // This is designed to be called from a template when need to ensure that the // output JSON is valid. -func mustToJSON(v interface{}) string { +func mustToJSON(v any) string { data, err := json.Marshal(v) if err != nil { panic(err) @@ -209,8 +245,8 @@ func mustToJSON(v interface{}) string { // JSON documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string into // m["Error"] in the returned map. -func fromJSON(str string) map[string]interface{} { - m := make(map[string]interface{}) +func fromJSON(str string) map[string]any { + m := make(map[string]any) if err := json.Unmarshal([]byte(str), &m); err != nil { m["Error"] = err.Error() @@ -224,11 +260,218 @@ func fromJSON(str string) map[string]interface{} { // JSON documents. Additionally, because its intended use is within templates // it tolerates errors. It will insert the returned error message string as // the first and only item in the returned array. -func fromJSONArray(str string) []interface{} { - a := []interface{}{} +func fromJSONArray(str string) []any { + a := []any{} if err := json.Unmarshal([]byte(str), &a); err != nil { - a = []interface{}{err.Error()} + a = []any{err.Error()} } return a } + +// ----------------------------------------------------------------------------- +// Duration helpers (numeric and time.Duration returns) +// ----------------------------------------------------------------------------- + +const ( + maxDurationSeconds = int64(math.MaxInt64 / int64(time.Second)) + minDurationSeconds = int64(math.MinInt64 / int64(time.Second)) + maxDurationSecondsFloat = float64(math.MaxInt64) / float64(time.Second) + minDurationSecondsFloat = float64(math.MinInt64) / float64(time.Second) +) + +func durationFromSecondsInt(seconds int64) (time.Duration, error) { + if seconds > maxDurationSeconds || seconds < minDurationSeconds { + return 0, fmt.Errorf("duration seconds overflow: %d", seconds) + } + return time.Duration(seconds) * time.Second, nil +} + +func durationFromSecondsUint(seconds uint64) (time.Duration, error) { + if seconds > uint64(maxDurationSeconds) { + return 0, fmt.Errorf("duration seconds overflow: %d", seconds) + } + return time.Duration(int64(seconds)) * time.Second, nil +} + +func durationFromSecondsFloat(seconds float64) (time.Duration, error) { + if math.IsNaN(seconds) || math.IsInf(seconds, 0) { + return 0, fmt.Errorf("invalid duration seconds: %v", seconds) + } + if seconds > maxDurationSecondsFloat || seconds < minDurationSecondsFloat { + return 0, fmt.Errorf("duration seconds overflow: %v", seconds) + } + nanos := seconds * float64(time.Second) + if nanos > float64(math.MaxInt64) || nanos < float64(math.MinInt64) { + return 0, fmt.Errorf("duration nanoseconds overflow: %v", nanos) + } + return time.Duration(nanos), nil +} + +// asDuration converts common template values into a time.Duration. +// +// Supported inputs: +// - time.Duration +// - string duration values parsed by time.ParseDuration (e.g. "1h2m3s") +// - numeric strings treated as seconds (e.g. "2.5") +// - ints and uints treated as seconds +// - floats treated as seconds +func asDuration(v any) (time.Duration, error) { + switch x := v.(type) { + case time.Duration: + return x, nil + + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, errors.New("empty duration") + } + if d, err := time.ParseDuration(s); err == nil { + return d, nil + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return durationFromSecondsFloat(f) + } + return 0, fmt.Errorf("could not parse duration %q", x) + + case nil: + return 0, errors.New("invalid duration") + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return durationFromSecondsInt(rv.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return durationFromSecondsUint(rv.Uint()) + case reflect.Float32, reflect.Float64: + return durationFromSecondsFloat(rv.Float()) + default: + return 0, fmt.Errorf("unsupported duration type %T", v) + } +} + +// mustToDuration takes anything and attempts to parse as a duration returning a time.Duration. +// +// This is designed to be called from a template when need to ensure that a +// duration is valid. +func mustToDuration(v any) time.Duration { + d, err := asDuration(v) + if err != nil { + panic(err) + } + return d +} + +// durationSeconds converts a duration to seconds (float64). +// On error it returns 0. +func durationSeconds(v any) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Seconds() +} + +// durationMilliseconds converts a duration to milliseconds (int64). +// On error it returns 0. +func durationMilliseconds(v any) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Milliseconds() +} + +// durationMicroseconds converts a duration to microseconds (int64). +// On error it returns 0. +func durationMicroseconds(v any) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Microseconds() +} + +// durationNanoseconds converts a duration to nanoseconds (int64). +// On error it returns 0. +func durationNanoseconds(v any) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Nanoseconds() +} + +// durationMinutes converts a duration to minutes (float64). +// On error it returns 0. +func durationMinutes(v any) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Minutes() +} + +// durationHours converts a duration to hours (float64). +// On error it returns 0. +func durationHours(v any) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() +} + +// durationDays converts a duration to days (float64). (Not in Go's stdlib; handy in templates.) +// On error it returns 0. +func durationDays(v any) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() / 24.0 +} + +// durationWeeks converts a duration to weeks (float64). (Not in Go's stdlib; handy in templates.) +// On error it returns 0. +func durationWeeks(v any) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() / 24.0 / 7.0 +} + +// durationRoundTo rounds v to the nearest multiple of m. +// Returns a time.Duration. +// +// v and m accept the same forms as asDuration (e.g. "2h13m", "30s"). +// On error, it returns time.Duration(0). If m is invalid, it returns v. +func durationRoundTo(v any, m any) time.Duration { + d, err := asDuration(v) + if err != nil { + return 0 + } + mul, err := asDuration(m) + if err != nil { + return d + } + return d.Round(mul) +} + +// durationTruncateTo truncates v toward zero to a multiple of m. +// Returns a time.Duration. +// +// On error, it returns time.Duration(0). If m is invalid, it returns v. +func durationTruncateTo(v any, m any) time.Duration { + d, err := asDuration(v) + if err != nil { + return 0 + } + mul, err := asDuration(m) + if err != nil { + return d + } + return d.Truncate(mul) +} diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index 71a72e2e4..cf6a8d5c9 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -17,30 +17,33 @@ limitations under the License. package engine import ( + "math" "strings" "testing" "text/template" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFuncs(t *testing.T) { //TODO write tests for failure cases tests := []struct { tpl, expect string - vars interface{} + vars any }{{ tpl: `{{ toYaml . }}`, expect: `foo: bar`, - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ toYamlPretty . }}`, expect: "baz:\n - 1\n - 2\n - 3", - vars: map[string]interface{}{"baz": []int{1, 2, 3}}, + vars: map[string]any{"baz": []int{1, 2, 3}}, }, { tpl: `{{ toToml . }}`, expect: "foo = \"bar\"\n", - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ fromToml . }}`, expect: "map[hello:world]", @@ -68,7 +71,7 @@ keyInElement1 = "valueInElement1"`, }, { tpl: `{{ toJson . }}`, expect: `{"foo":"bar"}`, - vars: map[string]interface{}{"foo": "bar"}, + vars: map[string]any{"foo": "bar"}, }, { tpl: `{{ fromYaml . }}`, expect: "map[hello:world]", @@ -109,11 +112,11 @@ keyInElement1 = "valueInElement1"`, }, { tpl: `{{ merge .dict (fromYaml .yaml) }}`, expect: `map[a:map[b:c]]`, - vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + vars: map[string]any{"dict": map[string]any{"a": map[string]any{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, }, { tpl: `{{ merge (fromYaml .yaml) .dict }}`, expect: `map[a:map[b:d]]`, - vars: map[string]interface{}{"dict": map[string]interface{}{"a": map[string]interface{}{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, + vars: map[string]any{"dict": map[string]any{"a": map[string]any{"b": "c"}}, "yaml": `{"a":{"b":"d"}}`}, }, { tpl: `{{ fromYaml . }}`, expect: `map[Error:error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into Go value of type map[string]interface {}]`, @@ -136,21 +139,32 @@ keyInElement1 = "valueInElement1"`, assert.Equal(t, tt.expect, b.String(), tt.tpl) } - loopMap := map[string]interface{}{ + loopMap := map[string]any{ "foo": "bar", } - loopMap["loop"] = []interface{}{loopMap} + loopMap["loop"] = []any{loopMap} mustFuncsTests := []struct { tpl string - expect interface{} - vars interface{} + expect any + vars any }{{ tpl: `{{ mustToYaml . }}`, vars: loopMap, }, { tpl: `{{ mustToJson . }}`, vars: loopMap, + }, { + tpl: `{{ mustToDuration 30 }}`, + expect: `30s`, + vars: nil, + }, { + tpl: `{{ mustToDuration "1m30s" }}`, + expect: `1m30s`, + vars: nil, + }, { + tpl: `{{ mustToDuration "foo" }}`, + vars: nil, }, { tpl: `{{ toYaml . }}`, expect: "", // should return empty string and swallow error @@ -159,6 +173,13 @@ keyInElement1 = "valueInElement1"`, tpl: `{{ toJson . }}`, expect: "", // should return empty string and swallow error vars: loopMap, + }, { + tpl: `{{ mustToToml . }}`, + vars: map[int]string{1: "one"}, // non-string key is invalid in TOML + }, { + tpl: `{{ mustToToml . }}`, + expect: "foo = \"bar\"\n", // should succeed and return TOML string + vars: map[string]string{"foo": "bar"}, }, } @@ -174,6 +195,239 @@ keyInElement1 = "valueInElement1"`, } } +func TestDurationHelpers(t *testing.T) { + tests := []struct { + name string + tpl string + vars any + expect string + }{{ + name: "durationSeconds parses duration string", + tpl: `{{ durationSeconds "1m30s" }}`, + expect: `90`, + }, { + name: "durationSeconds parses numeric string as seconds", + tpl: `{{ durationSeconds "2.5" }}`, + expect: `2.5`, + }, { + name: "durationSeconds trims whitespace around numeric string", + tpl: `{{ durationSeconds " 2.5 " }}`, + expect: `2.5`, + }, { + name: "durationSeconds int treated as seconds", + tpl: `{{ durationSeconds 2 }}`, + expect: `2`, + }, { + name: "durationSeconds float treated as seconds", + tpl: `{{ durationSeconds 2.5 }}`, + expect: `2.5`, + }, { + name: "durationSeconds uint treated as seconds", + tpl: `{{ durationSeconds . }}`, + vars: uint(2), + expect: `2`, + }, { + name: "durationSeconds time.Duration passthrough", + tpl: `{{ durationSeconds . }}`, + vars: 1500 * time.Millisecond, + expect: `1.5`, + }, { + name: "invalid duration string returns 0", + tpl: `{{ durationSeconds "nope" }}`, + expect: `0`, + }, { + name: "empty duration string returns 0", + tpl: `{{ durationSeconds "" }}`, + expect: `0`, + }, { + name: "whitespace-only duration string returns 0", + tpl: `{{ durationSeconds " " }}`, + expect: `0`, + }, { + name: "nil returns 0", + tpl: `{{ durationSeconds . }}`, + vars: nil, + expect: `0`, + }, { + name: "durationSeconds uint overflow returns 0", + tpl: `{{ durationSeconds . }}`, + vars: uint64(math.MaxInt64) + 1, + expect: `0`, + }, { + name: "durationSeconds int overflow returns 0", + tpl: `{{ durationSeconds . }}`, + vars: maxDurationSeconds + 1, + expect: `0`, + }, { + name: "durationSeconds int underflow returns 0", + tpl: `{{ durationSeconds . }}`, + vars: minDurationSeconds - 1, + expect: `0`, + }, { + name: "durationSeconds float overflow returns 0", + tpl: `{{ durationSeconds . }}`, + vars: maxDurationSecondsFloat + 0.5, + expect: `0`, + }, { + name: "durationSeconds float underflow returns 0", + tpl: `{{ durationSeconds . }}`, + vars: minDurationSecondsFloat - 0.5, + expect: `0`, + }, { + name: "durationSeconds NaN returns 0", + tpl: `{{ durationSeconds . }}`, + vars: math.NaN(), + expect: `0`, + }, { + name: "durationSeconds Inf returns 0", + tpl: `{{ durationSeconds . }}`, + vars: math.Inf(1), + expect: `0`, + }, { + name: "durationMilliseconds int seconds", + tpl: `{{ durationMilliseconds 2 }}`, + expect: `2000`, + }, { + name: "durationMilliseconds float seconds", + tpl: `{{ durationMilliseconds 1.5 }}`, + expect: `1500`, + }, { + name: "durationMicroseconds int seconds", + tpl: `{{ durationMicroseconds 2 }}`, + expect: `2000000`, + }, { + name: "durationNanoseconds int seconds", + tpl: `{{ durationNanoseconds 2 }}`, + expect: `2000000000`, + }, { + name: "durationMinutes parses duration string", + tpl: `{{ durationMinutes "90s" }}`, + expect: `1.5`, + }, { + name: "durationHours parses duration string", + tpl: `{{ durationHours "90m" }}`, + expect: `1.5`, + }, { + name: "durationDays parses duration string", + tpl: `{{ durationDays "36h" }}`, + expect: `1.5`, + }, { + name: "durationDays numeric seconds", + tpl: `{{ durationDays 86400 }}`, + expect: `1`, + }, { + name: "durationWeeks parses duration string", + tpl: `{{ durationWeeks "168h" }}`, + expect: `1`, + }, { + name: "durationWeeks parses fractional weeks", + tpl: `{{ durationWeeks "252h" }}`, + expect: `1.5`, + }, { + name: "durationRoundTo numeric seconds", + tpl: `{{ durationRoundTo 93 60 }}`, // 93s rounded to 60s = 120s + expect: `2m0s`, + }, { + name: "durationTruncateTo numeric seconds", + tpl: `{{ durationTruncateTo 93 60 }}`, // 93s truncated to 60s = 60s + expect: `1m0s`, + }, { + name: "durationRoundTo accepts duration-string multiplier", + tpl: `{{ durationRoundTo "93s" "1m" }}`, + expect: `2m0s`, + }, { + name: "durationTruncateTo accepts duration-string multiplier", + tpl: `{{ durationTruncateTo "93s" "1m" }}`, + expect: `1m0s`, + }, { + name: "durationRoundTo invalid m returns v unchanged", + tpl: `{{ durationRoundTo "93s" "nope" }}`, + expect: `1m33s`, + }, { + name: "durationTruncateTo invalid m returns v unchanged", + tpl: `{{ durationTruncateTo "93s" "nope" }}`, + expect: `1m33s`, + }, { + name: "durationRoundTo zero m returns v unchanged", + tpl: `{{ durationRoundTo "93s" 0 }}`, + expect: `1m33s`, + }, { + name: "durationTruncateTo negative m returns v unchanged", + tpl: `{{ durationTruncateTo "93s" -1 }}`, + expect: `1m33s`, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars) + require.NoError(t, err, tt.tpl) + assert.Equal(t, tt.expect, b.String(), tt.tpl) + }) + } + + mustErrTests := []struct { + name string + tpl string + vars any + }{{ + name: "mustToDuration invalid string", + tpl: `{{ mustToDuration "nope" }}`, + }, { + name: "mustToDuration empty string", + tpl: `{{ mustToDuration "" }}`, + }, { + name: "mustToDuration whitespace string", + tpl: `{{ mustToDuration " " }}`, + }, { + name: "mustToDuration unsupported type", + tpl: `{{ mustToDuration . }}`, + vars: []int{1, 2, 3}, + }, { + name: "mustToDuration uint overflow", + tpl: `{{ mustToDuration . }}`, + vars: uint64(math.MaxInt64) + 1, + }, { + name: "mustToDuration int overflow", + tpl: `{{ mustToDuration . }}`, + vars: maxDurationSeconds + 1, + }, { + name: "mustToDuration int underflow", + tpl: `{{ mustToDuration . }}`, + vars: minDurationSeconds - 1, + }, { + name: "mustToDuration float overflow", + tpl: `{{ mustToDuration . }}`, + vars: maxDurationSecondsFloat + 0.5, + }, { + name: "mustToDuration float underflow", + tpl: `{{ mustToDuration . }}`, + vars: minDurationSecondsFloat - 0.5, + }, { + name: "mustToDuration NaN", + tpl: `{{ mustToDuration . }}`, + vars: math.NaN(), + }, { + name: "mustToDuration Inf", + tpl: `{{ mustToDuration . }}`, + vars: math.Inf(-1), + }, + } + + for _, tt := range mustErrTests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + tmpl := template.Must( + template.New("test"). + Funcs(funcMap()). + Parse(tt.tpl), + ) + err := tmpl.Execute(&b, tt.vars) + require.Error(t, err, tt.tpl) + }) + } +} + // This test to check a function provided by sprig is due to a change in a // dependency of sprig. mergo in v0.3.9 changed the way it merges and only does // public fields (i.e. those starting with a capital letter). This test, from @@ -186,34 +440,34 @@ keyInElement1 = "valueInElement1"`, // be used to accidentally update mergo. This test and message should catch // the problem and explain why it's happening. func TestMerge(t *testing.T) { - dict := map[string]interface{}{ - "src2": map[string]interface{}{ + dict := map[string]any{ + "src2": map[string]any{ "h": 10, "i": "i", "j": "j", }, - "src1": map[string]interface{}{ + "src1": map[string]any{ "a": 1, "b": 2, - "d": map[string]interface{}{ + "d": map[string]any{ "e": "four", }, "g": []int{6, 7}, "i": "aye", "j": "jay", - "k": map[string]interface{}{ + "k": map[string]any{ "l": false, }, }, - "dst": map[string]interface{}{ + "dst": map[string]any{ "a": "one", "c": 3, - "d": map[string]interface{}{ + "d": map[string]any{ "f": 5, }, "g": []int{8, 9}, "i": "eye", - "k": map[string]interface{}{ + "k": map[string]any{ "l": true, }, }, @@ -223,11 +477,11 @@ func TestMerge(t *testing.T) { err := template.Must(template.New("test").Funcs(funcMap()).Parse(tpl)).Execute(&b, dict) assert.NoError(t, err) - expected := map[string]interface{}{ + expected := map[string]any{ "a": "one", // key overridden "b": 2, // merged from src1 "c": 3, // merged from dst - "d": map[string]interface{}{ // deep merge + "d": map[string]any{ // deep merge "e": "four", "f": 5, }, @@ -235,7 +489,7 @@ func TestMerge(t *testing.T) { "h": 10, // merged from src2 "i": "eye", // overridden twice "j": "jay", // overridden and merged - "k": map[string]interface{}{ + "k": map[string]any{ "l": true, // overridden }, } diff --git a/pkg/engine/lookup_func.go b/pkg/engine/lookup_func.go index 52b6ffdaf..b0d0224be 100644 --- a/pkg/engine/lookup_func.go +++ b/pkg/engine/lookup_func.go @@ -36,7 +36,7 @@ type lookupFunc = func(apiversion string, resource string, namespace string, nam // // If the resource does not exist, no error is raised. func NewLookupFunction(config *rest.Config) lookupFunc { //nolint:revive - return newLookupFunction(clientProviderFromConfig{config: config}) + return newLookupFunction(context.Background(), clientProviderFromConfig{config: config}) } type ClientProvider interface { @@ -54,7 +54,7 @@ func (c clientProviderFromConfig) GetClientFor(apiVersion, kind string) (dynamic return getDynamicClientOnKind(apiVersion, kind, c.config) } -func newLookupFunction(clientProvider ClientProvider) lookupFunc { +func newLookupFunction(ctx context.Context, clientProvider ClientProvider) lookupFunc { return func(apiversion string, kind string, namespace string, name string) (map[string]any, error) { var client dynamic.ResourceInterface c, namespaced, err := clientProvider.GetClientFor(apiversion, kind) @@ -68,11 +68,17 @@ func newLookupFunction(clientProvider ClientProvider) lookupFunc { } if name != "" { // this will return a single object - obj, err := client.Get(context.Background(), name, metav1.GetOptions{}) + obj, err := client.Get(ctx, name, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { // Just return an empty interface when the object was not found. // That way, users can use `if not (lookup ...)` in their templates. + slog.Debug("lookup: resource not found", + slog.String("apiVersion", apiversion), + slog.String("kind", kind), + slog.String("namespace", namespace), + slog.String("name", name), + ) return map[string]any{}, nil } return map[string]any{}, err @@ -80,11 +86,16 @@ func newLookupFunction(clientProvider ClientProvider) lookupFunc { return obj.UnstructuredContent(), nil } // this will return a list - obj, err := client.List(context.Background(), metav1.ListOptions{}) + obj, err := client.List(ctx, metav1.ListOptions{}) if err != nil { if apierrors.IsNotFound(err) { // Just return an empty interface when the object was not found. // That way, users can use `if not (lookup ...)` in their templates. + slog.Debug("lookup: resource list not found", + slog.String("apiVersion", apiversion), + slog.String("kind", kind), + slog.String("namespace", namespace), + ) return map[string]any{}, nil } return map[string]any{}, err diff --git a/pkg/gates/gates_test.go b/pkg/gates/gates_test.go index 4d77199e6..217ee1bbe 100644 --- a/pkg/gates/gates_test.go +++ b/pkg/gates/gates_test.go @@ -18,6 +18,8 @@ package gates import ( "os" "testing" + + "github.com/stretchr/testify/assert" ) const name string = "HELM_EXPERIMENTAL_FEATURE" @@ -25,31 +27,23 @@ const name string = "HELM_EXPERIMENTAL_FEATURE" func TestIsEnabled(t *testing.T) { g := Gate(name) - if g.IsEnabled() { - t.Errorf("feature gate shows as available, but the environment variable %s was not set", name) - } + assert.False(t, g.IsEnabled(), "feature gate shows as available, but the environment variable %s was not set", name) t.Setenv(name, "1") - if !g.IsEnabled() { - t.Errorf("feature gate shows as disabled, but the environment variable %s was set", name) - } + assert.True(t, g.IsEnabled(), "feature gate shows as disabled, but the environment variable %s was set", name) } func TestError(t *testing.T) { os.Unsetenv(name) g := Gate(name) - if g.Error().Error() != "this feature has been marked as experimental and is not enabled by default. Please set HELM_EXPERIMENTAL_FEATURE=1 in your environment to use this feature" { - t.Errorf("incorrect error message. Received %s", g.Error().Error()) - } + assert.Equal(t, "this feature has been marked as experimental and is not enabled by default. Please set HELM_EXPERIMENTAL_FEATURE=1 in your environment to use this feature", g.Error().Error()) } func TestString(t *testing.T) { os.Unsetenv(name) g := Gate(name) - if g.String() != "HELM_EXPERIMENTAL_FEATURE" { - t.Errorf("incorrect string representation. Received %s", g.String()) - } + assert.Equal(t, "HELM_EXPERIMENTAL_FEATURE", g.String()) } diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go index 2bc12bdbf..a72a9337a 100644 --- a/pkg/getter/httpgetter.go +++ b/pkg/getter/httpgetter.go @@ -17,9 +17,11 @@ package getter import ( "bytes" + "context" "crypto/tls" "fmt" "io" + "log/slog" "net/http" "net/url" "sync" @@ -48,7 +50,7 @@ func (g *HTTPGetter) Get(href string, options ...Option) (*bytes.Buffer, error) func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) { // Set a helm specific user agent so that a repo server and metrics can // separate helm calls from other tools interacting with repos. - req, err := http.NewRequest(http.MethodGet, href, nil) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, href, http.NoBody) if err != nil { return nil, err } @@ -87,11 +89,13 @@ func (g *HTTPGetter) get(href string, opts getterOptions) (*bytes.Buffer, error) return nil, err } + slog.Debug("fetching", "url", href) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() + slog.Debug("fetch complete", "url", href, "status", resp.Status, "content-length", resp.ContentLength) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status) } diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go index 3047ed6f6..f3116d626 100644 --- a/pkg/getter/httpgetter_test.go +++ b/pkg/getter/httpgetter_test.go @@ -403,7 +403,7 @@ func TestDownloadTLSWithRedirect(t *testing.T) { // request URL for every request (including redirects). Setting `tls.Config.ServerName` on the // client just overrides the remote endpoint's hostname. // See https://github.com/golang/go/blob/3979fb9/src/net/http/transport.go#L1505-L1513. - u.Host = fmt.Sprintf("localhost:%s", u.Port()) + u.Host = "localhost:" + u.Port() http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect) })) @@ -510,7 +510,6 @@ func TestDownloadInsecureSkipTLSVerify(t *testing.T) { if _, err = g.Get(u.String()); err != nil { t.Error(err) } - } func TestHTTPGetterTarDownload(t *testing.T) { @@ -583,10 +582,10 @@ func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expe t.Fatal(err) } - if returnVal == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if returnVal == nil { + t.Fatal("Expected non nil value for http client") } - transport := (returnVal.Transport).(*http.Transport) //nolint:staticcheck + transport := (returnVal.Transport).(*http.Transport) gotValue := false if transport.TLSClientConfig != nil { gotValue = transport.TLSClientConfig.InsecureSkipVerify @@ -607,11 +606,11 @@ func TestDefaultHTTPTransportReuse(t *testing.T) { t.Fatal(err) } - if httpClient1 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient1 == nil { + t.Fatal("Expected non nil value for http client") } - transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + transport1 := (httpClient1.Transport).(*http.Transport) httpClient2, err := g.httpClient(g.opts) @@ -619,14 +618,14 @@ func TestDefaultHTTPTransportReuse(t *testing.T) { t.Fatal(err) } - if httpClient2 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient2 == nil { + t.Fatal("Expected non nil value for http client") } - transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + transport2 := (httpClient2.Transport).(*http.Transport) if transport1 != transport2 { - t.Fatalf("Expected default transport to be reused") + t.Fatal("Expected default transport to be reused") } } @@ -641,14 +640,14 @@ func TestHTTPTransportOption(t *testing.T) { t.Fatal(err) } - if httpClient1 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient1 == nil { + t.Fatal("Expected non nil value for http client") } - transport1 := (httpClient1.Transport).(*http.Transport) //nolint:staticcheck + transport1 := (httpClient1.Transport).(*http.Transport) if transport1 != transport { - t.Fatalf("Expected transport option to be applied") + t.Fatal("Expected transport option to be applied") } httpClient2, err := g.httpClient(g.opts) @@ -657,14 +656,14 @@ func TestHTTPTransportOption(t *testing.T) { t.Fatal(err) } - if httpClient2 == nil { //nolint:staticcheck - t.Fatalf("Expected non nil value for http client") + if httpClient2 == nil { + t.Fatal("Expected non nil value for http client") } - transport2 := (httpClient2.Transport).(*http.Transport) //nolint:staticcheck + transport2 := (httpClient2.Transport).(*http.Transport) if transport1 != transport2 { - t.Fatalf("Expected applied transport to be reused") + t.Fatal("Expected applied transport to be reused") } g = HTTPGetter{} diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go index 24fc60c56..de8643bcd 100644 --- a/pkg/getter/ocigetter.go +++ b/pkg/getter/ocigetter.go @@ -58,7 +58,7 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { client = c } - ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)) + ref := strings.TrimPrefix(href, registry.OCIScheme+"://") if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") { ref = fmt.Sprintf("%s:%s", ref, version) diff --git a/pkg/getter/ocigetter_test.go b/pkg/getter/ocigetter_test.go index ef196afcc..5a7c99ebe 100644 --- a/pkg/getter/ocigetter_test.go +++ b/pkg/getter/ocigetter_test.go @@ -128,7 +128,7 @@ func TestOCIHTTPTransportReuse(t *testing.T) { } if g.transport == nil { - t.Fatalf("Expected non nil value for transport") + t.Fatal("Expected non nil value for transport") } transport1 := g.transport @@ -140,12 +140,12 @@ func TestOCIHTTPTransportReuse(t *testing.T) { } if g.transport == nil { - t.Fatalf("Expected non nil value for transport") + t.Fatal("Expected non nil value for transport") } transport2 := g.transport if transport1 != transport2 { - t.Fatalf("Expected default transport to be reused") + t.Fatal("Expected default transport to be reused") } } diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index ef8b87503..c683a1090 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -41,7 +41,6 @@ func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) { env := plugin.FormatEnv(settings.EnvVars()) pluginConstructorBuilder := func(plg plugin.Plugin) Constructor { return func(option ...Option) (Getter, error) { - return &getterPlugin{ options: append([]Option{}, option...), plg: plg, diff --git a/pkg/helmpath/home_unix_test.go b/pkg/helmpath/home_unix_test.go index a64c9bcd6..84c912502 100644 --- a/pkg/helmpath/home_unix_test.go +++ b/pkg/helmpath/home_unix_test.go @@ -16,9 +16,10 @@ package helmpath import ( - "runtime" "testing" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/helmpath/xdg" ) @@ -26,20 +27,13 @@ func TestHelmHome(t *testing.T) { t.Setenv(xdg.CacheHomeEnvVar, "/cache") t.Setenv(xdg.ConfigHomeEnvVar, "/config") t.Setenv(xdg.DataHomeEnvVar, "/data") - isEq := func(t *testing.T, got, expected string) { - t.Helper() - if expected != got { - t.Error(runtime.GOOS) - t.Errorf("Expected %q, got %q", expected, got) - } - } - - isEq(t, CachePath(), "/cache/helm") - isEq(t, ConfigPath(), "/config/helm") - isEq(t, DataPath(), "/data/helm") + + assert.Equal(t, "/cache/helm", CachePath()) + assert.Equal(t, "/config/helm", ConfigPath()) + assert.Equal(t, "/data/helm", DataPath()) // test to see if lazy-loading environment variables at runtime works t.Setenv(xdg.CacheHomeEnvVar, "/cache2") - isEq(t, CachePath(), "/cache2/helm") + assert.Equal(t, "/cache2/helm", CachePath()) } diff --git a/pkg/helmpath/home_windows_test.go b/pkg/helmpath/home_windows_test.go index 38fe5e4f1..4c2c7d11a 100644 --- a/pkg/helmpath/home_windows_test.go +++ b/pkg/helmpath/home_windows_test.go @@ -19,6 +19,8 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/helmpath/xdg" ) @@ -26,18 +28,13 @@ func TestHelmHome(t *testing.T) { os.Setenv(xdg.CacheHomeEnvVar, "c:\\") os.Setenv(xdg.ConfigHomeEnvVar, "d:\\") os.Setenv(xdg.DataHomeEnvVar, "e:\\") - isEq := func(t *testing.T, a, b string) { - if a != b { - t.Errorf("Expected %q, got %q", b, a) - } - } - isEq(t, CachePath(), "c:\\helm") - isEq(t, ConfigPath(), "d:\\helm") - isEq(t, DataPath(), "e:\\helm") + assert.Equal(t, "c:\\helm", CachePath()) + assert.Equal(t, "d:\\helm", ConfigPath()) + assert.Equal(t, "e:\\helm", DataPath()) // test to see if lazy-loading environment variables at runtime works os.Setenv(xdg.CacheHomeEnvVar, "f:\\") - isEq(t, CachePath(), "f:\\helm") + assert.Equal(t, "f:\\helm", CachePath()) } diff --git a/pkg/helmpath/lazypath.go b/pkg/helmpath/lazypath.go index c1f868754..bf6d71aa7 100644 --- a/pkg/helmpath/lazypath.go +++ b/pkg/helmpath/lazypath.go @@ -38,7 +38,6 @@ const ( type lazypath string func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string { - // There is an order to checking for a path. // 1. See if a Helm specific environment variable has been set. // 2. Check if an XDG environment variable is set diff --git a/pkg/helmpath/lazypath_darwin_test.go b/pkg/helmpath/lazypath_darwin_test.go index e3006d0d5..143750f04 100644 --- a/pkg/helmpath/lazypath_darwin_test.go +++ b/pkg/helmpath/lazypath_darwin_test.go @@ -20,6 +20,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "k8s.io/client-go/util/homedir" "helm.sh/helm/v4/pkg/helmpath/xdg" @@ -36,17 +37,13 @@ func TestDataPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "Library", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) t.Setenv(xdg.DataHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) } func TestConfigPath(t *testing.T) { @@ -54,17 +51,13 @@ func TestConfigPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "Library", "Preferences", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) t.Setenv(xdg.ConfigHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) } func TestCachePath(t *testing.T) { @@ -72,15 +65,11 @@ func TestCachePath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "Library", "Caches", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) t.Setenv(xdg.CacheHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) } diff --git a/pkg/helmpath/lazypath_unix_test.go b/pkg/helmpath/lazypath_unix_test.go index 4b0f2429b..6f8c6c2e8 100644 --- a/pkg/helmpath/lazypath_unix_test.go +++ b/pkg/helmpath/lazypath_unix_test.go @@ -19,6 +19,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "k8s.io/client-go/util/homedir" "helm.sh/helm/v4/pkg/helmpath/xdg" @@ -33,47 +34,35 @@ const ( func TestDataPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), ".local", "share", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) t.Setenv(xdg.DataHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) } func TestConfigPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), ".config", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) t.Setenv(xdg.ConfigHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) } func TestCachePath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), ".cache", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) t.Setenv(xdg.CacheHomeEnvVar, "/tmp") expected = filepath.Join("/tmp", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) } diff --git a/pkg/helmpath/lazypath_windows_test.go b/pkg/helmpath/lazypath_windows_test.go index ebd95e812..df8ad2d80 100644 --- a/pkg/helmpath/lazypath_windows_test.go +++ b/pkg/helmpath/lazypath_windows_test.go @@ -20,6 +20,7 @@ import ( "path/filepath" "testing" + "github.com/stretchr/testify/assert" "k8s.io/client-go/util/homedir" "helm.sh/helm/v4/pkg/helmpath/xdg" @@ -37,17 +38,13 @@ func TestDataPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "foo", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) os.Setenv(xdg.DataHomeEnvVar, filepath.Join(homedir.HomeDir(), "xdg")) expected = filepath.Join(homedir.HomeDir(), "xdg", appName, testFile) - if lazy.dataPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile)) - } + assert.Equal(t, expected, lazy.dataPath(testFile)) } func TestConfigPath(t *testing.T) { @@ -56,17 +53,13 @@ func TestConfigPath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "foo", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) os.Setenv(xdg.ConfigHomeEnvVar, filepath.Join(homedir.HomeDir(), "xdg")) expected = filepath.Join(homedir.HomeDir(), "xdg", appName, testFile) - if lazy.configPath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile)) - } + assert.Equal(t, expected, lazy.configPath(testFile)) } func TestCachePath(t *testing.T) { @@ -75,15 +68,11 @@ func TestCachePath(t *testing.T) { expected := filepath.Join(homedir.HomeDir(), "foo", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) os.Setenv(xdg.CacheHomeEnvVar, filepath.Join(homedir.HomeDir(), "xdg")) expected = filepath.Join(homedir.HomeDir(), "xdg", appName, testFile) - if lazy.cachePath(testFile) != expected { - t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile)) - } + assert.Equal(t, expected, lazy.cachePath(testFile)) } diff --git a/pkg/ignore/rules_test.go b/pkg/ignore/rules_test.go index 9581cf09f..30e157a52 100644 --- a/pkg/ignore/rules_test.go +++ b/pkg/ignore/rules_test.go @@ -21,6 +21,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var testdata = "./testdata" @@ -36,22 +39,14 @@ baz/bar/foo.txt one/more ` r, err := parseString(rules) - if err != nil { - t.Fatalf("Error parsing rules: %s", err) - } + require.NoError(t, err, "Error parsing rules") - if len(r.patterns) != 4 { - t.Errorf("Expected 4 rules, got %d", len(r.patterns)) - } + require.Len(t, r.patterns, 4) expects := []string{"foo", "bar/*", "baz/bar/foo.txt", "one/more"} for i, p := range r.patterns { - if p.raw != expects[i] { - t.Errorf("Expected %q, got %q", expects[i], p.raw) - } - if p.match == nil { - t.Errorf("Expected %s to have a matcher function.", p.raw) - } + assert.Equal(t, expects[i], p.raw) + assert.NotNil(t, p.match, "Expected %s to have a matcher function.", p.raw) } } @@ -59,26 +54,19 @@ func TestParseFail(t *testing.T) { shouldFail := []string{"foo/**/bar", "[z-"} for _, fail := range shouldFail { _, err := parseString(fail) - if err == nil { - t.Errorf("Rule %q should have failed", fail) - } + assert.Error(t, err, "Rule %q should have failed", fail) } } func TestParseFile(t *testing.T) { f := filepath.Join(testdata, HelmIgnore) - if _, err := os.Stat(f); err != nil { - t.Fatalf("Fixture %s missing: %s", f, err) - } + _, err := os.Stat(f) + require.NoError(t, err, "Fixture %s missing", f) r, err := ParseFile(f) - if err != nil { - t.Fatalf("Failed to parse rules file: %s", err) - } + require.NoError(t, err, "Failed to parse rules file") - if len(r.patterns) != 3 { - t.Errorf("Expected 3 patterns, got %d", len(r.patterns)) - } + assert.Len(t, r.patterns, 3) } func TestIgnore(t *testing.T) { @@ -126,17 +114,11 @@ func TestIgnore(t *testing.T) { for _, test := range tests { r, err := parseString(test.pattern) - if err != nil { - t.Fatalf("Failed to parse: %s", err) - } + require.NoError(t, err, "Failed to parse: %s", test.pattern) fi, err := os.Stat(filepath.Join(testdata, test.name)) - if err != nil { - t.Fatalf("Fixture missing: %s", err) - } + require.NoError(t, err, "Fixture missing: %s", test.name) - if r.Ignore(test.name, fi) != test.expect { - t.Errorf("Expected %q to be %v for pattern %q", test.name, test.expect, test.pattern) - } + assert.Equal(t, test.expect, r.Ignore(test.name, fi), "Expected %q to be %v for pattern %q", test.name, test.expect, test.pattern) } } @@ -144,9 +126,7 @@ func TestAddDefaults(t *testing.T) { r := Rules{} r.AddDefaults() - if len(r.patterns) != 1 { - t.Errorf("Expected 1 default patterns, got %d", len(r.patterns)) - } + assert.Len(t, r.patterns, 1) } func parseString(str string) (*Rules, error) { diff --git a/pkg/kube/client.go b/pkg/kube/client.go index fc706496d..7b02f47ef 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "bytes" @@ -235,12 +235,15 @@ func New(getter genericclioptions.RESTClientGetter) *Client { // getKubeClient get or create a new KubernetesClientSet func (c *Client) getKubeClient() (kubernetes.Interface, error) { - var err error - if c.kubeClient == nil { - c.kubeClient, err = c.Factory.KubernetesClientSet() + if c.kubeClient != nil { + return c.kubeClient, nil } - - return c.kubeClient, err + kc, err := c.Factory.KubernetesClientSet() + if err != nil { + return nil, err + } + c.kubeClient = kc + return c.kubeClient, nil } // IsReachable tests connectivity to the cluster. @@ -272,12 +275,12 @@ type ClientCreateOption func(*clientCreateOptions) error // ClientCreateOptionServerSideApply enables performing object apply server-side // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ // -// `forceConflicts` forces conflicts to be resolved (may be when serverSideApply enabled only) +// `forceConflicts` forces conflicts to be resolved (may be used when serverSideApply enabled only) // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts func ClientCreateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientCreateOption { return func(o *clientCreateOptions) error { if !serverSideApply && forceConflicts { - return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + return errors.New("forceConflicts enabled when serverSideApply disabled") } o.serverSideApply = serverSideApply @@ -521,7 +524,6 @@ func determineFieldValidationDirective(validate bool) FieldValidationDirective { } func buildResourceList(f Factory, namespace string, validationDirective FieldValidationDirective, reader io.Reader, transformRequest resource.RequestTransform) (ResourceList, error) { - schema, err := f.Validator(string(validationDirective)) if err != nil { return nil, err @@ -727,7 +729,7 @@ func ClientUpdateOptionThreeWayMergeForUnstructured(threeWayMergeForUnstructured func ClientUpdateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientUpdateOption { return func(o *clientUpdateOptions) error { if !serverSideApply && forceConflicts { - return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + return errors.New("forceConflicts enabled when serverSideApply disabled") } o.serverSideApply = serverSideApply @@ -811,15 +813,15 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate } if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply { - return &Result{}, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") + return &Result{}, errors.New("invalid operation: cannot use three-way merge for unstructured and server-side apply together") } if updateOptions.forceConflicts && updateOptions.forceReplace { - return &Result{}, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") + return &Result{}, errors.New("invalid operation: cannot use force conflicts and force replace together") } if updateOptions.serverSideApply && updateOptions.forceReplace { - return &Result{}, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") + return &Result{}, errors.New("invalid operation: cannot use server-side apply and force replace together") } createApplyFunc := c.makeCreateApplyFunc( @@ -859,7 +861,6 @@ func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdate slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective)), slog.Bool("upgradeClientSideFieldManager", updateOptions.upgradeClientSideFieldManager)) return func(original, target *resource.Info) error { - logger := c.Logger().With( slog.String("namespace", target.Namespace), slog.String("name", target.Name), @@ -954,7 +955,6 @@ func isIncompatibleServerError(err error) bool { // getManagedFieldsManager returns the manager string. If one was set it will be returned. // Otherwise, one is calculated based on the name of the binary. func getManagedFieldsManager() string { - // When a manager is explicitly set use it if ManagedFieldsManager != "" { return ManagedFieldsManager @@ -1102,7 +1102,6 @@ func createPatch(original runtime.Object, target *resource.Info, threeWayMergeFo } func replaceResource(target *resource.Info, fieldValidationDirective FieldValidationDirective) error { - helper := resource.NewHelper(target.Client, target.Mapping). WithFieldValidation(string(fieldValidationDirective)). WithFieldManager(getManagedFieldsManager()) @@ -1117,11 +1116,9 @@ func replaceResource(target *resource.Info, fieldValidationDirective FieldValida } return nil - } func patchResourceClientSide(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) error { - patch, patchType, err := createPatch(original, target, threeWayMergeForUnstructured) if err != nil { return fmt.Errorf("failed to create patch: %w", err) @@ -1155,14 +1152,12 @@ func patchResourceClientSide(original runtime.Object, target *resource.Info, thr // that upgrade CSA managed fields to SSA apply // see: https://github.com/kubernetes/kubernetes/pull/112905 func upgradeClientSideFieldManager(info *resource.Info, dryRun bool, fieldValidationDirective FieldValidationDirective) (bool, error) { - fieldManagerName := getManagedFieldsManager() patched := false err := retry.RetryOnConflict( retry.DefaultRetry, func() error { - if err := info.Get(); err != nil { return fmt.Errorf("failed to get object %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) } @@ -1234,14 +1229,14 @@ func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts ) if err != nil { if isIncompatibleServerError(err) { - return fmt.Errorf("server-side apply not available on the server: %v", err) + return fmt.Errorf("server-side apply not available on the server: %w", err) } if apierrors.IsConflict(err) { return fmt.Errorf("conflict occurred while applying object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } - return err + return fmt.Errorf("server-side apply failed for object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } return target.Refresh(obj, true) @@ -1251,7 +1246,7 @@ func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts func (c *Client) GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error) { podList, err := c.kubeClient.CoreV1().Pods(namespace).List(context.Background(), listOptions) if err != nil { - return nil, fmt.Errorf("failed to get pod list with options: %+v with error: %v", listOptions, err) + return nil, fmt.Errorf("failed to get pod list with options: %+v with error: %w", listOptions, err) } return podList, nil } diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index 7e4cf046c..131695dc2 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "net/http" "strings" @@ -283,7 +282,6 @@ func TestCreate(t *testing.T) { c := newTestClient(t) for name, tc := range testCases { t.Run(name, func(t *testing.T) { - client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { return tc.Callback(t, tc, previous, req) }) @@ -318,7 +316,6 @@ func TestCreate(t *testing.T) { } assert.Equal(t, tc.ExpectedActions, actions) - }) } } @@ -445,7 +442,6 @@ func TestUpdate(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - listOriginal := tc.OriginalPods listTarget := tc.TargetPods @@ -551,9 +547,9 @@ func TestUpdate(t *testing.T) { // Special handling for the rollback test case if name == "rollback after failed upgrade with removed resource" { - assert.Len(t, result.Created, 0, "expected 0 resource created, got %d", len(result.Created)) + assert.Empty(t, result.Created, "expected 0 resource created, got %d", len(result.Created)) assert.Len(t, result.Updated, 1, "expected 1 resource updated, got %d", len(result.Updated)) - assert.Len(t, result.Deleted, 0, "expected 0 resource deleted, got %d", len(result.Deleted)) + assert.Empty(t, result.Deleted, "expected 0 resource deleted, got %d", len(result.Deleted)) } else { assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) @@ -973,6 +969,7 @@ func TestGetPodList(t *testing.T) { podList, err := c.GetPodList(namespace, metav1.ListOptions{}) clientAssertions := assert.New(t) clientAssertions.NoError(err) + podList.ResourceVersion = "" clientAssertions.Equal(&responsePodList, podList) } @@ -1410,7 +1407,6 @@ func TestIsReachable(t *testing.T) { if !strings.Contains(err.Error(), tt.errorContains) { t.Errorf("expected error message to contain '%s', got: %v", tt.errorContains, err) } - } else { if err != nil { t.Errorf("expected no error but got: %v", err) @@ -1420,6 +1416,26 @@ func TestIsReachable(t *testing.T) { } } +func TestIsReachableTwiceAfterClientCreationFailure(t *testing.T) { + refusedErr := errors.New("connection refused") + client := newTestClient(t) + client.Factory = &errorFactory{err: refusedErr} + + assertReachableErr := func(label string, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected error, got nil", label) + } + if !errors.Is(err, refusedErr) { + t.Fatalf("%s: expected error wrapping %v, got %v", label, refusedErr, err) + } + } + + assertReachableErr("first call", client.IsReachable()) + // Second call must return the same underlying error, not panic. + assertReachableErr("second call", client.IsReachable()) +} + func TestIsIncompatibleServerError(t *testing.T) { testCases := map[string]struct { Err error @@ -1434,7 +1450,7 @@ func TestIsIncompatibleServerError(t *testing.T) { Want: false, }, "Generic error": { - Err: fmt.Errorf("some generic error"), + Err: errors.New("some generic error"), Want: false, }, } @@ -1488,7 +1504,6 @@ func TestReplaceResource(t *testing.T) { for name, tc := range testCases { t.Run(name, func(t *testing.T) { - testFactory := cmdtesting.NewTestFactory() t.Cleanup(testFactory.Cleanup) @@ -1611,7 +1626,6 @@ func TestPatchResourceClientSide(t *testing.T) { t.Fail() return nil, nil - }, ExpectedErrorContains: "cannot patch \"whale\" with kind Pod: the server reported a conflict", }, @@ -1633,14 +1647,12 @@ func TestPatchResourceClientSide(t *testing.T) { t.Fail() return nil, nil // newResponse(http.StatusOK, &tc.TargetPods.Items[0]) - }, }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { - testFactory := cmdtesting.NewTestFactory() t.Cleanup(testFactory.Cleanup) @@ -1803,11 +1815,27 @@ func TestPatchResourceServerSide(t *testing.T) { }, ExpectedErrorContains: "the server reported a conflict", }, + "generic server-side apply error": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + return newResponse(http.StatusBadRequest, &metav1.Status{ + Status: metav1.StatusFailure, + Message: `failed to create typed patch object: .spec.template.spec.containers[name="test"].env: duplicate entries for key [name="SERVER_CONTEXT_PATH"]`, + Reason: metav1.StatusReasonBadRequest, + Code: http.StatusBadRequest, + }) + }, + ExpectedErrorContains: "server-side apply failed for object default/whale /v1, Kind=Pod: failed to create typed patch object", + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { - testFactory := cmdtesting.NewTestFactory() t.Cleanup(testFactory.Cleanup) @@ -1838,7 +1866,6 @@ func TestPatchResourceServerSide(t *testing.T) { } func TestDetermineFieldValidationDirective(t *testing.T) { - assert.Equal(t, FieldValidationDirectiveIgnore, determineFieldValidationDirective(false)) assert.Equal(t, FieldValidationDirectiveStrict, determineFieldValidationDirective(true)) } diff --git a/pkg/kube/converter.go b/pkg/kube/converter.go index ac6d95fb4..7300a708c 100644 --- a/pkg/kube/converter.go +++ b/pkg/kube/converter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "sync" diff --git a/pkg/kube/factory.go b/pkg/kube/factory.go index 1d237c307..4e12e0889 100644 --- a/pkg/kube/factory.go +++ b/pkg/kube/factory.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "k8s.io/cli-runtime/pkg/resource" diff --git a/pkg/kube/fake/failing_kube_client.go b/pkg/kube/fake/failing_kube_client.go index 0f7787f79..75d0c8de1 100644 --- a/pkg/kube/fake/failing_kube_client.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -19,6 +19,7 @@ package fake import ( "io" + "sync" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -49,6 +50,7 @@ type FailingKubeClient struct { WaitDuration time.Duration // RecordedWaitOptions stores the WaitOptions passed to GetWaiter for testing RecordedWaitOptions []kube.WaitOption + mu sync.Mutex } var _ kube.Interface = &FailingKubeClient{} @@ -158,9 +160,14 @@ func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) return f.GetWaiterWithOptions(ws) } -func (f *FailingKubeClient) GetWaiterWithOptions(ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) { - // Record the WaitOptions for testing +func (f *FailingKubeClient) appendRecordedWaitOptionsLocked(opts ...kube.WaitOption) { + f.mu.Lock() + defer f.mu.Unlock() f.RecordedWaitOptions = append(f.RecordedWaitOptions, opts...) +} + +func (f *FailingKubeClient) GetWaiterWithOptions(ws kube.WaitStrategy, opts ...kube.WaitOption) (kube.Waiter, error) { + f.appendRecordedWaitOptionsLocked(opts...) waiter, _ := f.PrintingKubeClient.GetWaiterWithOptions(ws, opts...) printingKubeWaiter, _ := waiter.(*PrintingKubeWaiter) return &FailingKubeWaiter{ diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index e3fa11576..40897f0a7 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -17,7 +17,6 @@ limitations under the License. package fake import ( - "fmt" "io" "strings" "time" @@ -133,7 +132,7 @@ func (p *PrintingKubeClient) GetPodList(_ string, _ metav1.ListOptions) (*v1.Pod // OutputContainerLogsForPodList implements KubeClient OutputContainerLogsForPodList. func (p *PrintingKubeClient) OutputContainerLogsForPodList(_ *v1.PodList, someNamespace string, _ func(namespace, pod, container string) io.Writer) error { - _, err := io.Copy(p.LogOutput, strings.NewReader(fmt.Sprintf("attempted to output logs for namespace: %s", someNamespace))) + _, err := io.Copy(p.LogOutput, strings.NewReader("attempted to output logs for namespace: "+someNamespace)) return err } diff --git a/pkg/kube/ready.go b/pkg/kube/ready.go index bfa98504c..196fe922a 100644 --- a/pkg/kube/ready.go +++ b/pkg/kube/ready.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "context" @@ -108,7 +108,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err return c.pausedAsReady, nil } // Find RS associated with deployment - newReplicaSet, err := deploymentutil.GetNewReplicaSet(currentDeployment, c.client.AppsV1()) + newReplicaSet, err := deploymentutil.GetNewReplicaSet(currentDeployment, c.client.AppsV1()) //nolint:contextcheck if err != nil || newReplicaSet == nil { return false, err } diff --git a/pkg/kube/ready_test.go b/pkg/kube/ready_test.go index db0d02cbe..3b80e95d2 100644 --- a/pkg/kube/ready_test.go +++ b/pkg/kube/ready_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "context" diff --git a/pkg/kube/resource.go b/pkg/kube/resource.go index d6d08d589..bc0fff9a2 100644 --- a/pkg/kube/resource.go +++ b/pkg/kube/resource.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import "k8s.io/cli-runtime/pkg/resource" diff --git a/pkg/kube/resource_policy.go b/pkg/kube/resource_policy.go index fb1089785..3a271877c 100644 --- a/pkg/kube/resource_policy.go +++ b/pkg/kube/resource_policy.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube // ResourcePolicyAnno is the annotation name for a resource policy const ResourcePolicyAnno = "helm.sh/resource-policy" diff --git a/pkg/kube/resource_test.go b/pkg/kube/resource_test.go index 41215a878..283b44945 100644 --- a/pkg/kube/resource_test.go +++ b/pkg/kube/resource_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "testing" diff --git a/pkg/kube/roundtripper.go b/pkg/kube/roundtripper.go index 52cb5bad2..e13f2103a 100644 --- a/pkg/kube/roundtripper.go +++ b/pkg/kube/roundtripper.go @@ -16,65 +16,9 @@ limitations under the License. package kube -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "strings" -) +import "helm.sh/helm/v4/pkg/kubeenv" -type RetryingRoundTripper struct { - Wrapped http.RoundTripper -} - -func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return rt.roundTrip(req, 1, nil) -} - -func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { - if retry < 0 { - return prevResp, nil - } - resp, rtErr := rt.Wrapped.RoundTrip(req) - if rtErr != nil { - return resp, rtErr - } - if resp.StatusCode < 500 { - return resp, rtErr - } - if resp.Header.Get("content-type") != "application/json" { - return resp, rtErr - } - b, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return resp, err - } - - var ke kubernetesError - r := bytes.NewReader(b) - err = json.NewDecoder(r).Decode(&ke) - r.Seek(0, io.SeekStart) - resp.Body = io.NopCloser(r) - if err != nil { - return resp, err - } - if ke.Code < 500 { - return resp, nil - } - // Matches messages like "etcdserver: leader changed" - if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { - return rt.roundTrip(req, retry-1, resp) - } - // Matches messages like "rpc error: code = Unknown desc = raft proposal dropped" - if strings.HasSuffix(ke.Message, "raft proposal dropped") { - return rt.roundTrip(req, retry-1, resp) - } - return resp, nil -} - -type kubernetesError struct { - Message string `json:"message"` - Code int `json:"code"` -} +// RetryingRoundTripper retries transient Kubernetes API server errors on a +// wrapped [http.RoundTripper]. The implementation lives in [kubeenv] so +// consumers can depend on that package without importing all of kube. +type RetryingRoundTripper = kubeenv.RetryingRoundTripper diff --git a/pkg/kube/statuswait.go b/pkg/kube/statuswait.go index 01024afa6..29de0af2b 100644 --- a/pkg/kube/statuswait.go +++ b/pkg/kube/statuswait.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v3/pkg/kube" +package kube import ( "context" @@ -68,6 +68,12 @@ func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) { }, nil } +func getStatusWatcher(dynamicClient dynamic.Interface, mapper meta.RESTMapper) *watcher.DefaultStatusWatcher { + sw := watcher.NewDefaultStatusWatcher(dynamicClient, mapper) + sw.ResyncPeriod = 3 * time.Minute + return sw +} + func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error { if timeout == 0 { timeout = DefaultStatusWatcherTimeout @@ -75,7 +81,7 @@ func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.D ctx, cancel := w.contextWithTimeout(w.watchUntilReadyCtx, timeout) defer cancel() w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) - sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + sw := getStatusWatcher(w.client, w.restMapper) jobSR := helmStatusReaders.NewCustomJobStatusReader(w.restMapper) podSR := helmStatusReaders.NewCustomPodStatusReader(w.restMapper) // We don't want to wait on any other resources as watchUntilReady is only for Helm hooks. @@ -97,7 +103,7 @@ func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) er ctx, cancel := w.contextWithTimeout(w.waitCtx, timeout) defer cancel() w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) - sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + sw := getStatusWatcher(w.client, w.restMapper) sw.StatusReader = statusreaders.NewStatusReader(w.restMapper, w.readers...) return w.wait(ctx, resourceList, sw) } @@ -109,7 +115,7 @@ func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Dura ctx, cancel := w.contextWithTimeout(w.waitWithJobsCtx, timeout) defer cancel() w.Logger().Debug("waiting for resources", "count", len(resourceList), "timeout", timeout) - sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + sw := getStatusWatcher(w.client, w.restMapper) newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper) readers := append([]engine.StatusReader(nil), w.readers...) readers = append(readers, newCustomJobStatusReader) @@ -125,7 +131,7 @@ func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Dur ctx, cancel := w.contextWithTimeout(w.waitForDeleteCtx, timeout) defer cancel() w.Logger().Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout) - sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper) + sw := getStatusWatcher(w.client, w.restMapper) return w.waitForDelete(ctx, resourceList, sw) } diff --git a/pkg/kube/statuswait_test.go b/pkg/kube/statuswait_test.go index d2dd57872..73a424720 100644 --- a/pkg/kube/statuswait_test.go +++ b/pkg/kube/statuswait_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "context" @@ -271,7 +271,7 @@ func getRuntimeObjFromManifests(t *testing.T, manifests []string) []runtime.Obje t.Helper() objects := []runtime.Object{} for _, manifest := range manifests { - m := make(map[string]interface{}) + m := make(map[string]any) err := yaml.Unmarshal([]byte(manifest), &m) assert.NoError(t, err) resource := &unstructured.Unstructured{Object: m} @@ -715,7 +715,7 @@ func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNam return true, nil, apierrors.NewForbidden( action.GetResource().GroupResource(), "", - fmt.Errorf("user does not have cluster-wide LIST permissions for cluster-scoped resources"), + errors.New("user does not have cluster-wide LIST permissions for cluster-scoped resources"), ) } if !config.allowedNamespaces[ns] { @@ -739,7 +739,7 @@ func setupRestrictedClient(fakeClient *dynamicfake.FakeDynamicClient, allowedNam return true, nil, apierrors.NewForbidden( action.GetResource().GroupResource(), "", - fmt.Errorf("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), + errors.New("user does not have cluster-wide WATCH permissions for cluster-scoped resources"), ) } if !config.allowedNamespaces[ns] { @@ -793,7 +793,7 @@ func TestStatusWaitRestrictedRBAC(t *testing.T) { 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")}, + expectErrs: []error{errors.New("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) }, @@ -802,7 +802,7 @@ func TestStatusWaitRestrictedRBAC(t *testing.T) { 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")}, + expectErrs: []error{errors.New("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) }, @@ -892,7 +892,7 @@ func TestStatusWaitMixedResources(t *testing.T) { 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")}, + expectErrs: []error{errors.New("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) }, @@ -901,7 +901,7 @@ func TestStatusWaitMixedResources(t *testing.T) { 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")}, + expectErrs: []error{errors.New("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) }, @@ -910,7 +910,7 @@ func TestStatusWaitMixedResources(t *testing.T) { 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")}, + expectErrs: []error{errors.New("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) }, @@ -1680,8 +1680,6 @@ func TestMethodContextOverridesGeneralContext(t *testing.T) { t.Run("method-specific context overrides general context for WaitForDelete", func(t *testing.T) { t.Parallel() c := newTestClient(t) - timeout := time.Second - timeUntilPodDelete := time.Millisecond * 500 fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) fakeMapper := testutil.NewFakeRESTMapper( v1.SchemeGroupVersion.WithKind("Pod"), @@ -1698,27 +1696,14 @@ func TestMethodContextOverridesGeneralContext(t *testing.T) { waitForDeleteCtx: context.Background(), // Not cancelled - should be used } + // Use a non-existent resource: WaitForDelete should return immediately since + // the pod is already in the desired "deleted" state. + // This also validates context selection: if generalCtx (cancelled) were + // incorrectly used instead of waitForDeleteCtx, the watch context would be + // immediately cancelled and the call would return a context error. objs := getRuntimeObjFromManifests(t, []string{podCurrentManifest}) - for _, obj := range objs { - u := obj.(*unstructured.Unstructured) - gvr := getGVR(t, fakeMapper, u) - err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace()) - require.NoError(t, err) - } - - // Schedule deletion - for _, obj := range objs { - u := obj.(*unstructured.Unstructured) - gvr := getGVR(t, fakeMapper, u) - 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, objs) - err := sw.WaitForDelete(resourceList, timeout) + err := sw.WaitForDelete(resourceList, time.Second) // Should succeed because method context is used and it's not cancelled assert.NoError(t, err) }) diff --git a/pkg/kube/wait.go b/pkg/kube/wait.go index 9a276a459..b5e91d8f3 100644 --- a/pkg/kube/wait.go +++ b/pkg/kube/wait.go @@ -14,10 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube // import "helm.sh/helm/v4/pkg/kube" +package kube import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -107,7 +108,8 @@ func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) boo slog.String("resource", resource.Name), slog.Any("error", err), ) - if ev, ok := err.(*apierrors.StatusError); ok { + ev := &apierrors.StatusError{} + if errors.As(err, &ev) { statusCode := ev.Status().Code retryable := hw.isRetryableHTTPStatusCode(statusCode) slog.Debug( @@ -244,7 +246,7 @@ func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.In // Use a selector on the name of the resource. This should be unique for the // given version and kind - selector, err := fields.ParseSelector(fmt.Sprintf("metadata.name=%s", info.Name)) + selector, err := fields.ParseSelector("metadata.name=" + info.Name) if err != nil { return err } diff --git a/pkg/kube/wait_test.go b/pkg/kube/wait_test.go index d96f2c486..73270b5a3 100644 --- a/pkg/kube/wait_test.go +++ b/pkg/kube/wait_test.go @@ -17,7 +17,7 @@ limitations under the License. package kube import ( - "fmt" + "errors" "net/http" "strings" "testing" @@ -39,7 +39,7 @@ import ( func TestSelectorsForObject(t *testing.T) { tests := []struct { name string - object interface{} + object any expectError bool errorContains string expectedLabels map[string]string @@ -299,7 +299,7 @@ func TestLegacyWaiter_waitForPodSuccess(t *testing.T) { done, err := lw.waitForPodSuccess(tt.obj, "foo") if tt.wantErr { if err == nil { - t.Errorf("expected error, got none") + t.Error("expected error, got none") } else if !strings.Contains(err.Error(), tt.errMessage) { t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) } @@ -391,7 +391,7 @@ func TestLegacyWaiter_waitForJob(t *testing.T) { done, err := lw.waitForJob(tt.obj, "test-job") if tt.wantErr { if err == nil { - t.Errorf("expected error, got none") + t.Error("expected error, got none") } else if !strings.Contains(err.Error(), tt.errMessage) { t.Errorf("expected error to contain %q, got %q", tt.errMessage, err.Error()) } @@ -451,7 +451,7 @@ func TestLegacyWaiter_isRetryableError(t *testing.T) { }, { name: "non-status error", - err: fmt.Errorf("some generic error"), + err: errors.New("some generic error"), wantRetry: true, }, } diff --git a/pkg/kubeenv/roundtripper.go b/pkg/kubeenv/roundtripper.go new file mode 100644 index 000000000..e00f93984 --- /dev/null +++ b/pkg/kubeenv/roundtripper.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 kubeenv holds small, cycle-free Kubernetes client helpers shared by +// higher-level packages (for example pkg/cli and pkg/kube). +package kubeenv + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +// RetryingRoundTripper retries transient Kubernetes API server errors on a +// wrapped [http.RoundTripper]. +type RetryingRoundTripper struct { + Wrapped http.RoundTripper +} + +func (rt *RetryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return rt.roundTrip(req, 1, nil) +} + +func (rt *RetryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp *http.Response) (*http.Response, error) { + if retry < 0 { + return prevResp, nil + } + resp, rtErr := rt.Wrapped.RoundTrip(req) + if rtErr != nil { + return resp, rtErr + } + if resp.StatusCode < 500 { + return resp, rtErr + } + if resp.Header.Get("content-type") != "application/json" { + return resp, rtErr + } + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return resp, err + } + + var ke kubernetesError + r := bytes.NewReader(b) + err = json.NewDecoder(r).Decode(&ke) + r.Seek(0, io.SeekStart) + resp.Body = io.NopCloser(r) + if err != nil { + return resp, err + } + if ke.Code < 500 { + return resp, nil + } + // Matches messages like "etcdserver: leader changed" + if strings.HasSuffix(ke.Message, "etcdserver: leader changed") { + return rt.roundTrip(req, retry-1, resp) + } + // Matches messages like "rpc error: code = Unknown desc = raft proposal dropped" + if strings.HasSuffix(ke.Message, "raft proposal dropped") { + return rt.roundTrip(req, retry-1, resp) + } + return resp, nil +} + +type kubernetesError struct { + Message string `json:"message"` + Code int `json:"code"` +} diff --git a/pkg/kube/roundtripper_test.go b/pkg/kubeenv/roundtripper_test.go similarity index 93% rename from pkg/kube/roundtripper_test.go rename to pkg/kubeenv/roundtripper_test.go index 96602c1f4..6f0caf6f8 100644 --- a/pkg/kube/roundtripper_test.go +++ b/pkg/kubeenv/roundtripper_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package kube +package kubeenv import ( "encoding/json" @@ -25,6 +25,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type fakeRoundTripper struct { @@ -134,15 +135,15 @@ func TestRetryingRoundTripper_RoundTrip(t *testing.T) { rt := RetryingRoundTripper{ Wrapped: fakeRT, } - req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com", http.NoBody) + require.NoError(t, err) resp, err := rt.RoundTrip(req) if tt.expectedErr != "" { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedErr) + require.ErrorContains(t, err, tt.expectedErr) return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.expectedCode, resp.StatusCode) assert.Equal(t, tt.expectedCalls, fakeRT.calls) diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index 57af1ad42..9b3e774cb 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -25,9 +25,9 @@ import ( "os" "strings" - "github.com/ProtonMail/go-crypto/openpgp" //nolint - "github.com/ProtonMail/go-crypto/openpgp/clearsign" //nolint - "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" "sigs.k8s.io/yaml" ) @@ -332,7 +332,7 @@ func parseMessageBlock(data []byte) (*SumCollection, error) { // // This is the generic version that can work with any metadata type. // The metadata parameter should be a pointer to a struct that can be unmarshaled from YAML. -func ParseMessageBlock(data []byte, metadata interface{}, sums *SumCollection) error { +func ParseMessageBlock(data []byte, metadata any, sums *SumCollection) error { parts := bytes.Split(data, []byte("\n...\n")) if len(parts) < 2 { return errors.New("message block must have at least two parts") @@ -388,7 +388,7 @@ func DigestFile(filename string) (string, error) { func Digest(in io.Reader) (string, error) { hash := crypto.SHA256.New() if _, err := io.Copy(hash, in); err != nil { - return "", nil + return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 1985e9eea..8784e6c12 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -17,15 +17,15 @@ package provenance import ( "crypto" - "fmt" + "errors" "io" "os" "path/filepath" "strings" "testing" - pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" //nolint - "github.com/ProtonMail/go-crypto/openpgp/packet" //nolint + pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors" + "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" @@ -134,7 +134,7 @@ func TestParseMessageBlock(t *testing.T) { } if hash, ok := sc.Files["hashtest-1.2.3.tgz"]; !ok { - t.Errorf("hashtest file not found in Files") + t.Error("hashtest file not found in Files") } else if hash != "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888" { t.Errorf("Unexpected hash: %q", hash) } @@ -330,7 +330,7 @@ func (s failSigner) Public() crypto.PublicKey { } func (s failSigner) Sign(_ io.Reader, _ []byte, _ crypto.SignerOpts) ([]byte, error) { - return nil, fmt.Errorf("always fails") + return nil, errors.New("always fails") } func TestClearSignError(t *testing.T) { diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index f03188391..2a12e09b4 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -76,7 +76,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { } var pushOpts []registry.PushOption - provRef := fmt.Sprintf("%s.prov", chartRef) + provRef := chartRef + ".prov" if _, err := os.Stat(provRef); err == nil { provBytes, err := os.ReadFile(provRef) if err != nil { @@ -86,7 +86,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { } ref := fmt.Sprintf("%s:%s", - path.Join(strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme)), meta.Metadata.Name), + path.Join(strings.TrimPrefix(href, registry.OCIScheme+"://"), meta.Metadata.Name), meta.Metadata.Version) // The time the chart was "created" is semantically the time the chart archive file was last written(modified) diff --git a/pkg/registry/chart.go b/pkg/registry/chart.go index b00fc616d..d70b7e912 100644 --- a/pkg/registry/chart.go +++ b/pkg/registry/chart.go @@ -43,14 +43,12 @@ func extractChartMeta(chartData []byte) (*chart.Metadata, error) { // generateOCIAnnotations will generate OCI annotations to include within the OCI manifest func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string { - // Get annotations from Chart attributes ociAnnotations := generateChartOCIAnnotations(meta, creationTime) // Copy Chart annotations annotations: for chartAnnotationKey, chartAnnotationValue := range meta.Annotations { - // Avoid overriding key properties for _, immutableOciKey := range immutableOciAnnotations { if immutableOciKey == chartAnnotationKey { @@ -88,7 +86,6 @@ func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[ var maintainerSb strings.Builder for maintainerIdx, maintainer := range meta.Maintainers { - if len(maintainer.Name) > 0 { maintainerSb.WriteString(maintainer.Name) } @@ -102,11 +99,9 @@ func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[ if maintainerIdx < len(meta.Maintainers)-1 { maintainerSb.WriteString(", ") } - } chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationAuthors, maintainerSb.String()) - } return chartOCIAnnotations @@ -114,7 +109,6 @@ func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[ // addToMap takes an existing map and adds an item if the value is not empty func addToMap(inputMap map[string]string, newKey string, newValue string) map[string]string { - // Add item to map if its if len(strings.TrimSpace(newValue)) > 0 { inputMap[newKey] = newValue diff --git a/pkg/registry/chart_test.go b/pkg/registry/chart_test.go index 77ccdaab7..23a32506d 100644 --- a/pkg/registry/chart_test.go +++ b/pkg/registry/chart_test.go @@ -17,17 +17,17 @@ limitations under the License. package registry // import "helm.sh/helm/v4/pkg/registry" import ( - "reflect" "testing" "time" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" chart "helm.sh/helm/v4/pkg/chart/v2" ) func TestGenerateOCIChartAnnotations(t *testing.T) { - nowString := time.Now().Format(time.RFC3339) tests := []struct { @@ -147,18 +147,12 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { } for _, tt := range tests { - result := generateChartOCIAnnotations(tt.chart, nowString) - - if !reflect.DeepEqual(tt.expect, result) { - t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) - } - + assert.Equal(t, tt.expect, result, tt.name) } } func TestGenerateOCIAnnotations(t *testing.T) { - nowString := time.Now().Format(time.RFC3339) tests := []struct { @@ -221,18 +215,12 @@ func TestGenerateOCIAnnotations(t *testing.T) { } for _, tt := range tests { - result := generateOCIAnnotations(tt.chart, nowString) - - if !reflect.DeepEqual(tt.expect, result) { - t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result) - } - + assert.Equal(t, tt.expect, result, tt.name) } } func TestGenerateOCICreatedAnnotations(t *testing.T) { - nowTime := time.Now() nowTimeString := nowTime.Format(time.RFC3339) @@ -244,31 +232,23 @@ func TestGenerateOCICreatedAnnotations(t *testing.T) { result := generateOCIAnnotations(testChart, nowTimeString) // Check that created annotation exists - if _, ok := result[ocispec.AnnotationCreated]; !ok { - t.Errorf("%s annotation not created", ocispec.AnnotationCreated) - } + _, ok := result[ocispec.AnnotationCreated] + assert.True(t, ok, "%s annotation not created", ocispec.AnnotationCreated) // Verify value of created artifact in RFC3339 format - if _, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { - t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) - } + _, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]) + assert.NoError(t, err, "%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) // Verify default creation time set result = generateOCIAnnotations(testChart, "") // Check that created annotation exists - if _, ok := result[ocispec.AnnotationCreated]; !ok { - t.Errorf("%s annotation not created", ocispec.AnnotationCreated) - } + _, ok = result[ocispec.AnnotationCreated] + require.True(t, ok, "%s annotation not created", ocispec.AnnotationCreated) - if createdTimeAnnotation, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { - t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) - - // Verify creation annotation after time test began - if !nowTime.Before(createdTimeAnnotation) { - t.Errorf("%s annotation with value '%s' not configured properly. Annotation value is not after %s", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated], nowTimeString) - } - - } + createdTimeAnnotation, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]) + require.NoError(t, err, "%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) + // Verify creation annotation after (or equals) time test began + assert.False(t, nowTime.Before(createdTimeAnnotation), "%s annotation with value '%s' not configured properly. Annotation value is not after %s", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated], nowTimeString) } diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 72ba0b540..77f3d3efd 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -202,13 +202,15 @@ func ClientOptCredentialsFile(credentialsFile string) ClientOption { } } -// ClientOptHTTPClient returns a function that sets the httpClient setting on a client options set +// ClientOptHTTPClient returns a function that sets the HTTP client for the registry client. func ClientOptHTTPClient(httpClient *http.Client) ClientOption { return func(client *Client) { client.httpClient = httpClient } } +// ClientOptPlainHTTP returns a function that enables plain HTTP (non-TLS) +// communication for the registry client. func ClientOptPlainHTTP() ClientOption { return func(c *Client) { c.plainHTTP = true @@ -236,7 +238,7 @@ func warnIfHostHasPath(host string) bool { return false } -// Login logs into a registry +// Login authenticates the client with a remote OCI registry using the provided host and options. func (c *Client) Login(host string, options ...LoginOption) error { for _, option := range options { option(&loginOperation{host, c}) @@ -282,7 +284,8 @@ func LoginOptBasicAuth(username string, password string) LoginOption { } } -// LoginOptPlainText returns a function that allows plaintext (HTTP) login +// LoginOptPlainText returns a function that enables plaintext (HTTP) login +// instead of HTTPS for the registry client. func LoginOptPlainText(isPlainText bool) LoginOption { return func(o *loginOperation) { o.client.plainHTTP = isPlainText @@ -816,7 +819,6 @@ func (c *Client) Tags(ref string) ([]string, error) { } return tags, nil - } // Resolve a reference to a descriptor. @@ -882,7 +884,7 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur tag = version } else { // Retrieve list of repository tags - tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme))) + tags, err := c.Tags(strings.TrimPrefix(ref, OCIScheme+"://")) if err != nil { return "", nil, err } @@ -910,7 +912,6 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *ur func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store, configDescriptor ocispec.Descriptor, layers []ocispec.Descriptor, ociAnnotations map[string]string, parsedRef reference) (ocispec.Descriptor, error) { - manifest := ocispec.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, Config: configDescriptor, diff --git a/pkg/registry/client_http_test.go b/pkg/registry/client_http_test.go index 546d837d5..3eb74c541 100644 --- a/pkg/registry/client_http_test.go +++ b/pkg/registry/client_http_test.go @@ -17,8 +17,6 @@ limitations under the License. package registry import ( - "errors" - "fmt" "os" "testing" @@ -44,12 +42,12 @@ func (suite *HTTPRegistryClientTestSuite) Test_0_Login() { err := suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth("badverybad", "ohsobad"), LoginOptPlainText(true)) - suite.NotNil(err, "error logging into registry with bad credentials") + suite.Require().Error(err, "error logging into registry with bad credentials") err = suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth(testUsername, testPassword), LoginOptPlainText(true)) - suite.Nil(err, "no error logging into registry with good credentials") + suite.Require().NoError(err, "no error logging into registry with good credentials") } func (suite *HTTPRegistryClientTestSuite) Test_1_Push() { @@ -65,19 +63,19 @@ func (suite *HTTPRegistryClientTestSuite) Test_3_Tags() { } func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() { - ref := fmt.Sprintf("%s/testrepo/supposedlysafechart:9.9.9", suite.CompromisedRegistryHost) + ref := suite.CompromisedRegistryHost + "/testrepo/supposedlysafechart:9.9.9" // returns content that does not match the expected digest _, err := suite.RegistryClient.Pull(ref) - suite.NotNil(err) - suite.True(errors.Is(err, content.ErrMismatchedDigest)) + suite.Require().Error(err) + suite.ErrorIs(err, content.ErrMismatchedDigest) } func (suite *HTTPRegistryClientTestSuite) Test_5_ImageIndex() { - ref := fmt.Sprintf("%s/testrepo/image-index:0.1.0", suite.FakeRegistryHost) + ref := suite.FakeRegistryHost + "/testrepo/image-index:0.1.0" _, err := suite.RegistryClient.Pull(ref) - suite.Nil(err) + suite.Require().NoError(err) } func TestHTTPRegistryClientTestSuite(t *testing.T) { diff --git a/pkg/registry/client_insecure_tls_test.go b/pkg/registry/client_insecure_tls_test.go index 2774f5e6f..8c4e928e4 100644 --- a/pkg/registry/client_insecure_tls_test.go +++ b/pkg/registry/client_insecure_tls_test.go @@ -41,12 +41,12 @@ func (suite *InsecureTLSRegistryClientTestSuite) Test_0_Login() { err := suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth("badverybad", "ohsobad"), LoginOptInsecure(true)) - suite.NotNil(err, "error logging into registry with bad credentials") + suite.Require().Error(err, "error logging into registry with bad credentials") err = suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth(testUsername, testPassword), LoginOptInsecure(true)) - suite.Nil(err, "no error logging into registry with good credentials") + suite.Require().NoError(err, "no error logging into registry with good credentials") } func (suite *InsecureTLSRegistryClientTestSuite) Test_1_Push() { @@ -65,11 +65,11 @@ func (suite *InsecureTLSRegistryClientTestSuite) Test_4_Logout() { err := suite.RegistryClient.Logout("this-host-aint-real:5000") if err != nil { // credential backend for mac generates an error - suite.NotNil(err, "failed to delete the credential for this-host-aint-real:5000") + suite.Require().Error(err, "failed to delete the credential for this-host-aint-real:5000") } err = suite.RegistryClient.Logout(suite.DockerRegistryHost) - suite.Nil(err, "no error logging out of registry") + suite.Require().NoError(err, "no error logging out of registry") } func TestInsecureTLSRegistryClientTestSuite(t *testing.T) { diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 98a8b2ea3..97b7c7c4c 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -25,6 +25,7 @@ import ( "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "oras.land/oras-go/v2/content/memory" ) @@ -77,22 +78,15 @@ func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { ClientOptWriter(io.Discard), ClientOptCredentialsFile(credFile), ) - if err != nil { - t.Fatalf("NewClient error: %v", err) - } + require.NoError(t, err, "NewClient error") - if c.authorizer == nil || c.authorizer.ForceAttemptOAuth2 { - t.Fatalf("expected ForceAttemptOAuth2 default to be false") - } + require.NotNil(t, c.authorizer) + require.False(t, c.authorizer.ForceAttemptOAuth2, "expected ForceAttemptOAuth2 default to be false") // Call Login with plain HTTP against our test server - if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err != nil { - t.Fatalf("Login error: %v", err) - } + require.NoError(t, c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")), "Login error") - if c.authorizer.ForceAttemptOAuth2 { - t.Errorf("ForceAttemptOAuth2 should be false after successful Login") - } + assert.False(t, c.authorizer.ForceAttemptOAuth2, "ForceAttemptOAuth2 should be false after successful Login") } // Verifies that Login restores ForceAttemptOAuth2 to false even when ping fails. @@ -109,16 +103,12 @@ func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { ClientOptWriter(io.Discard), ClientOptCredentialsFile(credFile), ) - if err != nil { - t.Fatalf("NewClient error: %v", err) - } + require.NoError(t, err, "NewClient error") // Invoke Login, expect an error but ForceAttemptOAuth2 must end false _ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")) - if c.authorizer.ForceAttemptOAuth2 { - t.Errorf("ForceAttemptOAuth2 should be false after failed Login") - } + assert.False(t, c.authorizer.ForceAttemptOAuth2, "ForceAttemptOAuth2 should be false after failed Login") } // TestWarnIfHostHasPath verifies that warnIfHostHasPath correctly detects path components. @@ -159,10 +149,7 @@ func TestWarnIfHostHasPath(t *testing.T) { 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) - } + assert.Equal(t, tt.wantWarn, warnIfHostHasPath(tt.host)) }) } } diff --git a/pkg/registry/client_tls_test.go b/pkg/registry/client_tls_test.go index ddeeb3b66..2bea0377f 100644 --- a/pkg/registry/client_tls_test.go +++ b/pkg/registry/client_tls_test.go @@ -43,26 +43,26 @@ func (suite *TLSRegistryClientTestSuite) Test_0_Login() { err := suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth("badverybad", "ohsobad"), LoginOptTLSClientConfig(tlsCert, tlsKey, tlsCA)) - suite.NotNil(err, "error logging into registry with bad credentials") + suite.Require().Error(err, "error logging into registry with bad credentials") err = suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth(testUsername, testPassword), LoginOptTLSClientConfig(tlsCert, tlsKey, tlsCA)) - suite.Nil(err, "no error logging into registry with good credentials") + suite.Require().NoError(err, "no error logging into registry with good credentials") } func (suite *TLSRegistryClientTestSuite) Test_1_Login() { err := suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth("badverybad", "ohsobad"), LoginOptTLSClientConfigFromConfig(&tls.Config{})) - suite.NotNil(err, "error logging into registry with bad credentials") + suite.Require().Error(err, "error logging into registry with bad credentials") // Create a *tls.Config from tlsCert, tlsKey, and tlsCA. cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey) - suite.Nil(err, "error loading x509 key pair") + suite.Require().NoError(err, "error loading x509 key pair") rootCAs := x509.NewCertPool() caCert, err := os.ReadFile(tlsCA) - suite.Nil(err, "error reading CA certificate") + suite.Require().NoError(err, "error reading CA certificate") rootCAs.AppendCertsFromPEM(caCert) conf := &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -72,7 +72,7 @@ func (suite *TLSRegistryClientTestSuite) Test_1_Login() { err = suite.RegistryClient.Login(suite.DockerRegistryHost, LoginOptBasicAuth(testUsername, testPassword), LoginOptTLSClientConfigFromConfig(conf)) - suite.Nil(err, "no error logging into registry with good credentials") + suite.Require().NoError(err, "no error logging into registry with good credentials") } func (suite *TLSRegistryClientTestSuite) Test_1_Push() { @@ -91,11 +91,11 @@ func (suite *TLSRegistryClientTestSuite) Test_4_Logout() { err := suite.RegistryClient.Logout("this-host-aint-real:5000") if err != nil { // credential backend for mac generates an error - suite.NotNil(err, "failed to delete the credential for this-host-aint-real:5000") + suite.Require().Error(err, "failed to delete the credential for this-host-aint-real:5000") } err = suite.RegistryClient.Logout(suite.DockerRegistryHost) - suite.Nil(err, "no error logging out of registry") + suite.Require().NoError(err, "no error logging out of registry") } func TestTLSRegistryClientTestSuite(t *testing.T) { diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go index e4b4afa24..959faaa7d 100644 --- a/pkg/registry/plugin.go +++ b/pkg/registry/plugin.go @@ -18,6 +18,7 @@ package registry import ( "encoding/json" + "errors" "fmt" "strings" @@ -190,7 +191,7 @@ func GetPluginName(source string) (string, error) { // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name" repository := ref.Repository if repository == "" { - return "", fmt.Errorf("invalid OCI reference: missing repository") + return "", errors.New("invalid OCI reference: missing repository") } // Get the last part of the repository path as the plugin name diff --git a/pkg/registry/plugin_test.go b/pkg/registry/plugin_test.go index f8525829c..bb557c5b8 100644 --- a/pkg/registry/plugin_test.go +++ b/pkg/registry/plugin_test.go @@ -18,6 +18,9 @@ package registry import ( "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetPluginName(t *testing.T) { @@ -74,20 +77,12 @@ func TestGetPluginName(t *testing.T) { pluginName, err := GetPluginName(tt.source) if tt.expectErr { - if err == nil { - t.Errorf("expected error but got none") - } + assert.Error(t, err, "expected error but got none") return } - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if pluginName != tt.expected { - t.Errorf("expected plugin name %q, got %q", tt.expected, pluginName) - } + require.NoError(t, err) + assert.Equal(t, tt.expected, pluginName) }) } } diff --git a/pkg/registry/reference.go b/pkg/registry/reference.go index 9a98cf5c9..455792539 100644 --- a/pkg/registry/reference.go +++ b/pkg/registry/reference.go @@ -17,7 +17,6 @@ limitations under the License. package registry import ( - "fmt" "strings" "oras.land/oras-go/v2/registry" @@ -80,5 +79,5 @@ func (r *reference) String() string { // IsOCI determines whether a URL is to be treated as an OCI URL func IsOCI(url string) bool { - return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme)) + return strings.HasPrefix(url, OCIScheme+"://") } diff --git a/pkg/registry/reference_test.go b/pkg/registry/reference_test.go index b6872cc37..f1a87fefc 100644 --- a/pkg/registry/reference_test.go +++ b/pkg/registry/reference_test.go @@ -16,31 +16,21 @@ limitations under the License. package registry -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func verify(t *testing.T, actual reference, registry, repository, tag, digest string) { t.Helper() - if registry != actual.orasReference.Registry { - t.Errorf("Oras reference registry expected %v actual %v", registry, actual.Registry) - } - if repository != actual.orasReference.Repository { - t.Errorf("Oras reference repository expected %v actual %v", repository, actual.Repository) - } - if tag != actual.orasReference.Reference { - t.Errorf("Oras reference reference expected %v actual %v", tag, actual.Tag) - } - if registry != actual.Registry { - t.Errorf("Registry expected %v actual %v", registry, actual.Registry) - } - if repository != actual.Repository { - t.Errorf("Repository expected %v actual %v", repository, actual.Repository) - } - if tag != actual.Tag { - t.Errorf("Tag expected %v actual %v", tag, actual.Tag) - } - if digest != actual.Digest { - t.Errorf("Digest expected %v actual %v", digest, actual.Digest) - } + assert.Equal(t, registry, actual.orasReference.Registry, "Oras reference registry") + assert.Equal(t, repository, actual.orasReference.Repository, "Oras reference repository") + assert.Equal(t, tag, actual.orasReference.Reference, "Oras reference reference") + assert.Equal(t, registry, actual.Registry, "Registry") + assert.Equal(t, repository, actual.Repository, "Repository") + assert.Equal(t, tag, actual.Tag, "Tag") + assert.Equal(t, digest, actual.Digest, "Digest") expectedString := registry if repository != "" { expectedString = expectedString + "/" + repository @@ -50,51 +40,35 @@ func verify(t *testing.T, actual reference, registry, repository, tag, digest st } else { expectedString = expectedString + "@" + digest } - if actual.String() != expectedString { - t.Errorf("String expected %s actual %s", expectedString, actual.String()) - } + assert.Equal(t, expectedString, actual.String(), "String") } func TestNewReference(t *testing.T) { actual, err := newReference("registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") actual, err = newReference("oci://registry.example.com/repository:1.0@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "registry.example.com", "repository", "1.0", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") actual, err = newReference("a/b:1@c") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "a", "b", "1", "c") actual, err = newReference("a/b:@") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "a", "b", "", "") actual, err = newReference("registry.example.com/repository:1.0+001") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "registry.example.com", "repository", "1.0_001", "") actual, err = newReference("thing:1.0") - if err == nil { - t.Errorf("Expect error error %v", err) - } + assert.Error(t, err) verify(t, actual, "", "", "", "") actual, err = newReference("registry.example.com/the/repository@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") - if err != nil { - t.Errorf("Unexpected error %v", err) - } + assert.NoError(t, err) verify(t, actual, "registry.example.com", "the/repository", "", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888") } diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index c82b165bc..dc8118c6e 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -18,7 +18,6 @@ package registry import ( "bytes" - "context" "crypto/tls" "fmt" "io" @@ -107,26 +106,27 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool) { TLSClientConfig: tlsConf, }, } - suite.Nil(err, "no error loading tls config") + suite.Require().NoError(err, "no error loading tls config") opts = append(opts, ClientOptHTTPClient(httpClient)) } else { opts = append(opts, ClientOptPlainHTTP()) } suite.RegistryClient, err = NewClient(opts...) - suite.Nil(err, "no error creating registry client") + suite.Require().NoError(err, "no error creating registry client") // create htpasswd file (w BCrypt, which is required) pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) - suite.Nil(err, "no error generating bcrypt password for test htpasswd file") + suite.Require().NoError(err, "no error generating bcrypt password for test htpasswd file") htpasswdPath := filepath.Join(suite.WorkspaceDir, testHtpasswdFileBasename) err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0644) - suite.Nil(err, "no error creating test htpasswd file") + suite.Require().NoError(err, "no error creating test htpasswd file") // Registry config config := &configuration.Configuration{} - ln, err := net.Listen("tcp", "127.0.0.1:0") - suite.Nil(err, "no error finding free port for test registry") + lnCfg := net.ListenConfig{} + ln, err := lnCfg.Listen(suite.T().Context(), "tcp", "127.0.0.1:0") + suite.Require().NoError(err, "no error finding free port for test registry") defer func() { _ = ln.Close() }() // Change the registry host to another host which is not localhost. @@ -137,7 +137,7 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool) { config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second - config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}} config.Auth = configuration.Auth{ "htpasswd": configuration.Parameters{ @@ -158,8 +158,8 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool) { config.HTTP.TLS.ClientCAs = []string{tlsCA} } } - suite.dockerRegistry, err = registry.NewRegistry(context.Background(), config) - suite.Nil(err, "no error creating test registry") + suite.dockerRegistry, err = registry.NewRegistry(suite.T().Context(), config) + suite.Require().NoError(err, "no error creating test registry") suite.FakeRegistryHost = initFakeRegistryTestServer() suite.CompromisedRegistryHost = initCompromisedRegistryTestServer() @@ -170,7 +170,7 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool) { func teardown(suite *TestRegistry) { if suite.dockerRegistry != nil { - _ = suite.dockerRegistry.Shutdown(context.Background()) + _ = suite.dockerRegistry.Shutdown(suite.T().Context()) } } @@ -209,7 +209,7 @@ func initCompromisedRegistryTestServer() string { })) u, _ := url.Parse(s.URL) - return fmt.Sprintf("localhost:%s", u.Port()) + return "localhost:" + u.Port() } func initFakeRegistryTestServer() string { @@ -376,71 +376,70 @@ func initFakeRegistryTestServer() string { })) u, _ := url.Parse(s.URL) - return fmt.Sprintf("localhost:%s", u.Port()) + return "localhost:" + u.Port() } func testPush(suite *TestRegistry) { - testingChartCreationTime := "1977-09-02T22:04:05Z" // Bad bytes - ref := fmt.Sprintf("%s/testrepo/testchart:1.2.3", suite.DockerRegistryHost) + ref := suite.DockerRegistryHost + "/testrepo/testchart:1.2.3" _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptCreationTime(testingChartCreationTime)) - suite.NotNil(err, "error pushing non-chart bytes") + suite.Require().Error(err, "error pushing non-chart bytes") // Load a test chart chartData, err := os.ReadFile("../repo/v1/repotest/testdata/examplechart-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err := extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") // non-strict ref (chart name) ref = fmt.Sprintf("%s/testrepo/boop:%s", suite.DockerRegistryHost, meta.Version) _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) - suite.NotNil(err, "error pushing non-strict ref (bad basename)") + suite.Require().Error(err, "error pushing non-strict ref (bad basename)") // non-strict ref (chart name), with strict mode disabled _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptCreationTime(testingChartCreationTime)) - suite.Nil(err, "no error pushing non-strict ref (bad basename), with strict mode disabled") + suite.Require().NoError(err, "no error pushing non-strict ref (bad basename), with strict mode disabled") // non-strict ref (chart version) ref = fmt.Sprintf("%s/testrepo/%s:latest", suite.DockerRegistryHost, meta.Name) _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) - suite.NotNil(err, "error pushing non-strict ref (bad tag)") + suite.Require().Error(err, "error pushing non-strict ref (bad tag)") // non-strict ref (chart version), with strict mode disabled _, err = suite.RegistryClient.Push(chartData, ref, PushOptStrictMode(false), PushOptCreationTime(testingChartCreationTime)) - suite.Nil(err, "no error pushing non-strict ref (bad tag), with strict mode disabled") + suite.Require().NoError(err, "no error pushing non-strict ref (bad tag), with strict mode disabled") // basic push, good ref chartData, err = os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err = extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime)) - suite.Nil(err, "no error pushing good ref") + suite.Require().NoError(err, "no error pushing good ref") _, err = suite.RegistryClient.Pull(ref) - suite.Nil(err, "no error pulling a simple chart") + suite.Require().NoError(err, "no error pulling a simple chart") // Load another test chart chartData, err = os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err = extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") // Load prov file provData, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz.prov") - suite.Nil(err, "no error loading test prov") + suite.Require().NoError(err, "no error loading test prov") // push with prov ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) result, err := suite.RegistryClient.Push(chartData, ref, PushOptProvData(provData), PushOptCreationTime(testingChartCreationTime)) - suite.Nil(err, "no error pushing good ref with prov") + suite.Require().NoError(err, "no error pushing good ref with prov") _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true)) - suite.Nil(err, "no error pulling a simple chart") + suite.Require().NoError(err, "no error pulling a simple chart") // Validate the output // Note: these digests/sizes etc may change if the test chart/prov files are modified, @@ -468,52 +467,52 @@ func testPush(suite *TestRegistry) { func testPull(suite *TestRegistry) { // bad/missing ref - ref := fmt.Sprintf("%s/testrepo/no-existy:1.2.3", suite.DockerRegistryHost) + ref := suite.DockerRegistryHost + "/testrepo/no-existy:1.2.3" _, err := suite.RegistryClient.Pull(ref) - suite.NotNil(err, "error on bad/missing ref") + suite.Require().Error(err, "error on bad/missing ref") // Load test chart (to build ref pushed in previous test) chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err := extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) // Simple pull, chart only _, err = suite.RegistryClient.Pull(ref) - suite.Nil(err, "no error pulling a simple chart") + suite.Require().NoError(err, "no error pulling a simple chart") // Simple pull with prov (no prov uploaded) _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true)) - suite.NotNil(err, "error pulling a chart with prov when no prov exists") + suite.Require().Error(err, "error pulling a chart with prov when no prov exists") // Simple pull with prov, ignoring missing prov _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true), PullOptIgnoreMissingProv(true)) - suite.Nil(err, + suite.Require().NoError(err, "no error pulling a chart with prov when no prov exists, ignoring missing") // Load test chart (to build ref pushed in previous test) chartData, err = os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err = extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") ref = fmt.Sprintf("%s/testrepo/%s:%s", suite.DockerRegistryHost, meta.Name, meta.Version) // Load prov file provData, err := os.ReadFile("../downloader/testdata/signtest-0.1.0.tgz.prov") - suite.Nil(err, "no error loading test prov") + suite.Require().NoError(err, "no error loading test prov") // no chart and no prov causes error _, err = suite.RegistryClient.Pull(ref, PullOptWithChart(false), PullOptWithProv(false)) - suite.NotNil(err, "error on both no chart and no prov") + suite.Require().Error(err, "error on both no chart and no prov") // full pull with chart and prov result, err := suite.RegistryClient.Pull(ref, PullOptWithProv(true)) - suite.Require().Nil(err, "no error pulling a chart with prov") + suite.Require().NoError(err, "no error pulling a chart with prov") // Validate the output // Note: these digests/sizes etc may change if the test chart/prov files are modified, @@ -548,13 +547,13 @@ func testPull(suite *TestRegistry) { func testTags(suite *TestRegistry) { // Load test chart (to build ref pushed in previous test) chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") - suite.Nil(err, "no error loading test chart") + suite.Require().NoError(err, "no error loading test chart") meta, err := extractChartMeta(chartData) - suite.Nil(err, "no error extracting chart meta") + suite.Require().NoError(err, "no error extracting chart meta") ref := fmt.Sprintf("%s/testrepo/%s", suite.DockerRegistryHost, meta.Name) // Query for tags and validate length tags, err := suite.RegistryClient.Tags(ref) - suite.Nil(err, "no error retrieving tags") - suite.Equal(1, len(tags)) + suite.Require().NoError(err, "no error retrieving tags") + suite.Len(tags, 1) } diff --git a/pkg/registry/tag_test.go b/pkg/registry/tag_test.go index 09f0f12ea..830eac7e1 100644 --- a/pkg/registry/tag_test.go +++ b/pkg/registry/tag_test.go @@ -17,32 +17,25 @@ limitations under the License. package registry import ( - "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestGetTagMatchingVersionOrConstraint_ExactMatch(t *testing.T) { tags := []string{"1.0.0", "1.2.3", "2.0.0"} got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.2.3" { - t.Fatalf("expected exact match '1.2.3', got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.2.3", got, "expected exact match") } func TestGetTagMatchingVersionOrConstraint_EmptyVersionWildcard(t *testing.T) { // Includes a non-semver tag which should be skipped tags := []string{"latest", "0.9.0", "1.0.0"} got, err := GetTagMatchingVersionOrConstraint(tags, "") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) // Should pick the first valid semver tag in order, which is 0.9.0 - if got != "0.9.0" { - t.Fatalf("expected '0.9.0', got %q", got) - } + require.Equal(t, "0.9.0", got) } func TestGetTagMatchingVersionOrConstraint_ConstraintRange(t *testing.T) { @@ -50,73 +43,47 @@ func TestGetTagMatchingVersionOrConstraint_ConstraintRange(t *testing.T) { // Caret range got, err := GetTagMatchingVersionOrConstraint(tags, "^1.0.0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.0.0" { // first match in order - t.Fatalf("expected '1.0.0', got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.0.0", got, "first match in order") // Compound range got, err = GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.0.0" { - t.Fatalf("expected '1.0.0', got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.0.0", got) } func TestGetTagMatchingVersionOrConstraint_InvalidConstraint(t *testing.T) { tags := []string{"1.0.0"} _, err := GetTagMatchingVersionOrConstraint(tags, ">a1") - if err == nil { - t.Fatalf("expected error for invalid constraint") - } + require.Error(t, err, "expected error for invalid constraint") } func TestGetTagMatchingVersionOrConstraint_NoMatches(t *testing.T) { tags := []string{"0.1.0", "0.2.0"} _, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0") - if err == nil { - t.Fatalf("expected error when no tags match") - } - if !strings.Contains(err.Error(), ">=1.0.0") { - t.Fatalf("expected error to contain version string, got: %v", err) - } + require.Error(t, err, "expected error when no tags match") + require.Contains(t, err.Error(), ">=1.0.0", "expected error to contain version string") } func TestGetTagMatchingVersionOrConstraint_SkipsNonSemverTags(t *testing.T) { tags := []string{"alpha", "1.0.0", "beta", "1.1.0"} got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.0.0 <2.0.0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.0.0" { - t.Fatalf("expected '1.0.0', got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.0.0", got) } func TestGetTagMatchingVersionOrConstraint_OrderMatters_FirstMatchReturned(t *testing.T) { // Both 1.2.0 and 1.3.0 satisfy >=1.2.0 <2.0.0, but the function returns the first in input order tags := []string{"1.3.0", "1.2.0"} got, err := GetTagMatchingVersionOrConstraint(tags, ">=1.2.0 <2.0.0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.3.0" { - t.Fatalf("expected '1.3.0' (first satisfying tag), got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.3.0", got, "first satisfying tag") } func TestGetTagMatchingVersionOrConstraint_ExactMatchHasPrecedence(t *testing.T) { // Exact match should be returned even if another earlier tag would match the parsed constraint tags := []string{"1.3.0", "1.2.3"} got, err := GetTagMatchingVersionOrConstraint(tags, "1.2.3") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "1.2.3" { - t.Fatalf("expected exact match '1.2.3', got %q", got) - } + require.NoError(t, err) + require.Equal(t, "1.2.3", got, "expected exact match") } diff --git a/pkg/registry/transport.go b/pkg/registry/transport.go index f039a8159..e4177efb3 100644 --- a/pkg/registry/transport.go +++ b/pkg/registry/transport.go @@ -52,22 +52,10 @@ type LoggingTransport struct { // NewTransport creates and returns a new instance of LoggingTransport func NewTransport(debug bool) *retry.Transport { - type cloner[T any] interface { - Clone() T - } - - // try to copy (clone) the http.DefaultTransport so any mutations we - // perform on it (e.g. TLS config) are not reflected globally - // follow https://github.com/golang/go/issues/39299 for a more elegant - // solution in the future + // clone http.DefaultTransport so mutations (e.g. TLS config) are not + // reflected globally transport := http.DefaultTransport - if t, ok := transport.(cloner[*http.Transport]); ok { - transport = t.Clone() - } else if t, ok := transport.(cloner[http.RoundTripper]); ok { - // this branch will not be used with go 1.20, it was added - // optimistically to try to clone if the http.DefaultTransport - // implementation changes, still the Clone method in that case - // might not return http.RoundTripper... + if t, ok := transport.(*http.Transport); ok { transport = t.Clone() } if debug { diff --git a/pkg/registry/transport_test.go b/pkg/registry/transport_test.go index b4990c526..e72c97c0d 100644 --- a/pkg/registry/transport_test.go +++ b/pkg/registry/transport_test.go @@ -22,6 +22,9 @@ import ( "io" "net/http" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var errMockRead = errors.New("mock read error") @@ -127,9 +130,7 @@ func Test_isPrintableContentType(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := isPrintableContentType(tt.contentType); got != tt.want { - t.Errorf("isPrintableContentType() = %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, isPrintableContentType(tt.contentType)) }) } } @@ -292,21 +293,13 @@ func Test_logResponseBody(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := logResponseBody(tt.resp); got != tt.want { - t.Errorf("logResponseBody() = %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, logResponseBody(tt.resp)) // validate the response body if tt.resp.Body != nil { readBytes, err := io.ReadAll(tt.resp.Body) - if err != nil { - t.Errorf("failed to read body after logResponseBody(), err= %v", err) - } - if !bytes.Equal(readBytes, tt.wantData) { - t.Errorf("resp.Body after logResponseBody() = %v, want %v", readBytes, tt.wantData) - } - if closeErr := tt.resp.Body.Close(); closeErr != nil { - t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr) - } + require.NoError(t, err, "failed to read body after logResponseBody()") + assert.True(t, bytes.Equal(tt.wantData, readBytes), "resp.Body after logResponseBody()") + assert.NoError(t, tt.resp.Body.Close(), "failed to close body after logResponseBody()") } }) } @@ -331,12 +324,8 @@ func Test_logResponseBody_error(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := logResponseBody(tt.resp); got != tt.want { - t.Errorf("logResponseBody() = %v, want %v", got, tt.want) - } - if closeErr := tt.resp.Body.Close(); closeErr != nil { - t.Errorf("failed to close body after logResponseBody(), err= %v", closeErr) - } + assert.Equal(t, tt.want, logResponseBody(tt.resp)) + assert.NoError(t, tt.resp.Body.Close(), "failed to close body after logResponseBody()") }) } } @@ -391,9 +380,7 @@ func Test_containsCredentials(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := containsCredentials(tt.body); got != tt.want { - t.Errorf("containsCredentials() = %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, containsCredentials(tt.body)) }) } } diff --git a/pkg/release/interfaces.go b/pkg/release/interfaces.go index aaa5a756f..c758de944 100644 --- a/pkg/release/interfaces.go +++ b/pkg/release/interfaces.go @@ -22,9 +22,9 @@ import ( "helm.sh/helm/v4/pkg/chart" ) -type Releaser interface{} +type Releaser any -type Hook interface{} +type Hook any type Accessor interface { Name() string diff --git a/pkg/release/v1/hook.go b/pkg/release/v1/hook.go index f0a370c15..5c382cd77 100644 --- a/pkg/release/v1/hook.go +++ b/pkg/release/v1/hook.go @@ -86,7 +86,7 @@ type Hook struct { // Events are the events that this hook fires on. Events []HookEvent `json:"events,omitempty"` // LastRun indicates the date/time this was last run. - LastRun HookExecution `json:"last_run,omitempty"` + LastRun HookExecution `json:"last_run"` // Weight indicates the sort order for execution among similar Hook type Weight int `json:"weight,omitempty"` // DeletePolicies are the policies that indicate when to delete the hook @@ -133,7 +133,7 @@ type hookExecutionJSON struct { // It handles empty string time fields by treating them as zero values. func (h *HookExecution) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } diff --git a/pkg/release/v1/hook_test.go b/pkg/release/v1/hook_test.go index cea2568bc..f3b8811a6 100644 --- a/pkg/release/v1/hook_test.go +++ b/pkg/release/v1/hook_test.go @@ -220,7 +220,7 @@ func TestHookExecutionEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&exec) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/release/v1/info.go b/pkg/release/v1/info.go index f895fdf6c..0901c4171 100644 --- a/pkg/release/v1/info.go +++ b/pkg/release/v1/info.go @@ -36,6 +36,8 @@ type Info struct { Description string `json:"description,omitempty"` // Status is the current state of the release Status common.Status `json:"status,omitempty"` + // RollbackRevision is the revision that was rolled back to. Zero means not a rollback. + RollbackRevision int `json:"rollback_revision,omitempty"` // Contains the rendered templates/NOTES.txt if available Notes string `json:"notes,omitempty"` // Contains the deployed resources information @@ -44,20 +46,21 @@ type Info struct { // infoJSON is used for custom JSON marshaling/unmarshaling type infoJSON struct { - FirstDeployed *time.Time `json:"first_deployed,omitempty"` - LastDeployed *time.Time `json:"last_deployed,omitempty"` - Deleted *time.Time `json:"deleted,omitempty"` - Description string `json:"description,omitempty"` - Status common.Status `json:"status,omitempty"` - Notes string `json:"notes,omitempty"` - Resources map[string][]runtime.Object `json:"resources,omitempty"` + FirstDeployed *time.Time `json:"first_deployed,omitempty"` + LastDeployed *time.Time `json:"last_deployed,omitempty"` + Deleted *time.Time `json:"deleted,omitempty"` + Description string `json:"description,omitempty"` + Status common.Status `json:"status,omitempty"` + RollbackRevision int `json:"rollback_revision,omitempty"` + Notes string `json:"notes,omitempty"` + Resources map[string][]runtime.Object `json:"resources,omitempty"` } // UnmarshalJSON implements the json.Unmarshaler interface. // It handles empty string time fields by treating them as zero values. func (i *Info) UnmarshalJSON(data []byte) error { // First try to unmarshal into a map to handle empty string time fields - var raw map[string]interface{} + var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err } @@ -95,6 +98,7 @@ func (i *Info) UnmarshalJSON(data []byte) error { } i.Description = tmp.Description i.Status = tmp.Status + i.RollbackRevision = tmp.RollbackRevision i.Notes = tmp.Notes i.Resources = tmp.Resources @@ -105,10 +109,11 @@ func (i *Info) UnmarshalJSON(data []byte) error { // It omits zero-value time fields from the JSON output. func (i Info) MarshalJSON() ([]byte, error) { tmp := infoJSON{ - Description: i.Description, - Status: i.Status, - Notes: i.Notes, - Resources: i.Resources, + Description: i.Description, + Status: i.Status, + RollbackRevision: i.RollbackRevision, + Notes: i.Notes, + Resources: i.Resources, } if !i.FirstDeployed.IsZero() { diff --git a/pkg/release/v1/info_test.go b/pkg/release/v1/info_test.go index 0fff78f76..12b3014f6 100644 --- a/pkg/release/v1/info_test.go +++ b/pkg/release/v1/info_test.go @@ -87,6 +87,27 @@ func TestInfoMarshalJSON(t *testing.T) { }, expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","deleted":"2025-10-08T14:00:00Z","description":"Uninstalled release","status":"uninstalled"}`, }, + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + }, + { + name: "zero rollback revision omitted", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + expected: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + }, } for _, tt := range tests { @@ -203,6 +224,27 @@ func TestInfoUnmarshalJSON(t *testing.T) { Status: "", }, }, + { + name: "with rollback revision", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","rollback_revision":2,"description":"Rollback to 2"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + RollbackRevision: 2, + Description: "Rollback to 2", + }, + }, + { + name: "zero rollback revision omitted", + input: `{"first_deployed":"2025-10-08T12:00:00Z","last_deployed":"2025-10-08T13:00:00Z","status":"deployed","description":"Normal install"}`, + expected: Info{ + FirstDeployed: now, + LastDeployed: later, + Status: common.StatusDeployed, + Description: "Normal install", + }, + }, } for _, tt := range tests { @@ -219,6 +261,7 @@ func TestInfoUnmarshalJSON(t *testing.T) { assert.Equal(t, tt.expected.Deleted.Unix(), info.Deleted.Unix()) assert.Equal(t, tt.expected.Description, info.Description) assert.Equal(t, tt.expected.Status, info.Status) + assert.Equal(t, tt.expected.RollbackRevision, info.RollbackRevision) assert.Equal(t, tt.expected.Notes, info.Notes) assert.Equal(t, tt.expected.Resources, info.Resources) }) @@ -252,6 +295,61 @@ func TestInfoRoundTrip(t *testing.T) { assert.Equal(t, original.Notes, decoded.Notes) } +func TestInfoRollbackRevisionRoundTrip(t *testing.T) { + now := time.Date(2025, 10, 8, 12, 0, 0, 0, time.UTC) + later := time.Date(2025, 10, 8, 13, 0, 0, 0, time.UTC) + + tests := []struct { + name string + info Info + }{ + { + name: "with rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Rollback to 2", + Status: common.StatusDeployed, + RollbackRevision: 2, + }, + }, + { + name: "zero rollback revision", + info: Info{ + FirstDeployed: now, + LastDeployed: later, + Description: "Normal install", + Status: common.StatusDeployed, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.info) + require.NoError(t, err) + + var decoded Info + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + + assert.Equal(t, tt.info.RollbackRevision, decoded.RollbackRevision) + assert.Equal(t, tt.info.FirstDeployed.Unix(), decoded.FirstDeployed.Unix()) + assert.Equal(t, tt.info.LastDeployed.Unix(), decoded.LastDeployed.Unix()) + assert.Equal(t, tt.info.Status, decoded.Status) + assert.Equal(t, tt.info.Description, decoded.Description) + + // Verify omitempty behavior: zero rollback_revision should not appear in JSON + if tt.info.RollbackRevision == 0 { + var raw map[string]any + err = json.Unmarshal(data, &raw) + require.NoError(t, err) + assert.NotContains(t, raw, "rollback_revision") + } + }) + } +} + func TestInfoEmptyStringRoundTrip(t *testing.T) { // This test specifically verifies that empty string time fields // are handled correctly during parsing @@ -272,7 +370,7 @@ func TestInfoEmptyStringRoundTrip(t *testing.T) { data, err := json.Marshal(&info) require.NoError(t, err) - var result map[string]interface{} + var result map[string]any err = json.Unmarshal(data, &result) require.NoError(t, err) diff --git a/pkg/release/v1/mock.go b/pkg/release/v1/mock.go index dc135a24a..fc98a4525 100644 --- a/pkg/release/v1/mock.go +++ b/pkg/release/v1/mock.go @@ -17,8 +17,8 @@ limitations under the License. package v1 import ( - "fmt" "math/rand" + "strconv" "time" "helm.sh/helm/v4/pkg/chart/common" @@ -57,7 +57,7 @@ func Mock(opts *MockReleaseOptions) *Release { name := opts.Name if name == "" { - name = "testrelease-" + fmt.Sprint(rand.Intn(100)) + name = "testrelease-" + strconv.Itoa(rand.Intn(100)) } version := 1 @@ -123,7 +123,7 @@ func Mock(opts *MockReleaseOptions) *Release { Name: name, Info: info, Chart: ch, - Config: map[string]interface{}{"name": "value"}, + Config: map[string]any{"name": "value"}, Version: version, Namespace: namespace, Hooks: []*Hook{ diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index 454ee6eb7..3bbc0e4ce 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -36,7 +36,7 @@ type Release struct { Chart *chart.Chart `json:"chart,omitempty"` // Config is the set of extra Values added to the chart. // These values override the default values inside of the chart. - Config map[string]interface{} `json:"config,omitempty"` + Config map[string]any `json:"config,omitempty"` // Manifest is the string representation of the rendered template. Manifest string `json:"manifest,omitempty"` // Hooks are all of the hooks declared for this release. diff --git a/pkg/release/v1/util/filter_test.go b/pkg/release/v1/util/filter_test.go index 1004a4c57..d6bda8f84 100644 --- a/pkg/release/v1/util/filter_test.go +++ b/pkg/release/v1/util/filter_test.go @@ -19,23 +19,19 @@ package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "testing" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) func TestFilterAny(t *testing.T) { ls := Any(StatusFilter(common.StatusUninstalled)).Filter(releases) - if len(ls) != 2 { - t.Fatalf("expected 2 results, got '%d'", len(ls)) - } + require.Len(t, ls, 2) r0, r1 := ls[0], ls[1] - switch { - case r0.Info.Status != common.StatusUninstalled: - t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - case r1.Info.Status != common.StatusUninstalled: - t.Fatalf("expected UNINSTALLED result, got '%s'", r1.Info.Status.String()) - } + require.Equal(t, common.StatusUninstalled, r0.Info.Status) + require.Equal(t, common.StatusUninstalled, r1.Info.Status) } func TestFilterAll(t *testing.T) { @@ -47,14 +43,9 @@ func TestFilterAll(t *testing.T) { }) ls := All(fn).Filter(releases) - if len(ls) != 1 { - t.Fatalf("expected 1 result, got '%d'", len(ls)) - } - - switch r0 := ls[0]; { - case r0.Version == 4: - t.Fatal("got release with status revision 4") - case r0.Info.Status == common.StatusUninstalled: - t.Fatal("got release with status UNINSTALLED") - } + require.Len(t, ls, 1) + + r0 := ls[0] + require.NotEqual(t, 4, r0.Version, "got release with status revision 4") + require.NotEqual(t, common.StatusUninstalled, r0.Info.Status, "got release with status UNINSTALLED") } diff --git a/pkg/release/v1/util/kind_sorter.go b/pkg/release/v1/util/kind_sorter.go index bc074340f..01f1f801e 100644 --- a/pkg/release/v1/util/kind_sorter.go +++ b/pkg/release/v1/util/kind_sorter.go @@ -137,7 +137,7 @@ func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.H return h } -func lessByKind(_ interface{}, _ interface{}, kindA string, kindB string, o KindSortOrder) bool { +func lessByKind(_ any, _ any, kindA string, kindB string, o KindSortOrder) bool { ordering := make(map[string]int, len(o)) for v, k := range o { ordering[k] = v diff --git a/pkg/release/v1/util/kind_sorter_test.go b/pkg/release/v1/util/kind_sorter_test.go index 919de24e5..de8a79b25 100644 --- a/pkg/release/v1/util/kind_sorter_test.go +++ b/pkg/release/v1/util/kind_sorter_test.go @@ -20,6 +20,9 @@ import ( "bytes" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + release "helm.sh/helm/v4/pkg/release/v1" ) @@ -193,21 +196,15 @@ func TestKindSorter(t *testing.T) { } { var buf bytes.Buffer t.Run(test.description, func(t *testing.T) { - if got, want := len(test.expected), len(manifests); got != want { - t.Fatalf("Expected %d names in order, got %d", want, got) - } + require.Len(t, manifests, len(test.expected), "Expected %d names in order", len(manifests)) defer buf.Reset() orig := manifests for _, r := range sortManifestsByKind(manifests, test.order) { buf.WriteString(r.Name) } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) - } + assert.Equal(t, test.expected, buf.String()) for i, manifest := range orig { - if manifest != manifests[i] { - t.Fatal("Expected input to sortManifestsByKind to stay the same") - } + require.Equal(t, manifest, manifests[i], "Expected input to sortManifestsByKind to stay the same") } }) } @@ -267,9 +264,7 @@ func TestKindSorterKeepOriginalOrder(t *testing.T) { for _, r := range sortManifestsByKind(manifests, test.order) { buf.WriteString(r.Name) } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) - } + assert.Equal(t, test.expected, buf.String()) }) } } @@ -289,9 +284,7 @@ func TestKindSorterNamespaceAgainstUnknown(t *testing.T) { expectedOrder := []Manifest{namespace, unknown} for i, manifest := range manifests { - if expectedOrder[i].Name != manifest.Name { - t.Errorf("Expected %s, got %s", expectedOrder[i].Name, manifest.Name) - } + assert.Equal(t, expectedOrder[i].Name, manifest.Name) } } @@ -326,22 +319,16 @@ func TestKindSorterForHooks(t *testing.T) { } { var buf bytes.Buffer t.Run(test.description, func(t *testing.T) { - if got, want := len(test.expected), len(hooks); got != want { - t.Fatalf("Expected %d names in order, got %d", want, got) - } + require.Len(t, hooks, len(test.expected), "Expected %d names in order", len(hooks)) defer buf.Reset() orig := hooks for _, r := range sortHooksByKind(hooks, test.order) { buf.WriteString(r.Name) } for i, hook := range orig { - if hook != hooks[i] { - t.Fatal("Expected input to sortHooksByKind to stay the same") - } - } - if got := buf.String(); got != test.expected { - t.Errorf("Expected %q, got %q", test.expected, got) + require.Equal(t, hook, hooks[i], "Expected input to sortHooksByKind to stay the same") } + assert.Equal(t, test.expected, buf.String()) }) } } diff --git a/pkg/release/v1/util/manifest.go b/pkg/release/v1/util/manifest.go index 9a87949f8..fa26f6256 100644 --- a/pkg/release/v1/util/manifest.go +++ b/pkg/release/v1/util/manifest.go @@ -21,6 +21,7 @@ import ( "regexp" "strconv" "strings" + "unicode" ) // SimpleHead defines what the structure of the head of a manifest file @@ -35,7 +36,16 @@ type SimpleHead struct { var sep = regexp.MustCompile("(?:^|\\s*\n)---\\s*") -// SplitManifests takes a string of manifest and returns a map contains individual manifests +// SplitManifests takes a manifest string and returns a map containing individual manifests. +// +// **Note for Chart API v3**: This function (due to the regex above) has allowed _WRONG_ +// Go templates to be defined inside charts across the years. The generated text from Go +// templates may contain `---apiVersion: v1`, and this function magically splits this back +// to `---\napiVersion: v1`. This has caused issues recently after Helm 4 introduced +// kio.ParseAll to inject annotations when post-renderers are used. In Chart API v3, +// we should kill this regex with fire (or change it) and expose charts doing the wrong +// thing Go template-wise. Helm should say a big _NO_ to charts doing the wrong thing, +// with or without post-renderers. func SplitManifests(bigFile string) map[string]string { // Basically, we're quickly splitting a stream of YAML documents into an // array of YAML docs. The file name is just a place holder, but should be @@ -44,15 +54,15 @@ func SplitManifests(bigFile string) map[string]string { tpl := "manifest-%d" res := map[string]string{} // Making sure that any extra whitespace in YAML stream doesn't interfere in splitting documents correctly. - bigFileTmp := strings.TrimSpace(bigFile) + bigFileTmp := strings.TrimLeftFunc(bigFile, unicode.IsSpace) docs := sep.Split(bigFileTmp, -1) var count int for _, d := range docs { - if d == "" { + if strings.TrimSpace(d) == "" { continue } - d = strings.TrimSpace(d) + d = strings.TrimLeftFunc(d, unicode.IsSpace) res[fmt.Sprintf(tpl, count)] = d count = count + 1 } diff --git a/pkg/release/v1/util/manifest_sorter_test.go b/pkg/release/v1/util/manifest_sorter_test.go index 4360013e5..b02400938 100644 --- a/pkg/release/v1/util/manifest_sorter_test.go +++ b/pkg/release/v1/util/manifest_sorter_test.go @@ -17,16 +17,16 @@ limitations under the License. package util import ( - "reflect" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" release "helm.sh/helm/v4/pkg/release/v1" ) func TestSortManifests(t *testing.T) { - data := []struct { name []string path string @@ -139,56 +139,39 @@ metadata: } hs, generic, err := SortManifests(manifests, nil, InstallOrder) - if err != nil { - t.Fatalf("Unexpected error: %s", err) - } + require.NoError(t, err) // This test will fail if 'six' or 'seven' was added. - if len(generic) != 2 { - t.Errorf("Expected 2 generic manifests, got %d", len(generic)) - } + assert.Len(t, generic, 2) - if len(hs) != 4 { - t.Errorf("Expected 4 hooks, got %d", len(hs)) - } + assert.Len(t, hs, 4) for _, out := range hs { found := false for _, expect := range data { if out.Path == expect.path { found = true - if out.Path != expect.path { - t.Errorf("Expected path %s, got %s", expect.path, out.Path) - } + assert.Equal(t, expect.path, out.Path) nameFound := false for _, expectedName := range expect.name { if out.Name == expectedName { nameFound = true } } - if !nameFound { - t.Errorf("Got unexpected name %s", out.Name) - } + assert.True(t, nameFound, "Got unexpected name %s", out.Name) kindFound := false for _, expectedKind := range expect.kind { if out.Kind == expectedKind { kindFound = true } } - if !kindFound { - t.Errorf("Got unexpected kind %s", out.Kind) - } + assert.True(t, kindFound, "Got unexpected kind %s", out.Kind) expectedHooks := expect.hooks[out.Name] - if !reflect.DeepEqual(expectedHooks, out.Events) { - t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events) - } - + assert.Equal(t, expectedHooks, out.Events, "expected events: %v but got: %v", expectedHooks, out.Events) } } - if !found { - t.Errorf("Result not found: %v", out) - } + assert.True(t, found, "Result not found: %v", out) } // Verify the sort order @@ -220,8 +203,6 @@ metadata: sorted = sortManifestsByKind(sorted, InstallOrder) for i, m := range generic { - if m.Content != sorted[i].Content { - t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content) - } + assert.Equal(t, m.Content, sorted[i].Content) } } diff --git a/pkg/release/v1/util/manifest_test.go b/pkg/release/v1/util/manifest_test.go index 754ac1367..d1efa21e1 100644 --- a/pkg/release/v1/util/manifest_test.go +++ b/pkg/release/v1/util/manifest_test.go @@ -17,11 +17,20 @@ limitations under the License. package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( - "reflect" "testing" + + "github.com/stretchr/testify/assert" ) -const mockManifestFile = ` +func TestSplitManifests(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "single doc with leading separator and whitespace", + input: ` --- apiVersion: v1 @@ -35,9 +44,9 @@ spec: - name: nemo-test image: fake-image cmd: fake-command -` - -const expectedManifest = `apiVersion: v1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 kind: Pod metadata: name: finding-nemo, @@ -47,15 +56,461 @@ spec: containers: - name: nemo-test image: fake-image - cmd: fake-command` + cmd: fake-command +`, + }, + }, + { + name: "empty input", + input: "", + expected: map[string]string{}, + }, + { + name: "whitespace only", + input: " \n\n \n", + expected: map[string]string{}, + }, + { + name: "whitespace-only doc after separator is skipped", + input: "---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1\n---\n \n", + expected: map[string]string{ + "manifest-0": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm1", + }, + }, + { + name: "single doc no separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +`, + }, + }, + { + name: "two docs with proper separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + + // Block scalar chomping indicator tests using | (clip), |- (strip), and |+ (keep) + // inputs with 0, 1, and 2 trailing newlines after the block content. + // Note: the emitter may normalize the output chomping indicator when the + // trailing newline count makes another indicator equivalent for the result. + + // | (clip) input — clips trailing newlines to exactly one, though with + // 0 trailing newlines the emitted output may normalize to |-. + { + name: "block scalar clip (|) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + }, + }, + { + name: "block scalar clip (|) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +`, + }, + }, + { + name: "block scalar clip (|) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello + +`, + }, + }, + + // |- (strip) + { + name: "block scalar strip (|-) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello`, + }, + }, + { + name: "block scalar strip (|-) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello +`, + }, + }, + { + name: "block scalar strip (|-) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |- + hello + +`, + }, + }, + + // |+ (keep) + { + name: "block scalar keep (|+) with 0 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + }, + }, + { + name: "block scalar keep (|+) with 1 trailing newline", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello +`, + }, + }, + { + name: "block scalar keep (|+) with 2 trailing newlines", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + +`, + }, + }, -func TestSplitManifest(t *testing.T) { - manifests := SplitManifests(mockManifestFile) - if len(manifests) != 1 { - t.Errorf("Expected 1 manifest, got %v", len(manifests)) + // Multi-doc with block scalars: the regex consumes \s*\n before ---, + // so trailing newlines from non-last docs are stripped. + { + name: "multi-doc block scalar clip (|) before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: | + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + { + name: "multi-doc block scalar keep (|+) with 2 trailing newlines before separator", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello + + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test +data: + key: |+ + hello`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: test2 +`, + }, + }, + + // **Note for Chart API v3**: The following tests exercise the lenient + // regex that splits `---apiVersion` back into separate documents. + // In Chart API v3, these inputs should return an _ERROR_ instead. + // See the comment on the SplitManifests function for more details. + { + name: "leading glued separator (---apiVersion)", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +`, + }, + }, + { + name: "mid-content glued separator (---apiVersion)", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +`, + }, + }, + { + name: "multiple glued separators", + input: ` +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, + { + name: "mixed glued and proper separators", + input: ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2 +---apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + expected: map[string]string{ + "manifest-0": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1`, + "manifest-1": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm2`, + "manifest-2": `apiVersion: v1 +kind: ConfigMap +metadata: + name: cm3 +`, + }, + }, } - expected := map[string]string{"manifest-0": expectedManifest} - if !reflect.DeepEqual(manifests, expected) { - t.Errorf("Expected %v, got %v", expected, manifests) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitManifests(tt.input) + assert.Equal(t, tt.expected, result, "SplitManifests() =\n%v\nwant:\n%v", result, tt.expected) + }) } } diff --git a/pkg/release/v1/util/sorter_test.go b/pkg/release/v1/util/sorter_test.go index f47db7db8..0450b09e6 100644 --- a/pkg/release/v1/util/sorter_test.go +++ b/pkg/release/v1/util/sorter_test.go @@ -20,6 +20,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/release/common" rspb "helm.sh/helm/v4/pkg/release/v1" ) @@ -45,9 +47,7 @@ func tsRelease(name string, vers int, dur time.Duration, status common.Status) * func check(t *testing.T, by string, fn func(int, int) bool) { t.Helper() for i := len(releases) - 1; i > 0; i-- { - if fn(i, i-1) { - t.Errorf("release at positions '(%d,%d)' not sorted by %s", i-1, i, by) - } + assert.False(t, fn(i, i-1), "release at positions '(%d,%d)' not sorted by %s", i-1, i, by) } } diff --git a/pkg/repo/v1/chartrepo.go b/pkg/repo/v1/chartrepo.go index deef7474e..09b74d602 100644 --- a/pkg/repo/v1/chartrepo.go +++ b/pkg/repo/v1/chartrepo.go @@ -173,7 +173,6 @@ func WithInsecureSkipTLSVerify(insecureSkipTLSVerify bool) FindChartInRepoURLOpt // FindChartInRepoURL finds chart in chart repository pointed by repoURL // without adding repo to repositories func FindChartInRepoURL(repoURL string, chartName string, getters getter.Providers, options ...FindChartInRepoURLOption) (string, error) { - opts := findChartInRepoURLOptions{} for _, option := range options { option(&opts) diff --git a/pkg/repo/v1/chartrepo_test.go b/pkg/repo/v1/chartrepo_test.go index 7cffc04b6..9c992474b 100644 --- a/pkg/repo/v1/chartrepo_test.go +++ b/pkg/repo/v1/chartrepo_test.go @@ -85,7 +85,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) { } if len(myCustomGetter.repoUrls) != 1 { - t.Fatalf("Custom Getter.Get should be called once") + t.Fatal("Custom Getter.Get should be called once") } expectedRepoIndexURL := repoURL + "/index.yaml" @@ -126,7 +126,6 @@ func TestConcurrencyDownloadIndex(t *testing.T) { // 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.Go(func() { idx, err := repo.DownloadIndexFile() if err != nil { @@ -234,13 +233,12 @@ func TestFindChartInRepoURL(t *testing.T) { } func TestErrorFindChartInRepoURL(t *testing.T) { - g := getter.All(&cli.EnvSettings{ RepositoryCache: t.TempDir(), }) if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil { - t.Errorf("Expected error for bad chart URL, but did not get any errors") + t.Error("Expected error for bad chart URL, but did not get any errors") } else if !strings.Contains(err.Error(), `looks like "http://someserver/something" is not a valid chart repository or cannot be reached`) { t.Errorf("Expected error for bad chart URL, but got a different error (%v)", err) } @@ -252,22 +250,22 @@ func TestErrorFindChartInRepoURL(t *testing.T) { defer srv.Close() if _, err = FindChartInRepoURL(srv.URL, "nginx1", g); err == nil { - t.Errorf("Expected error for chart not found, but did not get any errors") + t.Error("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } if !errors.Is(err, ChartNotFoundError{}) { - t.Errorf("error is not of correct error type structure") + t.Error("error is not of correct error type structure") } if _, err = FindChartInRepoURL(srv.URL, "nginx1", g, WithChartVersion("0.1.0")); err == nil { - t.Errorf("Expected error for chart not found, but did not get any errors") + t.Error("Expected error for chart not found, but did not get any errors") } else if err.Error() != `chart "nginx1" version "0.1.0" not found in `+srv.URL+` repository` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", g); err == nil { - t.Errorf("Expected error for no chart URLs available, but did not get any errors") + t.Error("Expected error for no chart URLs available, but did not get any errors") } else if err.Error() != `chart "chartWithNoURL" has no downloadable URLs` { t.Errorf("Expected error for chart not found, but got a different error (%v)", err) } diff --git a/pkg/repo/v1/index.go b/pkg/repo/v1/index.go index 7969d64e9..57e4c081f 100644 --- a/pkg/repo/v1/index.go +++ b/pkg/repo/v1/index.go @@ -25,6 +25,7 @@ import ( "os" "path" "path/filepath" + "slices" "sort" "strings" "time" @@ -80,7 +81,7 @@ func (c ChartVersions) Less(a, b int) bool { // IndexFile represents the index file in a chart repository type IndexFile struct { // This is used ONLY for validation against chartmuseum's index files and is discarded after validation. - ServerInfo map[string]interface{} `json:"serverInfo,omitempty"` + ServerInfo map[string]any `json:"serverInfo,omitempty"` APIVersion string `json:"apiVersion"` Generated time.Time `json:"generated"` Entries map[string]ChartVersions `json:"entries"` @@ -153,7 +154,7 @@ func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string) // Deprecated: Use index.MustAdd instead. func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { if err := i.MustAdd(md, filename, baseURL, digest); err != nil { - slog.Error("skipping loading invalid entry for chart %q %q from %s: %s", md.Name, md.Version, filename, err) + slog.Error("skipping loading invalid entry for chart", "name", md.Name, "version", md.Version, "file", filename, "error", err) } } @@ -175,6 +176,19 @@ func (i IndexFile) SortEntries() { } } +// isVersionRange checks if the version string is a range constraint (e.g., "^1", "~1.10") +// rather than an exact version (e.g., "1.10.0"). +func isVersionRange(version string) bool { + if strings.ContainsAny(version, "^~<>=!*") || strings.Contains(version, "||") || strings.Contains(version, " - ") { + return true + } + core := version + if idx := strings.IndexAny(version, "-+"); idx != -1 { + core = version[:idx] + } + return strings.ContainsAny(core, "xX") +} + // Get returns the ChartVersion for the given name. // // If version is empty, this will return the chart with the latest stable version, @@ -215,8 +229,10 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) { } if constraint.Check(test) { - if len(version) != 0 { + if len(version) != 0 && !isVersionRange(version) { slog.Warn("unable to find exact version requested; falling back to closest available version", "chart", name, "requested", version, "selected", ver.Version) + } else if len(version) != 0 && isVersionRange(version) { + slog.Debug("selected version matching constraint", "chart", name, "constraint", version, "selected", ver.Version) } return ver, nil } @@ -270,7 +286,7 @@ func (i *IndexFile) Merge(f *IndexFile) { type ChartVersion struct { *chart.Metadata URLs []string `json:"urls"` - Created time.Time `json:"created,omitempty"` + Created time.Time `json:"created"` Removed bool `json:"removed,omitempty"` Digest string `json:"digest,omitempty"` @@ -356,21 +372,21 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { } for name, cvs := range i.Entries { - for idx := len(cvs) - 1; idx >= 0; idx-- { - if cvs[idx] == nil { - slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)) + for idx, v := range slices.Backward(cvs) { + if v == nil { + slog.Warn("skipping loading invalid entry for chart: empty entry", "name", name, "source", source) cvs = append(cvs[:idx], cvs[idx+1:]...) continue } // When metadata section missing, initialize with no data - if cvs[idx].Metadata == nil { - cvs[idx].Metadata = &chart.Metadata{} + if v.Metadata == nil { + v.Metadata = &chart.Metadata{} } - if cvs[idx].APIVersion == "" { - cvs[idx].APIVersion = chart.APIVersionV1 + if v.APIVersion == "" { + v.APIVersion = chart.APIVersionV1 } - if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil { - slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)) + if err := v.Validate(); ignoreSkippableChartValidationError(err) != nil { + slog.Warn("skipping loading invalid entry for chart", "name", name, "version", v.Version, "source", source, "error", err) cvs = append(cvs[:idx], cvs[idx+1:]...) } } @@ -391,7 +407,7 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { // checking its validity as JSON. If the data is valid JSON, it will use the // `encoding/json` package to unmarshal it. Otherwise, it will use the // `sigs.k8s.io/yaml` package to unmarshal the YAML data. -func jsonOrYamlUnmarshal(b []byte, i interface{}) error { +func jsonOrYamlUnmarshal(b []byte, i any) error { if json.Valid(b) { return json.Unmarshal(b, i) } diff --git a/pkg/repo/v1/index_test.go b/pkg/repo/v1/index_test.go index 446160143..9259e5a4d 100644 --- a/pkg/repo/v1/index_test.go +++ b/pkg/repo/v1/index_test.go @@ -21,7 +21,6 @@ import ( "bytes" "encoding/json" "errors" - "fmt" "net/http" "os" "path/filepath" @@ -131,17 +130,16 @@ func TestIndexFile(t *testing.T) { cv, err = i.Get("setter", "0.1.9+alpha") if err != nil || cv.Version != "0.1.9+alpha" { - t.Errorf("Expected version: 0.1.9+alpha") + t.Error("Expected version: 0.1.9+alpha") } cv, err = i.Get("setter", "0.1.8") if err != nil || cv.Version != "0.1.8" { - t.Errorf("Expected version: 0.1.8") + t.Error("Expected version: 0.1.8") } } func TestLoadIndex(t *testing.T) { - tests := []struct { Name string Filename string @@ -175,7 +173,7 @@ func TestLoadIndex(t *testing.T) { // TestLoadIndex_Duplicates is a regression to make sure that we don't non-deterministically allow duplicate packages. func TestLoadIndex_Duplicates(t *testing.T) { if _, err := loadIndex([]byte(indexWithDuplicates), "indexWithDuplicates"); err == nil { - t.Errorf("Expected an error when duplicate entries are present") + t.Error("Expected an error when duplicate entries are present") } } @@ -187,7 +185,7 @@ func TestLoadIndex_EmptyEntry(t *testing.T) { func TestLoadIndex_Empty(t *testing.T) { if _, err := loadIndex([]byte(""), "indexWithEmpty"); err == nil { - t.Errorf("Expected an error when index.yaml is empty.") + t.Error("Expected an error when index.yaml is empty.") } } @@ -251,7 +249,6 @@ func TestMerge(t *testing.T) { if v := vs[1]; v.Version != "0.2.0" { t.Errorf("Expected %q version to be 0.2.0, got %s", v.Name, v.Version) } - } func TestDownloadIndexFile(t *testing.T) { @@ -361,7 +358,7 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) { alpine, ok := i.Entries["alpine"] if !ok { - t.Fatalf("'alpine' section not found.") + t.Fatal("'alpine' section not found.") } if l := len(alpine); l != 1 { @@ -370,7 +367,7 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) { nginx, ok := i.Entries["nginx"] if !ok || len(nginx) != 2 { - t.Fatalf("Expected 2 nginx entries") + t.Fatal("Expected 2 nginx entries") } expects := []*ChartVersion{ @@ -596,7 +593,7 @@ func TestAddFileIndexEntriesNil(t *testing.T) { {&chart.Metadata{APIVersion: "v2", Name: " ", Version: "8033-5.apinie+s.r"}, "setter-0.1.9+beta.tgz", "http://example.com/charts", "sha256:1234567890abc"}, } { if err := i.MustAdd(x.md, x.filename, x.baseURL, x.digest); err == nil { - t.Errorf("expected err to be non-nil when entries not initialized") + t.Error("expected err to be non-nil when entries not initialized") } } } @@ -611,7 +608,7 @@ func TestIgnoreSkippableChartValidationError(t *testing.T) { Input: nil, }, "generic_error": { - Input: fmt.Errorf("foo"), + Input: errors.New("foo"), }, "non_skipped_validation_error": { Input: chart.ValidationError("chart.metadata.type must be application or library"), @@ -643,7 +640,6 @@ func TestIgnoreSkippableChartValidationError(t *testing.T) { if !errors.Is(tc.Input, result) { t.Error("expected the result equal to input") } - }) } } @@ -719,3 +715,45 @@ func TestLoadIndex_DuplicateChartDeps(t *testing.T) { }) } } + +func TestIsVersionRange(t *testing.T) { + tests := []struct { + version string + expected bool + }{ + {"1.0.0", false}, + {"1.0.0+metadata", false}, + {"v1.19.2", false}, + {"v1", false}, + {"^1", true}, + {"^1.2.3", true}, + {"~1.10", true}, + {"~1.10.0", true}, + {">= 1.0.0", true}, + {"> 1.0.0", true}, + {"< 2.0.0", true}, + {"<= 2.0.0", true}, + {"!= 1.0.0", true}, + {"1.*", true}, + {"1.x", true}, + {"1.X", true}, + {"v1.x", true}, + {"v1.X", true}, + {"1.0.0 - 2.0.0", true}, + {"^1.0.0 || ^2.0.0", true}, + {">=1.0.0 <2.0.0", true}, + // Exact versions with 'x'/'X' in prerelease or build metadata + {"1.0.0-fix", false}, + {"2.0.0-next", false}, + {"1.0.0+exp", false}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + got := isVersionRange(tt.version) + if got != tt.expected { + t.Errorf("isVersionRange(%q) = %v, want %v", tt.version, got, tt.expected) + } + }) + } +} diff --git a/pkg/repo/v1/repo_test.go b/pkg/repo/v1/repo_test.go index bdaa61eda..9b5c54309 100644 --- a/pkg/repo/v1/repo_test.go +++ b/pkg/repo/v1/repo_test.go @@ -114,11 +114,11 @@ func TestRepoFile_Get(t *testing.T) { name := "second" entry := repo.Get(name) - if entry == nil { //nolint:staticcheck + if entry == nil { t.Fatalf("Expected repo entry %q to be found", name) } - if entry.URL != "https://example.com/second" { //nolint:staticcheck + if entry.URL != "https://example.com/second" { t.Errorf("Expected repo URL to be %q but got %q", "https://example.com/second", entry.URL) } @@ -219,9 +219,9 @@ func TestWriteFile(t *testing.T) { func TestRepoNotExists(t *testing.T) { if _, err := LoadFile("/this/path/does/not/exist.yaml"); err == nil { - t.Errorf("expected err to be non-nil when path does not exist") + t.Error("expected err to be non-nil when path does not exist") } else if !strings.Contains(err.Error(), "couldn't load repositories file") { - t.Errorf("expected prompt `couldn't load repositories file`") + t.Error("expected prompt `couldn't load repositories file`") } } diff --git a/pkg/repo/v1/repotest/server.go b/pkg/repo/v1/repotest/server.go index 22b4c8060..06cdc9172 100644 --- a/pkg/repo/v1/repotest/server.go +++ b/pkg/repo/v1/repotest/server.go @@ -30,6 +30,8 @@ import ( "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" // used for docker test registry _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" // used for docker test registry + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "sigs.k8s.io/yaml" @@ -44,9 +46,8 @@ func BasicAuthMiddleware(t *testing.T) http.HandlerFunc { t.Helper() return http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() - if !ok || username != "username" || password != "password" { - t.Errorf("Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) - } + assert.True(t, ok && username == "username" && password == "password", + "Expected request to use basic auth and for username == 'username' and password == 'password', got '%v', '%s', '%s'", ok, username, password) }) } @@ -97,9 +98,8 @@ func NewTempServer(t *testing.T, options ...ServerOption) *Server { t.Cleanup(func() { os.RemoveAll(srv.docroot) }) if srv.chartSourceGlob != "" { - if _, err := srv.CopyCharts(srv.chartSourceGlob); err != nil { - t.Fatal(err) - } + _, err := srv.CopyCharts(srv.chartSourceGlob) + require.NoError(t, err) } return srv @@ -109,9 +109,7 @@ func NewTempServer(t *testing.T, options ...ServerOption) *Server { func newServer(t *testing.T, docroot string, options ...ServerOption) *Server { t.Helper() absdocroot, err := filepath.Abs(docroot) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) s := &Server{ docroot: absdocroot, @@ -131,9 +129,7 @@ func newServer(t *testing.T, docroot string, options ...ServerOption) *Server { s.start() // Add the testing repository as the only repo. Server must be started for the server's URL to be valid - if err := setTestingRepository(s.URL(), filepath.Join(s.docroot, "repositories.yaml")); err != nil { - t.Fatal(err) - } + require.NoError(t, setTestingRepository(s.URL(), filepath.Join(s.docroot, "repositories.yaml"))) return s } @@ -169,27 +165,22 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { testUsername, testPassword := "username", "password" pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) - if err != nil { - t.Fatal("error generating bcrypt password for test htpasswd file") - } + require.NoError(t, err, "error generating bcrypt password for test htpasswd file") htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0o644) - if err != nil { - t.Fatalf("error creating test htpasswd file") - } + require.NoError(t, err, "error creating test htpasswd file") // Registry config config := &configuration.Configuration{} - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("error finding free port for test registry") - } + lnCfg := net.ListenConfig{} + ln, err := lnCfg.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err, "error finding free port for test registry") defer ln.Close() port := ln.Addr().(*net.TCPAddr).Port config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second - config.Storage = map[string]configuration.Parameters{"inmemory": map[string]interface{}{}} + config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}} config.Auth = configuration.Auth{ "htpasswd": configuration.Parameters{ "realm": "localhost", @@ -200,9 +191,7 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { registryURL := fmt.Sprintf("localhost:%d", port) r, err := registry.NewRegistry(t.Context(), config) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) return &OCIServer{ Registry: r, @@ -236,53 +225,37 @@ func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServ ociRegistry.ClientOptWriter(os.Stdout), ociRegistry.ClientOptCredentialsFile(credentialsFile), ) - if err != nil { - t.Fatalf("error creating registry client") - } + require.NoError(t, err, "error creating registry client") err = registryClient.Login( srv.RegistryURL, ociRegistry.LoginOptBasicAuth(srv.TestUsername, srv.TestPassword), ociRegistry.LoginOptInsecure(true), ociRegistry.LoginOptPlainText(true)) - if err != nil { - t.Fatalf("error logging into registry with good credentials: %v", err) - } + require.NoError(t, err, "error logging into registry with good credentials") - ref := fmt.Sprintf("%s/u/ocitestuser/oci-dependent-chart:0.1.0", srv.RegistryURL) + ref := srv.RegistryURL + "/u/ocitestuser/oci-dependent-chart:0.1.0" err = chartutil.ExpandFile(srv.Dir, filepath.Join(srv.Dir, "oci-dependent-chart-0.1.0.tgz")) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) // valid chart ch, err := loader.LoadDir(filepath.Join(srv.Dir, "oci-dependent-chart")) - if err != nil { - t.Fatal("error loading chart") - } + require.NoError(t, err, "error loading chart") err = os.RemoveAll(filepath.Join(srv.Dir, "oci-dependent-chart")) - if err != nil { - t.Fatal("error removing chart before push") - } + require.NoError(t, err, "error removing chart before push") // save it back to disk.. absPath, err := chartutil.Save(ch, srv.Dir) - if err != nil { - t.Fatal("could not create chart archive") - } + require.NoError(t, err, "could not create chart archive") // load it into memory... contentBytes, err := os.ReadFile(absPath) - if err != nil { - t.Fatal("could not load chart into memory") - } + require.NoError(t, err, "could not load chart into memory") result, err := registryClient.Push(contentBytes, ref) - if err != nil { - t.Fatalf("error pushing dependent chart: %s", err) - } + require.NoError(t, err, "error pushing dependent chart") t.Logf("Manifest.Digest: %s, Manifest.Size: %d, "+ "Config.Digest: %s, Config.Size: %d, "+ "Chart.Digest: %s, Chart.Size: %d", @@ -305,14 +278,10 @@ func (srv *OCIServer) RunWithReturn(t *testing.T, opts ...OCIServerOpt) *OCIServ absPath = filepath.Join(srv.Dir, fmt.Sprintf("%s-%s.tgz", c.Metadata.Name, c.Metadata.Version)) contentBytes, err = os.ReadFile(absPath) - if err != nil { - t.Fatal("could not load chart into memory") - } + require.NoError(t, err, "could not load chart into memory") result, err = registryClient.Push(contentBytes, dependingRef) - if err != nil { - t.Fatalf("error pushing depending chart: %s", err) - } + require.NoError(t, err, "error pushing depending chart") t.Logf("Manifest.Digest: %s, Manifest.Size: %d, "+ "Config.Digest: %s, Config.Size: %d, "+ "Chart.Digest: %s, Chart.Size: %d", diff --git a/pkg/repo/v1/repotest/server_test.go b/pkg/repo/v1/repotest/server_test.go index f0e374fc0..7008e2305 100644 --- a/pkg/repo/v1/repotest/server_test.go +++ b/pkg/repo/v1/repotest/server_test.go @@ -22,6 +22,8 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "sigs.k8s.io/yaml" "helm.sh/helm/v4/internal/test/ensure" @@ -39,62 +41,43 @@ func TestServer(t *testing.T) { defer srv.Stop() c, err := srv.CopyCharts("testdata/*.tgz") - if err != nil { - // Some versions of Go don't correctly fire defer on Fatal. - t.Fatal(err) - } + require.NoError(t, err) - if len(c) != 1 { - t.Errorf("Unexpected chart count: %d", len(c)) - } + assert.Len(t, c, 1) - if filepath.Base(c[0]) != "examplechart-0.1.0.tgz" { - t.Errorf("Unexpected chart: %s", c[0]) - } + assert.Equal(t, "examplechart-0.1.0.tgz", filepath.Base(c[0])) - res, err := http.Get(srv.URL() + "/examplechart-0.1.0.tgz") + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/examplechart-0.1.0.tgz", http.NoBody) + require.NoError(t, err) + client := http.DefaultClient + res, err := client.Do(req) + require.NoError(t, err) res.Body.Close() - if err != nil { - t.Fatal(err) - } - - if res.ContentLength < 500 { - t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) - } - res, err = http.Get(srv.URL() + "/index.yaml") - if err != nil { - t.Fatal(err) - } + assert.GreaterOrEqual(t, res.ContentLength, int64(500)) + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/index.yaml", http.NoBody) + require.NoError(t, err) + res, err = client.Do(req) + require.NoError(t, err) data, err := io.ReadAll(res.Body) res.Body.Close() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) m := repo.NewIndexFile() - if err := yaml.Unmarshal(data, m); err != nil { - t.Fatal(err) - } + require.NoError(t, yaml.Unmarshal(data, m)) - if l := len(m.Entries); l != 1 { - t.Fatalf("Expected 1 entry, got %d", l) - } + require.Len(t, m.Entries, 1) expect := "examplechart" - if !m.Has(expect, "0.1.0") { - t.Errorf("missing %q", expect) - } + assert.True(t, m.Has(expect, "0.1.0"), "missing %q", expect) - res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/index.yaml-nosuchthing", http.NoBody) + require.NoError(t, err) + res, err = client.Do(req) + require.NoError(t, err) res.Body.Close() - if err != nil { - t.Fatal(err) - } - if res.StatusCode != http.StatusNotFound { - t.Fatalf("Expected 404, got %d", res.StatusCode) - } + require.Equal(t, http.StatusNotFound, res.StatusCode) } func TestNewTempServer(t *testing.T) { @@ -126,84 +109,52 @@ func TestNewTempServer(t *testing.T) { ) defer srv.Stop() - if srv.srv.URL == "" { - t.Fatal("unstarted server") - } + require.NotEmpty(t, srv.srv.URL, "unstarted server") client := srv.Client() { - res, err := client.Head(srv.URL() + "/repositories.yaml") - if err != nil { - t.Error(err) - } - + req, err := http.NewRequestWithContext(t.Context(), http.MethodHead, srv.URL()+"/repositories.yaml", http.NoBody) + require.NoError(t, err) + res, err := client.Do(req) + require.NoError(t, err) res.Body.Close() - - if res.StatusCode != http.StatusOK { - t.Errorf("Expected 200, got %d", res.StatusCode) - } - + assert.Equal(t, http.StatusOK, res.StatusCode) } - { - res, err := client.Head(srv.URL() + "/examplechart-0.1.0.tgz") - if err != nil { - t.Error(err) - } + req, err := http.NewRequestWithContext(t.Context(), http.MethodHead, srv.URL()+"/examplechart-0.1.0.tgz", http.NoBody) + require.NoError(t, err) + res, err := client.Do(req) + require.NoError(t, err) res.Body.Close() - - if res.StatusCode != http.StatusOK { - t.Errorf("Expected 200, got %d", res.StatusCode) - } + assert.Equal(t, http.StatusOK, res.StatusCode) } - - res, err := client.Get(srv.URL() + "/examplechart-0.1.0.tgz") + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/examplechart-0.1.0.tgz", http.NoBody) + require.NoError(t, err) + res, err := client.Do(req) + require.NoError(t, err) res.Body.Close() - if err != nil { - t.Fatal(err) - } - - if res.ContentLength < 500 { - t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) - } - - res, err = client.Get(srv.URL() + "/index.yaml") - if err != nil { - t.Fatal(err) - } - + assert.GreaterOrEqual(t, res.ContentLength, int64(500)) + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/index.yaml", http.NoBody) + require.NoError(t, err) + res, err = client.Do(req) + require.NoError(t, err) data, err := io.ReadAll(res.Body) res.Body.Close() - if err != nil { - t.Fatal(err) - } - + require.NoError(t, err) m := repo.NewIndexFile() - if err := yaml.Unmarshal(data, m); err != nil { - t.Fatal(err) - } - - if l := len(m.Entries); l != 1 { - t.Fatalf("Expected 1 entry, got %d", l) - } - + require.NoError(t, yaml.Unmarshal(data, m)) + require.Len(t, m.Entries, 1) expect := "examplechart" - if !m.Has(expect, "0.1.0") { - t.Errorf("missing %q", expect) - } - - res, err = client.Get(srv.URL() + "/index.yaml-nosuchthing") + assert.True(t, m.Has(expect, "0.1.0"), "missing %q", expect) + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL()+"/index.yaml-nosuchthing", http.NoBody) + require.NoError(t, err) + res, err = client.Do(req) + require.NoError(t, err) res.Body.Close() - if err != nil { - t.Fatal(err) - } - if res.StatusCode != http.StatusNotFound { - t.Fatalf("Expected 404, got %d", res.StatusCode) - } + require.Equal(t, http.StatusNotFound, res.StatusCode) }) } - } func TestNewTempServer_TLS(t *testing.T) { @@ -216,7 +167,5 @@ func TestNewTempServer_TLS(t *testing.T) { ) defer srv.Stop() - if !strings.HasPrefix(srv.URL(), "https://") { - t.Fatal("non-TLS server") - } + require.True(t, strings.HasPrefix(srv.URL(), "https://"), "non-TLS server") } diff --git a/pkg/repo/v1/repotest/tlsconfig.go b/pkg/repo/v1/repotest/tlsconfig.go index 3ea7338ff..d579f8054 100644 --- a/pkg/repo/v1/repotest/tlsconfig.go +++ b/pkg/repo/v1/repotest/tlsconfig.go @@ -35,8 +35,7 @@ func MakeTestTLSConfig(t *testing.T, path string) *tls.Config { tlsutil.WithCertKeyPairFiles(pub, priv), tlsutil.WithCAFile(ca), ) - //require.Nil(t, err, err.Error()) - require.Nil(t, err) + require.NoError(t, err) tlsConf.ServerName = "helm.sh" diff --git a/pkg/storage/driver/cfgmaps.go b/pkg/storage/driver/cfgmaps.go index f82ade5e9..00a0832b3 100644 --- a/pkg/storage/driver/cfgmaps.go +++ b/pkg/storage/driver/cfgmaps.go @@ -171,7 +171,7 @@ func (cfgmaps *ConfigMaps) Create(key string, rls release.Releaser) error { lbs.init() lbs.fromMap(rac.Labels()) - lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) rel, err := releaserToV1Release(rls) if err != nil { @@ -209,7 +209,7 @@ func (cfgmaps *ConfigMaps) Update(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new configmap object to hold the release obj, err := newConfigMapsObject(key, rls, lbs) diff --git a/pkg/storage/driver/memory_test.go b/pkg/storage/driver/memory_test.go index 329b82b2f..95f85faa6 100644 --- a/pkg/storage/driver/memory_test.go +++ b/pkg/storage/driver/memory_test.go @@ -280,7 +280,7 @@ func TestMemoryDelete(t *testing.T) { } _, err = ts.Get(tt.key) if err == nil { - t.Errorf("Expected an error when asking for a deleted key") + t.Error("Expected an error when asking for a deleted key") } } @@ -300,5 +300,4 @@ func TestMemoryDelete(t *testing.T) { t.Logf("Name: %s, Version: %d", rac.Name(), rac.Version()) } } - } diff --git a/pkg/storage/driver/secrets.go b/pkg/storage/driver/secrets.go index a73f3cf05..5e12684df 100644 --- a/pkg/storage/driver/secrets.go +++ b/pkg/storage/driver/secrets.go @@ -171,7 +171,7 @@ func (secrets *Secrets) Create(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("createdAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new secret to hold the release obj, err := newSecretsObject(key, rls, lbs) @@ -202,7 +202,7 @@ func (secrets *Secrets) Update(key string, rel release.Releaser) error { lbs.init() lbs.fromMap(rls.Labels) - lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix())) + lbs.set("modifiedAt", strconv.FormatInt(time.Now().Unix(), 10)) // create a new secret object to hold the release obj, err := newSecretsObject(key, rls, lbs) diff --git a/pkg/storage/driver/secrets_test.go b/pkg/storage/driver/secrets_test.go index f4aa1176c..a11ec4380 100644 --- a/pkg/storage/driver/secrets_test.go +++ b/pkg/storage/driver/secrets_test.go @@ -165,7 +165,7 @@ func TestSecretQuery(t *testing.T) { } _, err = secrets.Query(map[string]string{"name": "notExist"}) - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err) } } @@ -237,7 +237,7 @@ func TestSecretDelete(t *testing.T) { // perform the delete on a non-existing release _, err := secrets.Delete("nonexistent") - if err != ErrReleaseNotFound { + if !errors.Is(err, ErrReleaseNotFound) { t.Fatalf("Expected ErrReleaseNotFound, got: {%v}", err) } diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go index 21d9f6679..f3dd053a2 100644 --- a/pkg/storage/driver/sql.go +++ b/pkg/storage/driver/sql.go @@ -134,7 +134,6 @@ func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool { } func (s *SQL) ensureDBSetup() error { - migrations := &migrate.MemoryMigrationSource{ Migrations: []*migrate.Migration{ { @@ -485,7 +484,7 @@ func (s *SQL) Create(key string, rel release.Releaser) error { transaction, err := s.db.Beginx() if err != nil { s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) - return fmt.Errorf("error beginning transaction: %v", err) + return fmt.Errorf("error beginning transaction: %w", err) } insertQuery, args, err := s.statementBuilder. @@ -623,7 +622,7 @@ func (s *SQL) Delete(key string) (release.Releaser, error) { transaction, err := s.db.Beginx() if err != nil { s.Logger().Debug("failed to start SQL transaction", slog.Any("error", err)) - return nil, fmt.Errorf("error beginning transaction: %v", err) + return nil, fmt.Errorf("error beginning transaction: %w", err) } selectQuery, args, err := s.statementBuilder. diff --git a/pkg/storage/driver/sql_test.go b/pkg/storage/driver/sql_test.go index a1847e199..4b4686b66 100644 --- a/pkg/storage/driver/sql_test.go +++ b/pkg/storage/driver/sql_test.go @@ -290,7 +290,7 @@ func TestSqlCreateAlreadyExists(t *testing.T) { mock. ExpectExec(regexp.QuoteMeta(insertQuery)). WithArgs(key, sqlReleaseDefaultType, body, rel.Name, rel.Namespace, int(rel.Version), rel.Info.Status.String(), sqlReleaseDefaultOwner, recentUnixTimestamp()). - WillReturnError(fmt.Errorf("dialect dependent SQL error")) + WillReturnError(errors.New("dialect dependent SQL error")) selectQuery := fmt.Sprintf( regexp.QuoteMeta("SELECT %s FROM %s WHERE %s = $1 AND %s = $2"), diff --git a/pkg/storage/driver/util.go b/pkg/storage/driver/util.go index ca8e23cc2..756731ef6 100644 --- a/pkg/storage/driver/util.go +++ b/pkg/storage/driver/util.go @@ -46,9 +46,12 @@ func encodeRelease(rls *rspb.Release) (string, error) { return "", err } if _, err = w.Write(b); err != nil { + w.Close() + return "", err + } + if err = w.Close(); err != nil { return "", err } - w.Close() return b64.EncodeToString(buf.Bytes()), nil } diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go index 0055c095c..9b4203224 100644 --- a/pkg/storage/storage_test.go +++ b/pkg/storage/storage_test.go @@ -208,7 +208,7 @@ func TestStorageDeployed(t *testing.T) { switch { case rls == nil: - t.Fatalf("Release is nil") + t.Fatal("Release is nil") case rel.Name != name: t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) case rel.Version != vers: @@ -251,7 +251,7 @@ func TestStorageDeployedWithCorruption(t *testing.T) { switch { case rls == nil: - t.Fatalf("Release is nil") + t.Fatal("Release is nil") case rel.Name != name: t.Fatalf("Expected release name %q, actual %q\n", name, rel.Name) case rel.Version != vers: @@ -576,7 +576,7 @@ func (test ReleaseTestData) ToRelease() *rspb.Release { } } -func assertErrNil(eh func(args ...interface{}), err error, message string) { +func assertErrNil(eh func(args ...any), err error, message string) { if err != nil { eh(fmt.Sprintf("%s: %q", message, err)) } @@ -589,7 +589,7 @@ func TestStorageGetsLoggerFromDriver(t *testing.T) { s := Init(d) _, _ = s.Get("doesnt-matter", 123) if !l.Called { - t.Fatalf("Expected storage to use driver's logger, but it did not") + t.Fatal("Expected storage to use driver's logger, but it did not") } } diff --git a/pkg/strvals/literal_parser.go b/pkg/strvals/literal_parser.go index c2a824220..963558113 100644 --- a/pkg/strvals/literal_parser.go +++ b/pkg/strvals/literal_parser.go @@ -26,8 +26,8 @@ import ( // ParseLiteral parses a set line interpreting the value as a literal string. // // A set line is of the form name1=value1 -func ParseLiteral(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseLiteral(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newLiteralParser(scanner, vals) err := t.parse() @@ -39,7 +39,7 @@ func ParseLiteral(s string) (map[string]interface{}, error) { // // If the strval string has a key that exists in dest, it overwrites the // dest version. -func ParseLiteralInto(s string, dest map[string]interface{}) error { +func ParseLiteralInto(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newLiteralParser(scanner, dest) return t.parse() @@ -54,10 +54,10 @@ func ParseLiteralInto(s string, dest map[string]interface{}) error { // where data is the final parsed data from the parses with correct types type literalParser struct { sc *bytes.Buffer - data map[string]interface{} + data map[string]any } -func newLiteralParser(sc *bytes.Buffer, data map[string]interface{}) *literalParser { +func newLiteralParser(sc *bytes.Buffer, data map[string]any) *literalParser { return &literalParser{sc: sc, data: data} } @@ -88,7 +88,7 @@ func runesUntilLiteral(in io.RuneReader, stop map[rune]bool) ([]rune, rune, erro } } -func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { +func (t *literalParser) key(data map[string]any, nestedNameLevel int) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to parse key: %s", r) @@ -120,9 +120,9 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r } // first, create or find the target map in the given data - inner := map[string]interface{}{} + inner := map[string]any{} if _, ok := data[string(key)]; ok { - inner = data[string(key)].(map[string]interface{}) + inner = data[string(key)].(map[string]any) } // recurse on sub-tree with remaining data @@ -144,9 +144,9 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r kk := string(key) // find or create target list - list := []interface{}{} + list := []any{} if _, ok := data[kk]; ok { - list = data[kk].([]interface{}) + list = data[kk].([]any) } // now we need to get the value after the ] @@ -169,7 +169,7 @@ func (t *literalParser) keyIndex() (int, error) { return strconv.Atoi(string(v)) } -func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { +func (t *literalParser) listItem(list []any, i, nestedNameLevel int) ([]any, error) { if i < 0 { return list, fmt.Errorf("negative %d index not allowed", i) } @@ -191,14 +191,14 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] case lastRune == '.': // we have a nested object. Send to t.key - inner := map[string]interface{}{} + inner := map[string]any{} if len(list) > i { var ok bool - inner, ok = list[i].(map[string]interface{}) + inner, ok = list[i].(map[string]any) if !ok { // We have indices out of order. Initialize empty value. - list[i] = map[string]interface{}{} - inner = list[i].(map[string]interface{}) + list[i] = map[string]any{} + inner = list[i].(map[string]any) } } @@ -215,12 +215,12 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([] if err != nil { return list, fmt.Errorf("error parsing index: %w", err) } - var crtList []interface{} + var crtList []any if len(list) > i { // If nested list already exists, take the value of list to next cycle. existed := list[i] if existed != nil { - crtList = list[i].([]interface{}) + crtList = list[i].([]any) } } diff --git a/pkg/strvals/literal_parser_test.go b/pkg/strvals/literal_parser_test.go index cc3d73d50..d7892844f 100644 --- a/pkg/strvals/literal_parser_test.go +++ b/pkg/strvals/literal_parser_test.go @@ -20,6 +20,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" ) @@ -278,27 +281,25 @@ func TestParseLiteral(t *testing.T) { got, err := ParseLiteral(tt.str) if err != nil { if !tt.err { - t.Fatalf("%s: %s", tt.str, err) + require.NoError(t, err, tt.str) } continue } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.str) + assert.Fail(t, "Expected error. Got nil", tt.str) } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.str) } } } @@ -385,33 +386,31 @@ func TestParseLiteralInto(t *testing.T) { for _, tt := range tests { if err := ParseLiteralInto(tt.input, tt.got); err != nil { - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.input) + assert.Fail(t, "Expected error. Got nil", tt.input) } if tt.input2 != "" { if err := ParseLiteralInto(tt.input2, tt.got); err != nil { - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.input2) + assert.Fail(t, "Expected error. Got nil", tt.input2) } } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(tt.got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.input) } } } @@ -452,30 +451,28 @@ func TestParseLiteralNestedLevels(t *testing.T) { if tt.err { if tt.errStr != "" { if err.Error() != tt.errStr { - t.Errorf("Expected error: %s. Got error: %s", tt.errStr, err.Error()) + assert.Equal(t, tt.errStr, err.Error()) } } continue } - t.Fatalf("%s: %s", tt.str, err) + require.NoError(t, err, tt.str) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.str) + assert.Fail(t, "Expected error. Got nil", tt.str) } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.str) } } } diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go index 8eb761dce..1dbbd0e1b 100644 --- a/pkg/strvals/parser.go +++ b/pkg/strvals/parser.go @@ -52,8 +52,8 @@ func ToYAML(s string) (string, error) { // Parse parses a set line. // // A set line is of the form name1=value1,name2=value2 -func Parse(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func Parse(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newParser(scanner, vals, false) err := t.parse() @@ -63,8 +63,8 @@ func Parse(s string) (map[string]interface{}, error) { // ParseString parses a set line and forces a string value. // // A set line is of the form name1=value1,name2=value2 -func ParseString(s string) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseString(s string) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newParser(scanner, vals, true) err := t.parse() @@ -75,7 +75,7 @@ func ParseString(s string) (map[string]interface{}, error) { // // If the strval string has a key that exists in dest, it overwrites the // dest version. -func ParseInto(s string, dest map[string]interface{}) error { +func ParseInto(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newParser(scanner, dest, false) return t.parse() @@ -87,8 +87,8 @@ func ParseInto(s string, dest map[string]interface{}) error { // // When the files at path1 and path2 contained "val1" and "val2" respectively, the set line is consumed as // name1=val1,name2=val2 -func ParseFile(s string, reader RunesValueReader) (map[string]interface{}, error) { - vals := map[string]interface{}{} +func ParseFile(s string, reader RunesValueReader) (map[string]any, error) { + vals := map[string]any{} scanner := bytes.NewBufferString(s) t := newFileParser(scanner, vals, reader) err := t.parse() @@ -98,7 +98,7 @@ func ParseFile(s string, reader RunesValueReader) (map[string]interface{}, error // ParseIntoString parses a strvals line and merges the result into dest. // // This method always returns a string as the value. -func ParseIntoString(s string, dest map[string]interface{}) error { +func ParseIntoString(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newParser(scanner, dest, true) return t.parse() @@ -109,7 +109,7 @@ func ParseIntoString(s string, dest map[string]interface{}) error { // An empty val is treated as null. // // If a key exists in dest, the new value overwrites the dest version. -func ParseJSON(s string, dest map[string]interface{}) error { +func ParseJSON(s string, dest map[string]any) error { scanner := bytes.NewBufferString(s) t := newJSONParser(scanner, dest) return t.parse() @@ -118,7 +118,7 @@ func ParseJSON(s string, dest map[string]interface{}) error { // ParseIntoFile parses a filevals line and merges the result into dest. // // This method always returns a string as the value. -func ParseIntoFile(s string, dest map[string]interface{}, reader RunesValueReader) error { +func ParseIntoFile(s string, dest map[string]any, reader RunesValueReader) error { scanner := bytes.NewBufferString(s) t := newFileParser(scanner, dest, reader) return t.parse() @@ -126,7 +126,7 @@ func ParseIntoFile(s string, dest map[string]interface{}, reader RunesValueReade // RunesValueReader is a function that takes the given value (a slice of runes) // and returns the parsed value -type RunesValueReader func([]rune) (interface{}, error) +type RunesValueReader func([]rune) (any, error) // parser is a simple parser that takes a strvals line and parses it into a // map representation. @@ -135,23 +135,23 @@ type RunesValueReader func([]rune) (interface{}, error) // where data is the final parsed data from the parses with correct types type parser struct { sc *bytes.Buffer - data map[string]interface{} + data map[string]any reader RunesValueReader isjsonval bool } -func newParser(sc *bytes.Buffer, data map[string]interface{}, stringBool bool) *parser { - stringConverter := func(rs []rune) (interface{}, error) { +func newParser(sc *bytes.Buffer, data map[string]any, stringBool bool) *parser { + stringConverter := func(rs []rune) (any, error) { return typedVal(rs, stringBool), nil } return &parser{sc: sc, data: data, reader: stringConverter} } -func newJSONParser(sc *bytes.Buffer, data map[string]interface{}) *parser { +func newJSONParser(sc *bytes.Buffer, data map[string]any) *parser { return &parser{sc: sc, data: data, reader: nil, isjsonval: true} } -func newFileParser(sc *bytes.Buffer, data map[string]interface{}, reader RunesValueReader) *parser { +func newFileParser(sc *bytes.Buffer, data map[string]any, reader RunesValueReader) *parser { return &parser{sc: sc, data: data, reader: reader} } @@ -176,7 +176,7 @@ func runeSet(r []rune) map[rune]bool { return s } -func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr error) { +func (t *parser) key(data map[string]any, nestedNameLevel int) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to parse key: %s", r) @@ -200,9 +200,9 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } kk := string(k) // Find or create target list - list := []interface{}{} + list := []any{} if _, ok := data[kk]; ok { - list = data[kk].([]interface{}) + list = data[kk].([]any) } // Now we need to get the value after the ]. @@ -224,7 +224,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). - var jsonval interface{} + var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) if err = dec.Decode(&jsonval); err != nil { return err @@ -270,9 +270,9 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } // First, create or find the target map. - inner := map[string]interface{}{} + inner := map[string]any{} if _, ok := data[string(k)]; ok { - inner = data[string(k)].(map[string]interface{}) + inner = data[string(k)].(map[string]any) } // Recurse @@ -288,7 +288,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e } } -func set(data map[string]interface{}, key string, val interface{}) { +func set(data map[string]any, key string, val any) { // If key is empty, don't set it. if len(key) == 0 { return @@ -296,7 +296,7 @@ func set(data map[string]interface{}, key string, val interface{}) { data[key] = val } -func setIndex(list []interface{}, index int, val interface{}) (l2 []interface{}, err error) { +func setIndex(list []any, index int, val any) (l2 []any, err error) { // There are possible index values that are out of range on a target system // causing a panic. This will catch the panic and return an error instead. // The value of the index that causes a panic varies from system to system. @@ -313,7 +313,7 @@ func setIndex(list []interface{}, index int, val interface{}) (l2 []interface{}, return list, fmt.Errorf("index of %d is greater than maximum supported index of %d", index, MaxIndex) } if len(list) <= index { - newlist := make([]interface{}, index+1) + newlist := make([]any, index+1) copy(newlist, list) list = newlist } @@ -330,10 +330,9 @@ func (t *parser) keyIndex() (int, error) { } // v should be the index return strconv.Atoi(string(v)) - } -func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) { +func (t *parser) listItem(list []any, i, nestedNameLevel int) ([]any, error) { if i < 0 { return list, fmt.Errorf("negative %d index not allowed", i) } @@ -357,7 +356,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa // Since Decode has its own buffer that consumes more characters (from underlying t.sc) than the ones actually decoded, // we invoke Decode on a separate reader built with a copy of what is left in t.sc. After Decode is executed, we // discard in t.sc the chars of the decoded json value (the number of those characters is returned by InputOffset). - var jsonval interface{} + var jsonval any dec := json.NewDecoder(strings.NewReader(t.sc.String())) if err = dec.Decode(&jsonval); err != nil { return list, err @@ -397,12 +396,12 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa if err != nil { return list, fmt.Errorf("error parsing index: %w", err) } - var crtList []interface{} + var crtList []any if len(list) > i { // If nested list already exists, take the value of list to next cycle. existed := list[i] if existed != nil { - crtList = list[i].([]interface{}) + crtList = list[i].([]any) } } // Now we need to get the value after the ]. @@ -413,14 +412,14 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa return setIndex(list, i, list2) case last == '.': // We have a nested object. Send to t.key - inner := map[string]interface{}{} + inner := map[string]any{} if len(list) > i { var ok bool - inner, ok = list[i].(map[string]interface{}) + inner, ok = list[i].(map[string]any) if !ok { // We have indices out of order. Initialize empty value. - list[i] = map[string]interface{}{} - inner = list[i].(map[string]interface{}) + list[i] = map[string]any{} + inner = list[i].(map[string]any) } } @@ -463,18 +462,18 @@ func (t *parser) val() ([]rune, error) { return v, err } -func (t *parser) valList() ([]interface{}, error) { +func (t *parser) valList() ([]any, error) { r, _, e := t.sc.ReadRune() if e != nil { - return []interface{}{}, e + return []any{}, e } if r != '{' { t.sc.UnreadRune() - return []interface{}{}, ErrNotList + return []any{}, ErrNotList } - list := []interface{}{} + list := []any{} stop := runeSet([]rune{',', '}'}) for { switch rs, last, err := runesUntil(t.sc, stop); { @@ -526,7 +525,7 @@ func inMap(k rune, m map[rune]bool) bool { return ok } -func typedVal(v []rune, st bool) interface{} { +func typedVal(v []rune, st bool) any { val := string(v) if st { diff --git a/pkg/strvals/parser_test.go b/pkg/strvals/parser_test.go index e3f8b9d8e..1a9559236 100644 --- a/pkg/strvals/parser_test.go +++ b/pkg/strvals/parser_test.go @@ -20,6 +20,9 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" ) @@ -78,24 +81,24 @@ func TestSetIndex(t *testing.T) { got, err := setIndex(tt.initial, tt.add, tt.val) if err != nil && tt.err == false { - t.Fatalf("%s: Expected no error but error returned", tt.name) + require.Fail(t, "Expected no error but error returned", tt.name) } else if err == nil && tt.err == true { - t.Fatalf("%s: Expected error but no error returned", tt.name) + require.Fail(t, "Expected error but no error returned", tt.name) } if len(got) != len(tt.expect) { - t.Fatalf("%s: Expected length %d, got %d", tt.name, len(tt.expect), len(got)) + require.Len(t, got, len(tt.expect), tt.name) } if !tt.err { if gg := got[tt.add].(int); gg != tt.val { - t.Errorf("%s, Expected value %d, got %d", tt.name, tt.val, gg) + assert.Equal(t, tt.val, gg, tt.name) } } for k, v := range got { if v != tt.expect[k] { - t.Errorf("%s, Expected value %d, got %d", tt.name, tt.expect[k], v) + assert.Equal(t, tt.expect[k], v, tt.name) } } } @@ -384,23 +387,21 @@ func TestParseSet(t *testing.T) { if tt.err { continue } - t.Fatalf("%s: %s", tt.str, err) + require.NoError(t, err, tt.str) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.str) + assert.Fail(t, "Expected error. Got nil", tt.str) } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.str) } } for _, tt := range testsString { @@ -409,23 +410,21 @@ func TestParseSet(t *testing.T) { if tt.err { continue } - t.Fatalf("%s: %s", tt.str, err) + require.NoError(t, err, tt.str) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.str) + assert.Fail(t, "Expected error. Got nil", tt.str) } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.str) } } } @@ -513,32 +512,30 @@ func TestParseInto(t *testing.T) { } for _, tt := range tests { if err := ParseInto(tt.input, tt.got); err != nil { - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.input) + assert.Fail(t, "Expected error. Got nil", tt.input) } if tt.input2 != "" { if err := ParseInto(tt.input2, tt.got); err != nil { - t.Fatal(err) + require.NoError(t, err) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.input2) + assert.Fail(t, "Expected error. Got nil", tt.input2) } } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(tt.got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.input) } } } @@ -560,20 +557,18 @@ func TestParseIntoString(t *testing.T) { } if err := ParseIntoString(input, got); err != nil { - t.Fatal(err) + require.NoError(t, err) } y1, err := yaml.Marshal(expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + assert.Equal(t, string(y1), string(y2), input) } } @@ -658,22 +653,22 @@ func TestParseJSON(t *testing.T) { if tt.err { continue } - t.Fatalf("%s: %s", tt.input, err) + require.NoError(t, err, tt.input) } if tt.err { - t.Fatalf("%s: Expected error. Got nil", tt.input) + require.Fail(t, "Expected error. Got nil", tt.input) } y1, err := yaml.Marshal(tt.expect) if err != nil { - t.Fatalf("Error serializing expected value: %s", err) + require.NoError(t, err, "Error serializing expected value") } y2, err := yaml.Marshal(tt.got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.input, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.input) } } } @@ -686,28 +681,24 @@ func TestParseFile(t *testing.T) { rs2v := func(rs []rune) (any, error) { v := string(rs) if v != "path1" { - t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) + assert.Equal(t, "path1", v, "%s: runesToVal", input) return "", nil } return "value1", nil } got, err := ParseFile(input, rs2v) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y1, err := yaml.Marshal(expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + assert.Equal(t, string(y1), string(y2), input) } } @@ -720,27 +711,25 @@ func TestParseIntoFile(t *testing.T) { rs2v := func(rs []rune) (any, error) { v := string(rs) if v != "path1" { - t.Errorf("%s: runesToVal: Expected value path1, got %s", input, v) + assert.Equal(t, "path1", v, "%s: runesToVal", input) return "", nil } return "value1", nil } if err := ParseIntoFile(input, got, rs2v); err != nil { - t.Fatal(err) + require.NoError(t, err) } y1, err := yaml.Marshal(expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", input, y1, y2) + assert.Equal(t, string(y1), string(y2), input) } } @@ -748,12 +737,10 @@ func TestToYAML(t *testing.T) { // The TestParse does the hard part. We just verify that YAML formatting is // happening. o, err := ToYAML("name=value") - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) expect := "name: value" if o != expect { - t.Errorf("Expected %q, got %q", expect, o) + assert.Equal(t, expect, o) } } @@ -792,28 +779,26 @@ func TestParseSetNestedLevels(t *testing.T) { if tt.err { if tt.errStr != "" { if err.Error() != tt.errStr { - t.Errorf("Expected error: %s. Got error: %s", tt.errStr, err.Error()) + assert.Equal(t, tt.errStr, err.Error()) } } continue } - t.Fatalf("%s: %s", tt.str, err) + require.NoError(t, err, tt.str) } if tt.err { - t.Errorf("%s: Expected error. Got nil", tt.str) + assert.Fail(t, "Expected error. Got nil", tt.str) } y1, err := yaml.Marshal(tt.expect) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) y2, err := yaml.Marshal(got) if err != nil { - t.Fatalf("Error serializing parsed value: %s", err) + require.NoError(t, err, "Error serializing parsed value") } if string(y1) != string(y2) { - t.Errorf("%s: Expected:\n%s\nGot:\n%s", tt.str, y1, y2) + assert.Equal(t, string(y1), string(y2), tt.str) } } } diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 48328cb38..763c07c03 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -20,10 +20,10 @@ PREVIOUS_RELEASE=${PREVIOUS_RELEASE:-$1} ## Ensure Correct Usage if [[ -z "${PREVIOUS_RELEASE}" || -z "${RELEASE}" ]]; then echo Usage: - echo ./scripts/release-notes.sh v3.0.0 v3.1.0 + echo ./scripts/release-notes.sh v4.0.0 v4.1.0 echo or - echo PREVIOUS_RELEASE=v3.0.0 - echo RELEASE=v3.1.0 + echo PREVIOUS_RELEASE=v4.0.0 + echo RELEASE=v4.1.0 echo ./scripts/release-notes.sh exit 1 fi @@ -94,7 +94,7 @@ Download Helm ${RELEASE}. The common platform binaries are here: - [Windows amd64](https://get.helm.sh/helm-${RELEASE}-windows-amd64.zip) ([checksum](https://get.helm.sh/helm-${RELEASE}-windows-amd64.zip.sha256sum) / $(cat _dist/helm-${RELEASE}-windows-amd64.zip.sha256)) - [Windows arm64](https://get.helm.sh/helm-${RELEASE}-windows-arm64.zip) ([checksum](https://get.helm.sh/helm-${RELEASE}-windows-arm64.zip.sha256sum) / $(cat _dist/helm-${RELEASE}-windows-arm64.zip.sha256)) -The [Quickstart Guide](https://helm.sh/docs/intro/quickstart/) will get you going from there. For **upgrade instructions** or detailed installation notes, check the [install guide](https://helm.sh/docs/intro/install/). You can also use a [script to install](https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3) on any system with \`bash\`. +The [Quickstart Guide](https://helm.sh/docs/intro/quickstart/) will get you going from there. For **upgrade instructions** or detailed installation notes, check the [install guide](https://helm.sh/docs/intro/install/). You can also use a [script to install](https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4) on any system with \`bash\`. ## What's Next