diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index b377a086c..000000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,14 +0,0 @@
----
-
-# This file can be removed when Helm no longer uses CircleCI on any release
-# branches. Once CircleCI is turned off this file can be removed.
-version: 2
-
-jobs:
- build:
- docker:
- - image: cimg/go:1.18
-
- steps:
- - checkout
-
diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml
new file mode 100644
index 000000000..4309d800b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.yaml
@@ -0,0 +1,69 @@
+name: Bug Report
+description: Report a bug encountered in Helm
+labels: kind/bug
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What happened?
+ description: |
+ Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner.
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: What did you expect to happen?
+ validations:
+ required: true
+
+ - type: textarea
+ id: repro
+ attributes:
+ label: How can we reproduce it (as minimally and precisely as possible)?
+ description: |
+ Please list steps someone can follow to trigger the issue.
+
+ For example:
+ 1. Run `helm install mychart ./path-to-chart -f values.yaml --debug`
+ 2. Observe the following error: ...
+
+ You can include:
+ - a sample `values.yaml` block
+ - a link to a chart
+ - specific `helm` commands used
+
+ This helps others reproduce and debug your issue more effectively.
+ validations:
+ required: true
+
+ - type: textarea
+ id: helmVersion
+ attributes:
+ label: Helm version
+ value: |
+
+ ```console
+ $ helm version
+ # paste output here
+ ```
+
+ validations:
+ required: true
+
+ - type: textarea
+ id: kubeVersion
+ attributes:
+ label: Kubernetes version
+ value: |
+
+
+ ```console
+ $ kubectl version
+ # paste output here
+ ```
+
+
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/documentation.yaml b/.github/ISSUE_TEMPLATE/documentation.yaml
new file mode 100644
index 000000000..bb1b7537c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/documentation.yaml
@@ -0,0 +1,27 @@
+name: Documentation
+description: Report any mistakes or missing information from the documentation or the examples
+labels: kind/documentation
+body:
+ - type: markdown
+ attributes:
+ value: |
+ ⚠️ **Note**: Most documentation lives in [helm/helm-www](https://github.com/helm/helm-www).
+ If your issue is about Helm website documentation or examples, please [open an issue there](https://github.com/helm/helm-www/issues/new/choose).
+
+ - type: textarea
+ id: feature
+ attributes:
+ label: What would you like to be added?
+ description: |
+ Link to the issue (please include a link to the specific documentation or example).
+ Link to the issue raised in [Helm Documentation Improvement Proposal](https://github.com/helm/helm-www)
+ validations:
+ required: true
+
+ - type: textarea
+ id: rationale
+ attributes:
+ label: Why is this needed?
+ validations:
+ required: true
+
diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml
new file mode 100644
index 000000000..45b9c3f94
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature.yaml
@@ -0,0 +1,21 @@
+name: Enhancement/feature
+description: Provide supporting details for a feature in development
+labels: kind/feature
+body:
+ - type: textarea
+ id: feature
+ attributes:
+ label: What would you like to be added?
+ description: |
+ Feature requests are unlikely to make progress as issues.
+ Initial discussion and ideas can happen on an issue.
+ But significant changes or features must be proposed as a [Helm Improvement Proposal](https://github.com/helm/community/blob/main/hips/hip-0001.md) (HIP)
+ validations:
+ required: true
+
+ - type: textarea
+ id: rationale
+ attributes:
+ label: Why is this needed?
+ validations:
+ required: true
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c9702f2cd..0133fd8f4 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,7 +1,24 @@
version: 2
updates:
+ - # Keep dev-v3 branch dependencies up to date, while Helm v3 is within support
+ package-ecosystem: "gomod"
+ target-branch: "dev-v3"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ groups:
+ k8s.io:
+ patterns:
+ - "k8s.io/api"
+ - "k8s.io/apiextensions-apiserver"
+ - "k8s.io/apimachinery"
+ - "k8s.io/apiserver"
+ - "k8s.io/cli-runtime"
+ - "k8s.io/client-go"
+ - "k8s.io/kubectl"
- package-ecosystem: "gomod"
+ target-branch: "main"
directory: "/"
schedule:
interval: "daily"
@@ -16,6 +33,7 @@ updates:
- "k8s.io/client-go"
- "k8s.io/kubectl"
- package-ecosystem: "github-actions"
+ target-branch: "main"
directory: "/"
schedule:
interval: "daily"
diff --git a/.github/env b/.github/env
new file mode 100644
index 000000000..4384ba074
--- /dev/null
+++ b/.github/env
@@ -0,0 +1,2 @@
+GOLANG_VERSION=1.24
+GOLANGCI_LINT_VERSION=v2.1.0
diff --git a/.github/issue_template.md b/.github/issue_template.md
deleted file mode 100644
index 48f48e5b6..000000000
--- a/.github/issue_template.md
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-Output of `helm version`:
-
-Output of `kubectl version`:
-
-Cloud Provider/Platform (AKS, GKE, Minikube etc.):
-
-
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index cda9086dd..0fe5f1106 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -7,6 +7,6 @@
**Special notes for your reviewer**:
**If applicable**:
-- [ ] this PR contains documentation
+- [ ] this PR contains user facing changes (the `docs needed` label should be applied if so)
- [ ] this PR contains unit tests
- [ ] this PR has been tested for backwards compatibility
diff --git a/.github/workflows/asset-transparency.yaml b/.github/workflows/asset-transparency.yaml
deleted file mode 100644
index ff58d1a5f..000000000
--- a/.github/workflows/asset-transparency.yaml
+++ /dev/null
@@ -1,18 +0,0 @@
-name: Publish Release Assets to Asset Transparency Log
-
-on:
- release:
- types: [published, created, edited, released]
-
-jobs:
- github_release_asset_transparency_log_publish_job:
- runs-on: ubuntu-latest
- name: Publish GitHub release asset digests to https://beta-asset.transparencylog.net
- steps:
- - name: Gather URLs from GitHub release and publish
- id: asset-transparency
- uses: transparencylog/github-releases-asset-transparency-verify-action@c77874b4514ae4003994ece9582675195fe012e2 # v11
- - name: List verified and published URLs
- run: echo "Verified URLs ${{ steps.asset-transparency.outputs.verified }}"
- - name: List failed URLs
- run: echo "Failed URLs ${{ steps.asset-transparency.outputs.failed }}"
diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 7696b4e21..6a9d217b0 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -2,34 +2,32 @@ name: build-test
on:
push:
branches:
- - 'main'
- - 'release-**'
+ - "main"
+ - "dev-v3"
+ - "release-**"
pull_request:
branches:
- - main
+ - "main"
+ - "dev-v3"
+
+permissions:
+ contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # pin@v3.6.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
+ - name: Add variables to environment file
+ run: cat ".github/env" >> "$GITHUB_ENV"
- name: Setup Go
- uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0
with:
- go-version: '1.20'
- - name: Install golangci-lint
- run: |
- curl -sSLO https://github.com/golangci/golangci-lint/releases/download/v$GOLANGCI_LINT_VERSION/golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz
- shasum -a 256 golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz | grep "^$GOLANGCI_LINT_SHA256 " > /dev/null
- tar -xf golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64.tar.gz
- sudo mv golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64/golangci-lint /usr/local/bin/golangci-lint
- rm -rf golangci-lint-$GOLANGCI_LINT_VERSION-linux-amd64*
- env:
- GOLANGCI_LINT_VERSION: '1.51.2'
- GOLANGCI_LINT_SHA256: '4de479eb9d9bc29da51aec1834e7c255b333723d38dbd56781c68e5dddc6a90b'
- - name: Test style
- run: make test-style
+ go-version: '${{ env.GOLANG_VERSION }}'
+ check-latest: true
+ - name: Test source headers are present
+ run: make test-source-headers
- name: Run unit tests
run: make test-coverage
- name: Test build
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 8b904e223..c1a2bff20 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -13,13 +13,21 @@ name: "CodeQL"
on:
push:
- branches: [ main ]
+ branches:
+ - main
+ - dev-v3
pull_request:
# The branches below must be a subset of the branches above
- branches: [ main ]
+ branches:
+ - main
+ - dev-v3
schedule:
- cron: '29 6 * * 6'
+permissions:
+ contents: read
+ security-events: write
+
jobs:
analyze:
name: Analyze
@@ -35,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # pin@v3.6.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # pinv2.22.3
+ uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pinv3.26.6
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -50,7 +58,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@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # pinv2.22.3
+ uses: github/codeql-action/autobuild@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pinv3.26.6
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -64,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # pinv2.22.3
+ uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # pinv3.26.6
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
new file mode 100644
index 000000000..0d5b4e969
--- /dev/null
+++ b/.github/workflows/golangci-lint.yml
@@ -0,0 +1,27 @@
+name: golangci-lint
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ golangci:
+ name: golangci-lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
+ - name: Add variables to environment file
+ run: cat ".github/env" >> "$GITHUB_ENV"
+ - name: Setup Go
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0
+ with:
+ go-version: '${{ env.GOLANG_VERSION }}'
+ check-latest: true
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 #pin@8.0.0
+ with:
+ version: ${{ env.GOLANGCI_LINT_VERSION }}
diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml
new file mode 100644
index 000000000..84d260a8f
--- /dev/null
+++ b/.github/workflows/govulncheck.yml
@@ -0,0 +1,28 @@
+name: govulncheck
+on:
+ push:
+ paths:
+ - go.sum
+ schedule:
+ - cron: "0 0 * * *"
+
+permissions: read-all
+
+jobs:
+ govulncheck:
+ name: govulncheck
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
+ - name: Add variables to environment file
+ run: cat ".github/env" >> "$GITHUB_ENV"
+ - name: Setup Go
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0
+ with:
+ go-version: '${{ env.GOLANG_VERSION }}'
+ check-latest: true
+ - name: govulncheck
+ uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # pin@1.0.4
+ with:
+ go-package: ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0a32365db..21c527442 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,6 +7,8 @@ on:
branches:
- main
+permissions: read-all
+
# Note the only differences between release and canary-release jobs are:
# - only canary passes --overwrite flag
# - the VERSION make variable passed to 'make dist checksum' is expected to
@@ -15,30 +17,40 @@ on:
jobs:
release:
if: startsWith(github.ref, 'refs/tags/v')
- runs-on: ubuntu-latest
+ runs-on: ubuntu-latest-16-cores
steps:
- name: Checkout source code
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # pin@v3.6.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.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@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0
with:
- go-version: '1.20'
-
+ go-version: '${{ env.GOLANG_VERSION }}'
- name: Run unit tests
run: make test-coverage
-
- name: Build Helm Binaries
run: |
- make build-cross
+ set -eu -o pipefail
+
+ make build-cross VERSION="${{ github.ref_name }}"
make dist checksum VERSION="${{ github.ref_name }}"
-
+
- name: Set latest version
run: |
+ set -eu -o pipefail
+
+ mkdir -p _dist_versions
+
# Push the latest semver tag, excluding prerelease tags
- git tag | sort -r --version-sort | grep '^v[0-9]' | grep -v '-' | head -n1 > _dist/helm-latest-version
+ LATEST_VERSION="$(git tag | sort -r --version-sort | grep '^v[0-9]' | grep -v '-' | head -n1)"
+ echo "LATEST_VERSION=${LATEST_VERSION}"
+ echo "${LATEST_VERSION}" > _dist_versions/helm-latest-version
+ echo "${LATEST_VERSION}" > _dist_versions/helm3-latest-version
- name: Upload Binaries
uses: bacongobbler/azure-blob-storage-upload@50f7d898b7697e864130ea04c303ca38b5751c50 # pin@3.0.0
@@ -51,17 +63,32 @@ jobs:
connection_string: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}
extra_args: '--pattern helm-*'
+ - name: Upload Version tag files
+ uses: bacongobbler/azure-blob-storage-upload@50f7d898b7697e864130ea04c303ca38b5751c50 # pin@3.0.0
+ env:
+ AZURE_STORAGE_CONNECTION_STRING: "${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}"
+ AZURE_STORAGE_CONTAINER_NAME: "${{ secrets.AZURE_STORAGE_CONTAINER_NAME }}"
+ with:
+ overwrite: 'true'
+ source_dir: _dist_versions
+ container_name: ${{ secrets.AZURE_STORAGE_CONTAINER_NAME }}
+ connection_string: ${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}
+
canary-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-latest-16-cores
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout source code
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # pin@v3.6.0
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # pin@v5.0.0
+
+ - name: Add variables to environment file
+ run: cat ".github/env" >> "$GITHUB_ENV"
- name: Setup Go
- uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # pin@4.1.0
+ uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # pin@5.5.0
with:
- go-version: '1.20'
+ go-version: '${{ env.GOLANG_VERSION }}'
+ check-latest: true
- name: Run unit tests
run: make test-coverage
diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml
new file mode 100644
index 000000000..6a44c8afb
--- /dev/null
+++ b/.github/workflows/scorecards.yml
@@ -0,0 +1,69 @@
+name: Scorecard supply-chain security
+on:
+ # For Branch-Protection check. Only the default branch is supported. See
+ # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
+ branch_protection_rule:
+ # To guarantee Maintained check is occasionally updated. See
+ # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
+ schedule:
+ - cron: '25 7 * * 0'
+ push:
+ branches: [ "main" ]
+
+# Declare default permissions as read only.
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ # Needed to upload the results to code-scanning dashboard.
+ security-events: write
+ # Needed to publish results and get a badge (see publish_results below).
+ id-token: write
+ # Uncomment the permissions below if installing in a private repository.
+ # contents: read
+ # actions: read
+
+ steps:
+ - name: "Checkout code"
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: "Run analysis"
+ uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
+ # - you want to enable the Branch-Protection check on a *public* repository, or
+ # - you are installing Scorecard on a *private* repository
+ # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
+ # repo_token: ${{ secrets.SCORECARD_TOKEN }}
+
+ # Public repositories:
+ # - Publish results to OpenSSF REST API for easy access by consumers
+ # - Allows the repository to include the Scorecard badge.
+ # - See https://github.com/ossf/scorecard-action#publishing-results.
+ # For private repositories:
+ # - `publish_results` will always be set to `false`, regardless
+ # of the value entered here.
+ publish_results: true
+
+ # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
+ # format to the repository Actions tab.
+ - name: "Upload artifact"
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ # 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@v3
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/stale-issue-bot.yaml b/.github/workflows/stale.yaml
similarity index 60%
rename from .github/workflows/stale-issue-bot.yaml
rename to .github/workflows/stale.yaml
index 85160634d..3417e1734 100644
--- a/.github/workflows/stale-issue-bot.yaml
+++ b/.github/workflows/stale.yaml
@@ -2,15 +2,17 @@ name: "Close stale issues"
on:
schedule:
- cron: "0 0 * * *"
+
jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v3.0.14
+ - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.'
+ stale-pr-message: 'This pull request has been marked as stale because it has been open for 90 days with no activity. This pull request will be automatically closed in 30 days if no further activity occurs.'
exempt-issue-labels: 'keep open,v4.x,in progress'
days-before-stale: 90
days-before-close: 30
- operations-per-run: 100
+ operations-per-run: 200
diff --git a/.gitignore b/.gitignore
index d1af995a3..7ea0717ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,8 +5,12 @@
.idea/
.vimrc
.vscode/
+.devcontainer/
_dist/
+_dist_versions/
bin/
vendor/
# Ignores charts pulled for dependency build tests
cmd/helm/testdata/testcharts/issue-7233/charts/*
+pkg/cmd/testdata/testcharts/issue-7233/charts/*
+.pre-commit-config.yaml
diff --git a/.golangci.yml b/.golangci.yml
index 3cf50a0d4..a9b13c35f 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,25 +1,71 @@
-run:
- timeout: 10m
+formatters:
+ enable:
+ - gofmt
+ - goimports
+
+ exclusions:
+ generated: lax
+
+ settings:
+ gofmt:
+ simplify: true
+
+ goimports:
+ local-prefixes:
+ - helm.sh/helm/v4
linters:
- disable-all: true
+ default: none
+
enable:
+ - depguard
- dupl
- - gofmt
- - goimports
- - gosimple
+ - gomodguard
- govet
- ineffassign
- misspell
- nakedret
- revive
- - unused
- staticcheck
+ - thelper
+ - unused
+ - usestdlibvars
+ - usetesting
+
+ exclusions:
+ generated: lax
+
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+
+ rules: []
+
+ warn-unused: true
+
+ settings:
+ depguard:
+ rules:
+ Main:
+ deny:
+ - pkg: github.com/hashicorp/go-multierror
+ desc: "use errors instead"
+ - pkg: github.com/pkg/errors
+ desc: "use errors instead"
+
+ dupl:
+ threshold: 400
+
+ gomodguard:
+ blocked:
+ modules:
+ - github.com/evanphx/json-patch:
+ recommendations:
+ - github.com/evanphx/json-patch/v5
+
+run:
+ timeout: 10m
-linters-settings:
- gofmt:
- simplify: true
- goimports:
- local-prefixes: helm.sh/helm/v3
- dupl:
- threshold: 400
+version: "2"
diff --git a/ADOPTERS.md b/ADOPTERS.md
index 9d5365b72..a83519fea 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -5,12 +5,21 @@
# Organizations Using Helm
-- [Blood Orange](https://bloodorange.io)
-- [IBM](https://www.ibm.com)
-- [Microsoft](https://microsoft.com)
-- [Qovery](https://www.qovery.com/)
-- [Samsung SDS](https://www.samsungsds.com/)
-- [Softonic](https://hello.softonic.com/)
-- [Ville de Montreal](https://montreal.ca)
+- [IBM](https://www.ibm.com)
+- [InfoCert](https://www.infocert.it/)
+- [Intercept](https://Intercept.cloud)
+- [Microsoft](https://microsoft.com)
+- [New Relic](https://www.newrelic.com)
+- [Octopus Deploy](https://octopus.com/)
+- [Omnistrate](https://omnistrate.com)
+- [Oracle](www.oracle.com)
+- [Percona](https://www.percona.com)
+- [Qovery](https://www.qovery.com/)
+- [Samsung SDS](https://www.samsungsds.com/)
+- [Softonic](https://hello.softonic.com/)
+- [SyncTune](https://mb-consulting.dev)
+- [Syself](https://syself.com)
+- [Ville de Montreal](https://montreal.ca)
+
_This file is part of the CNCF official documentation for projects._
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 37627e716..8ab93403d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,9 +11,13 @@ vulnerability_, please email a report to
[cncf-helm-security@lists.cncf.io](mailto:cncf-helm-security@lists.cncf.io). This will give us a
chance to try to fix the issue before it is exploited in the wild.
+## Helm v3 and v4
+
+Helm v4 is currently under development on the `main` branch. During the development of Helm v4 and for some time after its released, Helm v3 will continue to be supported and developed on the `dev-v3` branch. Helm v3 will continue to get bug fixes and updates for new Kubernetes releases. Helm v4 is where new features and major changes will happen. For features to be backported to Helm v3, an exception will be needed. Bugs should first be fixed on Helm v4 and then backported to Helm v3.
+
## Sign Your Work
-The sign-off is a simple line at the end of the explanation for a commit. All commits needs to be
+The sign-off is a simple line at the end of the explanation for a commit. All commits need to be
signed. Your signature certifies that you wrote the patch or otherwise have the right to contribute
the material. The rules are pretty simple, if you can certify the below (from
[developercertificate.org](https://developercertificate.org/)):
@@ -66,6 +70,18 @@ Use your real name (sorry, no pseudonyms or anonymous contributions.)
If you set your `user.name` and `user.email` git configs, you can sign your commit automatically
with `git commit -s`.
+The following command will update your git config with `user.email`:
+
+``` bash
+git config --global user.email joe.smith@example.com
+```
+
+This command will update your git config with `user.name`:
+
+``` bash
+git config --global user.name "Joe Smith"
+```
+
Note: If your git config information is set properly then viewing the `git log` information for your
commit will look something like this:
@@ -115,8 +131,9 @@ Helm maintains a strong commitment to backward compatibility. All of our changes
formats are backward compatible from one major release to the next. No features, flags, or commands
are removed or substantially modified (unless we need to fix a security issue).
-We also try very hard to not change publicly accessible Go library definitions inside of the `pkg/`
-directory of our source code.
+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 a quick summary of our backward compatibility guidelines for releases between 3.0 and 4.0:
@@ -126,30 +143,9 @@ For a quick summary of our backward compatibility guidelines for releases betwee
(barring the cases where (a) Kubernetes itself changed, and (b) the chart worked because it
exploited a bug)
- Chart repository functionality MUST be backward compatible
-- Go libraries inside of `pkg/` SHOULD remain backward compatible, though code inside of `cmd/` and
+- Go libraries inside of `pkg/` MUST remain backward compatible, though code inside of `cmd/` and
`internal/` may be changed from release to release without notice.
-## Support Contract for Helm 2
-
-With Helm 2's current release schedule, we want to take into account any migration issues for users
-due to the upcoming holiday shopping season and tax season. We also want to clarify what actions may
-occur after the support contract ends for Helm 2, so that users will not be surprised or caught off
-guard.
-
-After Helm 2.15.0 is released, Helm 2 will go into "maintenance mode". We will continue to accept
-bug fixes and fix any security issues that arise, but no new features will be accepted for Helm 2.
-All feature development will be moved over to Helm 3.
-
-6 months after Helm 3.0.0's public release, Helm 2 will stop accepting bug fixes. Only security
-issues will be accepted.
-
-12 months after Helm 3.0.0's public release, support for Helm 2 will formally end. Download links
-for the Helm 2 client through Google Cloud Storage, the Docker image for Tiller stored in Google
-Container Registry, and the Google Cloud buckets for the stable and incubator chart repositories may
-no longer work at any point. Client downloads through `get.helm.sh` will continue to work, and we
-will distribute a Tiller image that will be made available at an alternative location which can be
-updated with `helm init --tiller-image`.
-
## Issues
Issues are used as the primary method for tracking anything to do with the Helm project.
@@ -195,7 +191,7 @@ below.
See [Proposing an Idea](#proposing-an-idea). Smaller quality-of-life enhancements are exempt.
- Issues that are labeled as `feature` or `bug` should be connected to the PR that resolves it.
- Whoever is working on a `feature` or `bug` issue (whether a maintainer or someone from the
- community), should either assign the issue to themself or make a comment in the issue saying
+ community), should either assign the issue to themselves or make a comment in the issue saying
that they are taking it.
- `proposal` and `support/question` issues should stay open until resolved or if they have not
been active for more than 30 days. This will help keep the issue queue to a manageable size
@@ -280,11 +276,25 @@ Like any good open source project, we use Pull Requests (PRs) to track code chan
or explicitly request another OWNER do that for them.
- If the owner of a PR is _not_ listed in `OWNERS`, any core maintainer may merge the PR.
-#### Documentation PRs
+### Documentation PRs
+
+Documentation PRs should be made on the docs repo: . Keeping Helm's documentation up to date is highly desirable, and is recommended for all user facing changes. Accurate and helpful documentation is critical for effectively communicating Helm's behavior to a wide audience.
+
+Small, ad-hoc changes/PRs to Helm which introduce user facing changes, which would benefit from documentation changes, should apply the `docs needed` label. Larger changes associated with a HIP should track docs via that HIP. The `docs needed` label doesn't block PRs, and maintainers/PR reviewers should apply discretion judging in whether the `docs needed` label should be applied.
+
+### Profiling PRs
-Documentation PRs will follow the same lifecycle as other PRs. They will also be labeled with the
-`docs` label. For documentation, special attention will be paid to spelling, grammar, and clarity
-(whereas those things don't matter *as* much for comments in code).
+If your contribution requires profiling to check memory and/or CPU usage, you can set `HELM_PPROF_CPU_PROFILE=/path/to/cpu.prof` and/or `HELM_PPROF_MEM_PROFILE=/path/to/mem.prof` environment variables to collect runtime profiling data for analysis. You can use Golang's [pprof](https://github.com/google/pprof/blob/main/doc/README.md) tool to inspect the results.
+
+Example analysing collected profiling data
+```
+HELM_PPROF_CPU_PROFILE=cpu.prof HELM_PPROF_MEM_PROFILE=mem.prof helm show all bitnami/nginx
+
+# Visualize graphs. You need to have installed graphviz package in your system
+go tool pprof -http=":8000" cpu.prof
+
+go tool pprof -http=":8001" mem.prof
+```
## The Triager
@@ -327,6 +337,7 @@ The following tables define all label types used for Helm. It is split up by cat
| `needs rebase` | Indicates a PR needs to be rebased before it can be merged |
| `needs pick` | Indicates a PR needs to be cherry-picked into a feature branch (generally bugfix branches). Once it has been, the `picked` label should be applied and this one removed |
| `picked` | This PR has been cherry-picked into a feature branch |
+| `docs needed` | Tracks PRs that introduces a feature/change for which documentation update would be desirable (non-blocking). Once a suitable documentation PR has been created, then this label should be removed |
#### Size labels
diff --git a/KEYS b/KEYS
index 89ef930fd..e772fff40 100644
--- a/KEYS
+++ b/KEYS
@@ -940,3 +940,121 @@ AirPev6SluPhLJ2mswaK3THlhOZulKO/VIEJ6g50m5Vj3hdYf6sR603yK9rP+3iu
IagTQt2SGfW3Ap0RO3Yt+w29BpZ1CZ5Ml4gAYkXz0hiiMnVRhlcLIOHoFw==
=h3+3
-----END PGP PUBLIC KEY BLOCK-----
+pub rsa4096 2018-12-08 [SC]
+ 208DD36ED5BB3745A16743A4C7C6FBB5B91C1155
+uid [ultimate] Scott Rigby
+sig 3 C7C6FBB5B91C1155 2018-12-08 [self-signature]
+sig 134FC1555856DA4F 2018-12-13 [User ID not found]
+sig 62F49E747D911B60 2018-12-17 [User ID not found]
+sig F54982D216088EE1 2019-01-05 [User ID not found]
+sub rsa4096 2018-12-08 [E]
+sig C7C6FBB5B91C1155 2018-12-08 [self-signature]
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBFwMUAcBEADplQ+msULZ4kt01bXDvZ66MSVe5Fi1cPqAa/5/ZtaHZWSKrcN6
+K0cadpozJp74HSZzORLYV/50EGwXU+OG1dFe73FbsTCgQyLCbh/OjT+Exq553g2D
+/IB4/6/vCs9XXiYdKot3P2NsHI6RqeGqgW2IkFVsMXO2Lq1XKFTWQniO7PhHW8nG
+Trub7HrxR6i9KHtVtxLs+XoXY7Jj0gB7WyRkYjHLXti4VtvcBq0WK3pSgEIy5MwR
+WDepmle8n8EJZrh3T323YM41MXKGT00wCSKMbSHJO7QssiOda9XluC175HDfihm3
+q5OKV2ZYIbChsQxuJz1Y97hwZ5KkLn//W2pxTdOElOcynFpQNx7D4b6UTP2DCCRc
+n41SiDIyHg25cUXXAkJWlYRD1koGfLBipJA0DcKqlh3W+8zNfngZ0PSxwFtJwSre
+Zx5I5uHAgKO5nS4hLxGYUMv+MsSKHMYR6qkqFg1Eal6tTa68bPFTbzypDmMUKXZT
+sZtZ79WoIUU9D3O+F+Z9rxwaQ3Dv7J49FdbLPB3zqENqX7OWHZ38m5dsweTFhQi+
+4AaDLEMiqMi27SfPkF1/+JDc1SOoLVo9QgukqhFlz6qEIbud7LUfpeKBRNJsfdr6
+HE3cH8MWHInnlJ49De1oLl5bwAwScQig5jmv5DZxN5qdTg64vgoreBLgsQARAQAB
+tBxTY290dCBSaWdieSA8c2NvdHRAcjZieS5jb20+iQJOBBMBCAA4FiEEII3TbtW7
+N0WhZ0Okx8b7tbkcEVUFAlwMUAcCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AA
+CgkQx8b7tbkcEVUIPg//cK8zaAOUIFClQ31TeQnwHsJwcBMeRRegUjt4cIYSjv3F
+oNe5EQKxgpwT/1iegvRURjxHbhSE0paQ9wsi8Tr5Ygs0DJLDp4PJVZtVHJ5qB3XT
+VuCiqyb90kIG+ok1m8FHBwpeU7o2a05InYwJLWowBQTulhrS4VKzrPs2kMu4z5Am
+5sbs70f0hRLUXGmEAFUnZm+lpF48/PCMNSPrxgZ6rtNqtXq8oCyPNvyO8Ou130tv
+TSoHx5Tobz3RnSeDXpidC/Z1rQGq4Spi+a0WwC9BCArDvtOrQBoeQFgdpy5OsE/t
+QfQNEffyLRLlAZgXewOC+PeF+xrz+ku/rMBlt/h0h5UcMS3joUHTRZyQoA5dzWZh
+K8pMXYppHXJtxl0fNSG1rH1vsTu+Mjxmd9eBwaDxnBFwzBOukiwSUgW1r2DyZ7S+
+25ZW62lcz/E9+sgWV/PGR+YKGbsDdnraPdQEc95k8NQnYsX/7YO/WXJfP7Cdqd8X
+dAAVW6/dGUp2i4QogEYk+GeJz3rJJK24Wzgf2FJ32FuSH8uOQUH93h5QZbJAD+pD
+R2qZAZjCHYAgvSuqnmDlefvG95TwIVzy3OFWbLwp6YyBdyZrdpTafER4zk17f0HF
+xS3z3LG0LsEWHShxccjzjoAUeppAU0Qojag3kKLoCwveambIdjXIxliBr/S2VSWJ
+AjMEEAEKAB0WIQTlYbNM52c/3RO87D71SYLSFgiO4QUCXC/89gAKCRD1SYLSFgiO
+4YfMEACS7/90VvyhbcQYB/2N6dYYuVd4tMRpM7jgWO6LqDDh3C9S2NI2bzwxGFBR
+nqV9N6fD+yLug/dtAPq5D4i7AXzRqPA8XQ2ky/1EJOv5EmOl6NYnUZafEBMDMai6
+F6XOji2JR7b6xlRC7GwzUdMR1rn8eyCxuJobgB+wMzfcSAnaDsH1qU7a5ohEewtQ
+IgEcLLmiLapCqNXm0l5oIYQQypbRrogw4ePw8KcLDreRtPCpPdLIThqdfkXLQ69Z
+ZwGd1+Pg6xgu7MJggnNq2RYortW/Jx9WUFs0Jj9c+Yz5pPeQmc1jjF4uHvxfi0gt
+wFqL+bu+HfxI/Hiudkzzp3v+Llmjk6RypJowLLk5cxMxv7XMfK3cLLDO5uw0pPiq
+l1f4u4P3YeRLv5YFh8SRrk/PadEqFr10mIOmreASq9LivJkhGK9eqQ0X7zQfHmDq
+S3nw62ousqlmEld39MIAMn02Ak9i9CD2M3G2O5gCAcvnFlWblx5CN9Pjc6kOb52W
+eDMYisUmKnIkChzvAlfh8PhvQfLUpKN1AmzUOcXJokpu1Yx7OGaoDnfpXSHS8fe8
+pu0jMsEhlqsNxNS6y5N0tWjxjYg7D1Qpeq3O4oft4HiO3ZfMPI4V1tatfeohnjic
+UJkpVsS4RZybu8aqNGc8i/ggagiWc50oydK8Lp906XfOcEert4kCMwQQAQoAHRYh
+BKuiUpWY9mJsQg0zW2L0nnR9kRtgBQJcF85IAAoJEGL0nnR9kRtg0OMQAKqpxGtA
+uaMknrZxnxu+y4FXXrX/W2TLlF7Ns71upXhitwSWk0pVJi+OZUvIGj+8yCj2MEg6
+o5qBJPyo8TuwIh1YfxBYigY5Hmt/uVVbKBM/VXyKDxzGrSts+r73cxf3BpPfyANB
+a90LjKHvFv0czu5sfiRMHU9GCOehnBukdZ9PhOOcRuUlHoHlSf21x9kxa1tUFPVJ
+eeoVOOnONDK6Dzmi6GoGRTq3X/HZ2JjhcdYSn37z/KxmZ/SNiwat60gw5zJHTh0a
+dM54hwsdsp48/avpF8BlTgXsoH5dVdbaOTyNGXBbQaoL09FY2x2eXaFCP3RbMWK8
+TpWh0Ijs/3JLFJ20jrvZqsmxmwIX25TmBb4UoR3HSEHFasYoIxv/me6V4oB7D1SJ
+F3q5scPnRzV3I5wCRljKJcNHQbNb/c9Xnt5UVnLHRtkZW/NUw93H5Bq15dkq4U6i
+prC0EgjfiWy2Esd9TiGb5kRuN0/duUY/d7dewp1tJGDRZACwWwHyYkPiPA6gzQfN
+83yk29evZeX1rSBslnXSLzuwhVJc28KNZCeEcC2o1JninqfqoYnypgSFOS5BK6ZG
+5YJD0gkKFX+ImC8OSKsIJ6QyrzyOBb7UwRcK5qlvYZGYgrt+B+mFDpxWPkzgpfMe
+CvE0nUCgKDNg0Yvhr5w9JhK+49Qn6TuTADMIiQIzBBABCAAdFiEE2o9xo8issez5
+FC4eE0/BVVhW2k8FAlwSzMYACgkQE0/BVVhW2k8jEQ//cV4+ke8eHhwwxCPZd+lA
+mvgzalwSiZZ8H0EgAB2cK0LXEFe07XGKxe2tDkf5FDIQcNm7sIk0OcLhJzYX0p9P
+A7ZzO2tZu8QuZlUqt4VnDL7B3xeW2Sh3STEmw80wubkgauRRysflAHIw3edchnIX
+9Hq55MLBBAplQFkpA/Y7arg3Bn8v/8YQlULc30xRO8EoyxD+zyl+Ic+xFtFUxNc/
+2dkqkdjq0Ohq89wTGTy0jaSI8INhZTGqR0cEYQPKZD+PXUUym/TaPKJKXagqxmu2
+XXBv6QPp46a58viBxMj6+fl1JJH3DxNF9YY++7Xp8CckA3TKDA9hxOJK6wbrTzDB
+DB+tjcwR4ff8QLv/CV3psyk2fX9CGCBdr0k0SCMQSFcHM8pKagkySjG+EJ1Tcflf
+UDY2LD33n2BBIdCaQTu6u+Zeqq2e6R3UXm/raXuGrxUxzvOQBIhb7XaC6nhfDu8k
+07yN/Tjwp+rgHt9ouH4pfFbGpvaIomBJq6pkTOk9ywDtHlSatqoVkbrbKpNzmwf8
+z7pt+ICtKqAAWQTPFPD83h6elP26GKlsyXyhT7HNmKUHaXInEbaD+IoCJ+wY/O3i
+gHV2Dn4QllSBSBhYlhl6utmP1zqwJJ0rI39mPS+nMXOhGB+bJ8EzAF/3N5J6y3TG
+FnMEJD8qgdpDEgztjHUSAy25Ag0EXAxQBwEQALqYikkk0Ur4gn9PjxtjW4OxS5J4
+e/u0UyOsv8znFM7CG9Ndha9rQs/7c7NEf3e+K6a7XqhzDKtyGAFVFlZArxbe6X8e
+UV1OidOaH46z2vmtWOJYHIupXHlXS9LeXNO+pJjCNEAzmHbGjpkjGtNz6Opl4Uae
+LoMFubRViXhvD8pBF72dGUlp8m+U4yeXJ03/q0sR94AdTA+1OzGd2+1s7PvL5XAx
+BwXqx9gccMYhrNRPyBo/yRA+Wf4ewwluIVBMi9cpR1sNF4ITIYCH4i3mf4NJvg7P
+0sPBY0s9k2jvHGLpINbFk6PkMtaRpqmgw695szTz16Gp0j41hRnEh7KnGneEp+SU
+6A8A9UGnjM9upR/d1591I5gT2U+6Q05B8RtJQUmd3HBeHEBgftjgBR0tstH8qeac
+Xc4V81OGlf3tdYP/LVggIlv8V5cdSZ4Bd3BXYWj2TIc2RmwWA2LWf4SA6JYvhEfp
+OxOzzphlgPtZF0kneEgV/b/D/KQqEk7MyZl3gN+LNk+zX7VJ2RDeUiUnoxZDFJGi
+jsbZd7yoDLkYvGiFkcQXORs2zbucweVXXK1Gyskj0c3Ih4syYYmKS0WJHMEozJUl
+b81oa7kSc2XFArcAnPz2c1yErfzcCAlg/HImkZmAgVqAfuRyxZ426F7CYucHAOcE
+N7bpIrOkqFp3uUb3ABEBAAGJAjYEGAEIACAWIQQgjdNu1bs3RaFnQ6THxvu1uRwR
+VQUCXAxQBwIbDAAKCRDHxvu1uRwRVbFaD/0e57rP3H+1rUoGhRO0oeIveQqIdd9V
+LKXUYuwzoK3HLg3BYUDEN03RS0KyNMYlHpnjyFl5L2JuXqGiJd/eu2iRXCwUMRb7
+SPvH7gypa1NUK5te85+Y8JhXOMjwZkly3zS2nRTyvHxMn9EV7NPlT/oEVu/woPrM
+o7XzmPChuvnk8pLWBW04wg5G5atDbu5+QVZlecNCrtRYJg/Cd8alKpJSeZX7y3cy
+fe2P20Gv0UOipKWaAFL55zFLbmu7HWVumYAKs6T+X/pZqmcfMaVwodIBeRJxRIvl
+PkrBxljahaFGOdgJ6FVnmO34uoYcpd019NEr9gbPoaFWmw37h3Tnc6U5sLAouaV4
+AERWmwBPIVTizYt1h8Qj4qyBhJ+QgZMjPlRqHWPZogHfMXDQV4gw3jgvVWTMVp1Z
+gDQgrFNbw02CqPwgtFn15VNwAv/4vbyToRhc3pG54e3xwdAFM8R2uM9lHJKuHafW
+7aFUk7aA20k8SG2BsZalb6tZLGxgcZOwMdO3lnLMPu1I5oOLl4cVoUIRZxtgmrbQ
+ROaGdXGIgO7fJBXXogMxjUGhMola+v6ioFQpbOnJRAr2AUVBCrrEgHoodAufGTDu
+nk38BkgHg3LHjCbCNEVkSK2TMT69A58iwpY9WUQlphsiz4WBpafSPbv/jSlsm7uK
+TNWtbFGBRpJyEg==
+=w141
+-----END PGP PUBLIC KEY BLOCK-----
+pub ed25519 2024-07-09 [SC]
+ 7FEC81FACC7FFB2A010ADD13C2D40F4D8196E874
+uid [ultimate] Robert Sirchia (I like turtles.)
+sig 3 C2D40F4D8196E874 2024-07-09 [self-signature]
+sub cv25519 2024-07-09 [E]
+sig C2D40F4D8196E874 2024-07-09 [self-signature]
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mDMEZo2C6xYJKwYBBAHaRw8BAQdA8kCWaI+FlCabcTw8EVeiMkokyWDalgl/Inbn
+ACcGN1e0N1JvYmVydCBTaXJjaGlhIChJIGxpa2UgdHVydGxlcy4pIDxyc2lyY2hp
+YUBvdXRsb29rLmNvbT6IkwQTFgoAOxYhBH/sgfrMf/sqAQrdE8LUD02Bluh0BQJm
+jYLrAhsDBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEMLUD02Bluh0dyYA
+/i7RB6m3MXNA8ei7GD8uQVpLfCRgEFsqSS/AzAOu8NGhAQCbw1kWL3AUll7KKtiQ
+UE96nhCk+HnkQeVkWYS+MZ1tALg4BGaNgusSCisGAQQBl1UBBQEBB0CCA6Au4krL
+YinQq9aAs29fFeRu/ye3PqQuz5jZ2r1ScAMBCAeIeAQYFgoAIBYhBH/sgfrMf/sq
+AQrdE8LUD02Bluh0BQJmjYLrAhsMAAoJEMLUD02Bluh0KH4BAMSwEIGkoQl10LN3
+K6V08VpFmniENmCDHshXYq0gGiTDAP9FsXl2UtmFU5xuYxH4fRKIxgmxJRAFMWI8
+u3Rdu/s+DQ==
+=smBO
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/Makefile b/Makefile
index d61ac1507..0a20259bd 100644
--- a/Makefile
+++ b/Makefile
@@ -1,8 +1,8 @@
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/ppc64le linux/s390x windows/amd64
-TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum
+TARGETS := darwin/amd64 darwin/arm64 linux/amd64 linux/386 linux/arm linux/arm64 linux/ppc64le linux/s390x linux/riscv64 windows/amd64 windows/arm64
+TARGET_OBJS ?= darwin-amd64.tar.gz darwin-amd64.tar.gz.sha256 darwin-amd64.tar.gz.sha256sum darwin-arm64.tar.gz darwin-arm64.tar.gz.sha256 darwin-arm64.tar.gz.sha256sum linux-amd64.tar.gz linux-amd64.tar.gz.sha256 linux-amd64.tar.gz.sha256sum linux-386.tar.gz linux-386.tar.gz.sha256 linux-386.tar.gz.sha256sum linux-arm.tar.gz linux-arm.tar.gz.sha256 linux-arm.tar.gz.sha256sum linux-arm64.tar.gz linux-arm64.tar.gz.sha256 linux-arm64.tar.gz.sha256sum linux-ppc64le.tar.gz linux-ppc64le.tar.gz.sha256 linux-ppc64le.tar.gz.sha256sum linux-s390x.tar.gz linux-s390x.tar.gz.sha256 linux-s390x.tar.gz.sha256sum linux-riscv64.tar.gz linux-riscv64.tar.gz.sha256 linux-riscv64.tar.gz.sha256sum windows-amd64.zip windows-amd64.zip.sha256 windows-amd64.zip.sha256sum windows-arm64.zip windows-arm64.zip.sha256 windows-arm64.zip.sha256sum
BINNAME ?= helm
GOBIN = $(shell go env GOBIN)
@@ -11,7 +11,7 @@ GOBIN = $(shell go env GOPATH)/bin
endif
GOX = $(GOBIN)/gox
GOIMPORTS = $(GOBIN)/goimports
-ARCH = $(shell uname -p)
+ARCH = $(shell go env GOARCH)
ACCEPTANCE_DIR:=../acceptance-testing
# To specify the subset of acceptance tests to run. '.' means all tests
@@ -21,7 +21,7 @@ ACCEPTANCE_RUN_TESTS=.
PKG := ./...
TAGS :=
TESTS := .
-TESTFLAGS :=
+TESTFLAGS := -shuffle=on -count=1
LDFLAGS := -w -s
GOFLAGS :=
CGO_ENABLED ?= 0
@@ -44,7 +44,7 @@ BINARY_VERSION ?= ${GIT_TAG}
# Only set Version if building a tag or VERSION is set
ifneq ($(BINARY_VERSION),)
- LDFLAGS += -X helm.sh/helm/v3/internal/version.version=${BINARY_VERSION}
+ LDFLAGS += -X helm.sh/helm/v4/internal/version.version=${BINARY_VERSION}
endif
VERSION_METADATA = unreleased
@@ -53,9 +53,9 @@ ifneq ($(GIT_TAG),)
VERSION_METADATA =
endif
-LDFLAGS += -X helm.sh/helm/v3/internal/version.metadata=${VERSION_METADATA}
-LDFLAGS += -X helm.sh/helm/v3/internal/version.gitCommit=${GIT_COMMIT}
-LDFLAGS += -X helm.sh/helm/v3/internal/version.gitTreeState=${GIT_DIRTY}
+LDFLAGS += -X helm.sh/helm/v4/internal/version.metadata=${VERSION_METADATA}
+LDFLAGS += -X helm.sh/helm/v4/internal/version.gitCommit=${GIT_COMMIT}
+LDFLAGS += -X helm.sh/helm/v4/internal/version.gitTreeState=${GIT_DIRTY}
LDFLAGS += $(EXT_LDFLAGS)
# Define constants based on the client-go version
@@ -63,10 +63,10 @@ K8S_MODULES_VER=$(subst ., ,$(subst v,,$(shell go list -f '{{.Version}}' -m k8s.
K8S_MODULES_MAJOR_VER=$(shell echo $$(($(firstword $(K8S_MODULES_VER)) + 1)))
K8S_MODULES_MINOR_VER=$(word 2,$(K8S_MODULES_VER))
-LDFLAGS += -X helm.sh/helm/v3/pkg/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER)
-LDFLAGS += -X helm.sh/helm/v3/pkg/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
-LDFLAGS += -X helm.sh/helm/v3/pkg/chartutil.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER)
-LDFLAGS += -X helm.sh/helm/v3/pkg/chartutil.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
+LDFLAGS += -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER)
+LDFLAGS += -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
+LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER)
+LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER)
.PHONY: all
all: build
@@ -78,7 +78,7 @@ all: build
build: $(BINDIR)/$(BINNAME)
$(BINDIR)/$(BINNAME): $(SRC)
- GO111MODULE=on CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/helm
+ CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/helm
# ------------------------------------------------------------------------------
# install
@@ -104,7 +104,16 @@ test: test-unit
test-unit:
@echo
@echo "==> Running unit tests <=="
- GO111MODULE=on go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS)
+ go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS)
+ @echo
+ @echo "==> Running unit test(s) with ldflags <=="
+# Test to check the deprecation warnings on Kubernetes templates created by `helm create` against the current Kubernetes
+# version. Note: The version details are set in var LDFLAGS. To avoid the ldflags impact on other unit tests that are
+# based on older versions, this is run separately. When run without the ldflags in the unit test (above) or coverage
+# test, it still passes with a false-positive result as the resources shouldn’t be deprecated in the older Kubernetes
+# version if it only starts failing with the latest.
+ go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./pkg/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)'
+
.PHONY: test-coverage
test-coverage:
@@ -114,7 +123,11 @@ test-coverage:
.PHONY: test-style
test-style:
- GO111MODULE=on golangci-lint run
+ golangci-lint run ./...
+ @scripts/validate-license.sh
+
+.PHONY: test-source-headers
+test-source-headers:
@scripts/validate-license.sh
.PHONY: test-acceptance
@@ -138,12 +151,12 @@ coverage:
.PHONY: format
format: $(GOIMPORTS)
- GO111MODULE=on go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm
+ go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm
# Generate golden files used in unit tests
.PHONY: gen-test-golden
gen-test-golden:
-gen-test-golden: PKG = ./cmd/helm ./pkg/action
+gen-test-golden: PKG = ./pkg/cmd ./pkg/action
gen-test-golden: TESTFLAGS = -update
gen-test-golden: test-unit
@@ -155,10 +168,10 @@ gen-test-golden: test-unit
# without a go.mod file when downloading the following dependencies
$(GOX):
- (cd /; GO111MODULE=on go install github.com/mitchellh/gox@latest)
+ (cd /; go install github.com/mitchellh/gox@v1.0.2-0.20220701044238-9f712387e2d2)
$(GOIMPORTS):
- (cd /; GO111MODULE=on go install golang.org/x/tools/cmd/goimports@latest)
+ (cd /; go install golang.org/x/tools/cmd/goimports@latest)
# ------------------------------------------------------------------------------
# release
@@ -166,7 +179,7 @@ $(GOIMPORTS):
.PHONY: build-cross
build-cross: LDFLAGS += -extldflags "-static"
build-cross: $(GOX)
- GOFLAGS="-trimpath" GO111MODULE=on CGO_ENABLED=0 $(GOX) -parallel=3 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' ./cmd/helm
+ GOFLAGS="-trimpath" CGO_ENABLED=0 $(GOX) -parallel=3 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)" -osarch='$(TARGETS)' $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' ./cmd/helm
.PHONY: dist
dist:
diff --git a/OWNERS b/OWNERS
index cc18ea522..761cf76a3 100644
--- a/OWNERS
+++ b/OWNERS
@@ -1,22 +1,25 @@
maintainers:
- - hickeyma
+ - gjenkins8
- joejulian
- - jdolitsky
- marckhouzam
- mattfarina
+ - robertsirc
- sabre1041
- scottrigby
- technosophos
triage:
+ - banjoh
+ - TerryHowe
- yxxhero
- zonggen
- - gjenkins8
- z4ce
emeritus:
- adamreese
- bacongobbler
- fibonacci1729
+ - hickeyma
- jascott1
+ - jdolitsky
- michelleN
- migmartri
- nebril
diff --git a/README.md b/README.md
index b279d6af8..66fdab041 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,11 @@
# Helm
[](https://github.com/helm/helm/actions?workflow=release)
-[](https://goreportcard.com/report/github.com/helm/helm)
-[](https://pkg.go.dev/helm.sh/helm/v3)
+[](https://goreportcard.com/report/helm.sh/helm/v4)
+[](https://pkg.go.dev/helm.sh/helm/v4)
[](https://bestpractices.coreinfrastructure.org/projects/3131)
+[](https://scorecard.dev/viewer/?uri=github.com/helm/helm)
+[](https://insights.linuxfoundation.org/project/helm)
Helm is a tool for managing Charts. Charts are packages of pre-configured Kubernetes resources.
@@ -28,6 +30,11 @@ Think of it like apt/yum/homebrew for Kubernetes.
- Charts can be stored on disk, or fetched from remote chart repositories
(like Debian or RedHat packages)
+## 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.
+
## Install
Binary downloads of the Helm client can be found on [the Releases page](https://github.com/helm/helm/releases/latest).
@@ -38,8 +45,10 @@ If you want to use a package manager:
- [Homebrew](https://brew.sh/) users can use `brew install helm`.
- [Chocolatey](https://chocolatey.org/) users can use `choco install kubernetes-helm`.
+- [Winget](https://learn.microsoft.com/en-us/windows/package-manager/) users can use `winget install Helm.Helm`.
- [Scoop](https://scoop.sh/) users can use `scoop install helm`.
-- [Snapcraft](https://snapcraft.io/) users can use `snap install helm --classic`
+- [Snapcraft](https://snapcraft.io/) users can use `snap install helm --classic`.
+- [Flox](https://flox.dev) users can use `flox install kubernetes-helm`.
To rapidly get Helm up and running, start with the [Quick Start Guide](https://helm.sh/docs/intro/quickstart/).
@@ -48,12 +57,14 @@ including installing pre-releases.
## Docs
-Get started with the [Quick Start guide](https://helm.sh/docs/intro/quickstart/) or plunge into the [complete documentation](https://helm.sh/docs)
+Get started with the [Quick Start guide](https://helm.sh/docs/intro/quickstart/) or plunge into the [complete documentation](https://helm.sh/docs).
## Roadmap
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.
+
## Community, discussion, contribution, and support
You can reach the Helm community and developers via the following channels:
diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go
index 553da5098..05e7e7ba2 100644
--- a/cmd/helm/helm.go
+++ b/cmd/helm/helm.go
@@ -14,47 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main // import "helm.sh/helm/v3/cmd/helm"
+package main // import "helm.sh/helm/v4/cmd/helm"
import (
- "fmt"
- "io"
- "log"
+ "log/slog"
"os"
- "strings"
-
- "github.com/spf13/cobra"
- "sigs.k8s.io/yaml"
// Import to initialize client auth plugins.
_ "k8s.io/client-go/plugin/pkg/client/auth"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/kube"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage/driver"
+ helmcmd "helm.sh/helm/v4/pkg/cmd"
+ "helm.sh/helm/v4/pkg/kube"
)
-var settings = cli.New()
-
-func init() {
- log.SetFlags(log.Lshortfile)
-}
-
-func debug(format string, v ...interface{}) {
- if settings.Debug {
- format = fmt.Sprintf("[debug] %s\n", format)
- log.Output(2, fmt.Sprintf(format, v...))
- }
-}
-
-func warning(format string, v ...interface{}) {
- format = fmt.Sprintf("WARNING: %s\n", format)
- fmt.Fprintf(os.Stderr, format, v...)
-}
-
func main() {
// Setting the name of the app for managedFields in the Kubernetes client.
// It is set here to the full name of "helm" so that renaming of helm to
@@ -62,69 +34,18 @@ func main() {
// manager as picked up by the automated name detection.
kube.ManagedFieldsManager = "helm"
- actionConfig := new(action.Configuration)
- cmd, err := newRootCmd(actionConfig, os.Stdout, os.Args[1:])
+ cmd, err := helmcmd.NewRootCmd(os.Stdout, os.Args[1:], helmcmd.SetupLogging)
if err != nil {
- warning("%+v", err)
+ slog.Warn("command failed", slog.Any("error", err))
os.Exit(1)
}
- // run when each command's execute method is called
- cobra.OnInitialize(func() {
- helmDriver := os.Getenv("HELM_DRIVER")
- if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver, debug); err != nil {
- log.Fatal(err)
- }
- if helmDriver == "memory" {
- loadReleasesInMemory(actionConfig)
- }
- })
-
if err := cmd.Execute(); err != nil {
- debug("%+v", err)
switch e := err.(type) {
- case pluginError:
- os.Exit(e.code)
+ case helmcmd.PluginError:
+ os.Exit(e.Code)
default:
os.Exit(1)
}
}
}
-
-// This function loads releases into the memory storage if the
-// environment variable is properly set.
-func loadReleasesInMemory(actionConfig *action.Configuration) {
- filePaths := strings.Split(os.Getenv("HELM_MEMORY_DRIVER_DATA"), ":")
- if len(filePaths) == 0 {
- return
- }
-
- store := actionConfig.Releases
- mem, ok := store.Driver.(*driver.Memory)
- if !ok {
- // For an unexpected reason we are not dealing with the memory storage driver.
- return
- }
-
- actionConfig.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard}
-
- for _, path := range filePaths {
- b, err := os.ReadFile(path)
- if err != nil {
- log.Fatal("Unable to read memory driver data", err)
- }
-
- releases := []*release.Release{}
- if err := yaml.Unmarshal(b, &releases); err != nil {
- log.Fatal("Unable to unmarshal memory driver data: ", err)
- }
-
- for _, rel := range releases {
- if err := store.Create(rel); err != nil {
- log.Fatal(err)
- }
- }
- }
- // Must reset namespace to the proper one
- mem.SetNamespace(settings.Namespace())
-}
diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go
index b20b1a24d..5431daad0 100644
--- a/cmd/helm/helm_test.go
+++ b/cmd/helm/helm_test.go
@@ -18,153 +18,12 @@ package main
import (
"bytes"
- "io"
"os"
"os/exec"
"runtime"
- "strings"
"testing"
-
- shellwords "github.com/mattn/go-shellwords"
- "github.com/spf13/cobra"
-
- "helm.sh/helm/v3/internal/test"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/cli"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage"
- "helm.sh/helm/v3/pkg/storage/driver"
- "helm.sh/helm/v3/pkg/time"
)
-func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() }
-
-func init() {
- action.Timestamper = testTimestamper
-}
-
-func runTestCmd(t *testing.T, tests []cmdTestCase) {
- t.Helper()
- for _, tt := range tests {
- for i := 0; i <= tt.repeat; i++ {
- t.Run(tt.name, func(t *testing.T) {
- defer resetEnv()()
-
- storage := storageFixture()
- for _, rel := range tt.rels {
- if err := storage.Create(rel); err != nil {
- t.Fatal(err)
- }
- }
- t.Logf("running cmd (attempt %d): %s", i+1, tt.cmd)
- _, out, err := executeActionCommandC(storage, tt.cmd)
- if tt.wantError && err == nil {
- t.Errorf("expected error, got success with the following output:\n%s", out)
- }
- if !tt.wantError && err != nil {
- t.Errorf("expected no error, got: '%v'", err)
- }
- if tt.golden != "" {
- test.AssertGoldenString(t, out, tt.golden)
- }
- })
- }
- }
-}
-
-func storageFixture() *storage.Storage {
- return storage.Init(driver.NewMemory())
-}
-
-func executeActionCommandC(store *storage.Storage, cmd string) (*cobra.Command, string, error) {
- return executeActionCommandStdinC(store, nil, cmd)
-}
-
-func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) (*cobra.Command, string, error) {
- args, err := shellwords.Parse(cmd)
- if err != nil {
- return nil, "", err
- }
-
- buf := new(bytes.Buffer)
-
- actionConfig := &action.Configuration{
- Releases: store,
- KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard},
- Capabilities: chartutil.DefaultCapabilities,
- Log: func(format string, v ...interface{}) {},
- }
-
- root, err := newRootCmd(actionConfig, buf, args)
- if err != nil {
- return nil, "", err
- }
-
- root.SetOut(buf)
- root.SetErr(buf)
- root.SetArgs(args)
-
- oldStdin := os.Stdin
- if in != nil {
- root.SetIn(in)
- os.Stdin = in
- }
-
- if mem, ok := store.Driver.(*driver.Memory); ok {
- mem.SetNamespace(settings.Namespace())
- }
- c, err := root.ExecuteC()
-
- result := buf.String()
-
- os.Stdin = oldStdin
-
- return c, result, err
-}
-
-// cmdTestCase describes a test case that works with releases.
-type cmdTestCase struct {
- name string
- cmd string
- golden string
- wantError bool
- // Rels are the available releases at the start of the test.
- rels []*release.Release
- // Number of repeats (in case a feature was previously flaky and the test checks
- // it's now stably producing identical results). 0 means test is run exactly once.
- repeat int
-}
-
-func executeActionCommand(cmd string) (*cobra.Command, string, error) {
- return executeActionCommandC(storageFixture(), cmd)
-}
-
-func resetEnv() func() {
- origEnv := os.Environ()
- return func() {
- os.Clearenv()
- for _, pair := range origEnv {
- kv := strings.SplitN(pair, "=", 2)
- os.Setenv(kv[0], kv[1])
- }
- settings = cli.New()
- }
-}
-
-func testChdir(t *testing.T, dir string) func() {
- t.Helper()
- old, err := os.Getwd()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.Chdir(dir); err != nil {
- t.Fatal(err)
- }
- return func() { os.Chdir(old) }
-}
-
func TestPluginExitCode(t *testing.T) {
if os.Getenv("RUN_MAIN_FOR_TESTING") == "1" {
os.Args = []string{"helm", "exitwith", "2"}
@@ -190,10 +49,8 @@ func TestPluginExitCode(t *testing.T) {
"RUN_MAIN_FOR_TESTING=1",
// See pkg/cli/environment.go for which envvars can be used for configuring these passes
// and also see plugin_test.go for how a plugin env can be set up.
- // We just does the same setup as plugin_test.go via envvars
- "HELM_PLUGINS=testdata/helmhome/helm/plugins",
- "HELM_REPOSITORY_CONFIG=testdata/helmhome/helm/repositories.yaml",
- "HELM_REPOSITORY_CACHE=testdata/helmhome/helm/repository",
+ // This mimics the "exitwith" test case in TestLoadPlugins using envvars
+ "HELM_PLUGINS=../../pkg/cmd/testdata/helmhome/helm/plugins",
)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
diff --git a/cmd/helm/plugin_install.go b/cmd/helm/plugin_install.go
deleted file mode 100644
index 4e8ee327b..000000000
--- a/cmd/helm/plugin_install.go
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package main
-
-import (
- "fmt"
- "io"
-
- "github.com/pkg/errors"
- "github.com/spf13/cobra"
-
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/plugin"
- "helm.sh/helm/v3/pkg/plugin/installer"
-)
-
-type pluginInstallOptions struct {
- source string
- version string
-}
-
-const pluginInstallDesc = `
-This command allows you to install a plugin from a url to a VCS repo or a local path.
-`
-
-func newPluginInstallCmd(out io.Writer) *cobra.Command {
- o := &pluginInstallOptions{}
- cmd := &cobra.Command{
- Use: "install [options] ...",
- Short: "install one or more Helm plugins",
- Long: pluginInstallDesc,
- Aliases: []string{"add"},
- Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- if len(args) == 0 {
- // We do file completion, in case the plugin is local
- return nil, cobra.ShellCompDirectiveDefault
- }
- // No more completion once the plugin path has been specified
- return nil, cobra.ShellCompDirectiveNoFileComp
- },
- PreRunE: func(cmd *cobra.Command, args []string) error {
- return o.complete(args)
- },
- RunE: func(cmd *cobra.Command, args []string) error {
- return o.run(out)
- },
- }
- cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed")
- return cmd
-}
-
-func (o *pluginInstallOptions) complete(args []string) error {
- o.source = args[0]
- return nil
-}
-
-func (o *pluginInstallOptions) run(out io.Writer) error {
- installer.Debug = settings.Debug
-
- i, err := installer.NewForSource(o.source, o.version)
- if err != nil {
- return err
- }
- if err := installer.Install(i); err != nil {
- return err
- }
-
- debug("loading plugin from %s", i.Path())
- p, err := plugin.LoadDir(i.Path())
- if err != nil {
- return errors.Wrap(err, "plugin is installed but unusable")
- }
-
- if err := runHook(p, plugin.Install); err != nil {
- return err
- }
-
- fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata.Name)
- return nil
-}
diff --git a/cmd/helm/plugin_list.go b/cmd/helm/plugin_list.go
deleted file mode 100644
index ddf01f6f2..000000000
--- a/cmd/helm/plugin_list.go
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package main
-
-import (
- "fmt"
- "io"
-
- "github.com/gosuri/uitable"
- "github.com/spf13/cobra"
-
- "helm.sh/helm/v3/pkg/plugin"
-)
-
-func newPluginListCmd(out io.Writer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "list",
- Aliases: []string{"ls"},
- Short: "list installed Helm plugins",
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
- debug("pluginDirs: %s", settings.PluginsDirectory)
- plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
- if err != nil {
- return err
- }
-
- table := uitable.New()
- table.AddRow("NAME", "VERSION", "DESCRIPTION")
- for _, p := range plugins {
- table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description)
- }
- fmt.Fprintln(out, table)
- return nil
- },
- }
- return cmd
-}
-
-// Returns all plugins from plugins, except those with names matching ignoredPluginNames
-func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plugin.Plugin {
- // if ignoredPluginNames is nil, just return plugins
- if ignoredPluginNames == nil {
- return plugins
- }
-
- var filteredPlugins []*plugin.Plugin
- for _, plugin := range plugins {
- found := false
- for _, ignoredName := range ignoredPluginNames {
- if plugin.Metadata.Name == ignoredName {
- found = true
- break
- }
- }
- if !found {
- filteredPlugins = append(filteredPlugins, plugin)
- }
- }
-
- return filteredPlugins
-}
-
-// Provide dynamic auto-completion for plugin names
-func compListPlugins(toComplete string, ignoredPluginNames []string) []string {
- var pNames []string
- plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
- if err == nil && len(plugins) > 0 {
- filteredPlugins := filterPlugins(plugins, ignoredPluginNames)
- for _, p := range filteredPlugins {
- pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata.Name, p.Metadata.Usage))
- }
- }
- return pNames
-}
diff --git a/cmd/helm/plugin_uninstall.go b/cmd/helm/plugin_uninstall.go
deleted file mode 100644
index ee4a47beb..000000000
--- a/cmd/helm/plugin_uninstall.go
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package main
-
-import (
- "fmt"
- "io"
- "os"
- "strings"
-
- "github.com/pkg/errors"
- "github.com/spf13/cobra"
-
- "helm.sh/helm/v3/pkg/plugin"
-)
-
-type pluginUninstallOptions struct {
- names []string
-}
-
-func newPluginUninstallCmd(out io.Writer) *cobra.Command {
- o := &pluginUninstallOptions{}
-
- cmd := &cobra.Command{
- Use: "uninstall ...",
- Aliases: []string{"rm", "remove"},
- Short: "uninstall one or more Helm plugins",
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp
- },
- PreRunE: func(cmd *cobra.Command, args []string) error {
- return o.complete(args)
- },
- RunE: func(cmd *cobra.Command, args []string) error {
- return o.run(out)
- },
- }
- return cmd
-}
-
-func (o *pluginUninstallOptions) complete(args []string) error {
- if len(args) == 0 {
- return errors.New("please provide plugin name to uninstall")
- }
- o.names = args
- return nil
-}
-
-func (o *pluginUninstallOptions) run(out io.Writer) error {
- debug("loading installed plugins from %s", settings.PluginsDirectory)
- plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
- if err != nil {
- return err
- }
- var errorPlugins []string
- for _, name := range o.names {
- if found := findPlugin(plugins, name); found != nil {
- if err := uninstallPlugin(found); err != nil {
- errorPlugins = append(errorPlugins, fmt.Sprintf("Failed to uninstall plugin %s, got error (%v)", name, err))
- } else {
- fmt.Fprintf(out, "Uninstalled plugin: %s\n", name)
- }
- } else {
- errorPlugins = append(errorPlugins, fmt.Sprintf("Plugin: %s not found", name))
- }
- }
- if len(errorPlugins) > 0 {
- return errors.Errorf(strings.Join(errorPlugins, "\n"))
- }
- return nil
-}
-
-func uninstallPlugin(p *plugin.Plugin) error {
- if err := os.RemoveAll(p.Dir); err != nil {
- return err
- }
- return runHook(p, plugin.Delete)
-}
-
-func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin {
- for _, p := range plugins {
- if p.Metadata.Name == name {
- return p
- }
- }
- return nil
-}
diff --git a/cmd/helm/root_unix.go b/cmd/helm/root_unix.go
deleted file mode 100644
index 92fa1b59d..000000000
--- a/cmd/helm/root_unix.go
+++ /dev/null
@@ -1,58 +0,0 @@
-//go:build !windows
-
-/*
-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 main
-
-import (
- "os"
- "os/user"
- "path/filepath"
-)
-
-func checkPerms() {
- // This function MUST NOT FAIL, as it is just a check for a common permissions problem.
- // If for some reason the function hits a stopping condition, it may panic. But only if
- // we can be sure that it is panicking because Helm cannot proceed.
-
- kc := settings.KubeConfig
- if kc == "" {
- kc = os.Getenv("KUBECONFIG")
- }
- if kc == "" {
- u, err := user.Current()
- if err != nil {
- // No idea where to find KubeConfig, so return silently. Many helm commands
- // can proceed happily without a KUBECONFIG, so this is not a fatal error.
- return
- }
- kc = filepath.Join(u.HomeDir, ".kube", "config")
- }
- fi, err := os.Stat(kc)
- if err != nil {
- // DO NOT error if no KubeConfig is found. Not all commands require one.
- return
- }
-
- perm := fi.Mode().Perm()
- if perm&0040 > 0 {
- warning("Kubernetes configuration file is group-readable. This is insecure. Location: %s", kc)
- }
- if perm&0004 > 0 {
- warning("Kubernetes configuration file is world-readable. This is insecure. Location: %s", kc)
- }
-}
diff --git a/cmd/helm/root_unix_test.go b/cmd/helm/root_unix_test.go
deleted file mode 100644
index f7466a93d..000000000
--- a/cmd/helm/root_unix_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-//go:build !windows
-
-/*
-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 main
-
-import (
- "bytes"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func checkPermsStderr() (string, error) {
- r, w, err := os.Pipe()
- if err != nil {
- return "", err
- }
-
- stderr := os.Stderr
- os.Stderr = w
- defer func() {
- os.Stderr = stderr
- }()
-
- checkPerms()
- w.Close()
-
- var text bytes.Buffer
- io.Copy(&text, r)
- return text.String(), nil
-}
-
-func TestCheckPerms(t *testing.T) {
- tdir := t.TempDir()
- tfile := filepath.Join(tdir, "testconfig")
- fh, err := os.OpenFile(tfile, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0440)
- if err != nil {
- t.Errorf("Failed to create temp file: %s", err)
- }
-
- tconfig := settings.KubeConfig
- settings.KubeConfig = tfile
- defer func() { settings.KubeConfig = tconfig }()
-
- text, err := checkPermsStderr()
- if err != nil {
- t.Fatalf("could not read from stderr: %s", err)
- }
- expectPrefix := "WARNING: Kubernetes configuration file is group-readable. This is insecure. Location:"
- if !strings.HasPrefix(text, expectPrefix) {
- t.Errorf("Expected to get a warning for group perms. Got %q", text)
- }
-
- if err := fh.Chmod(0404); err != nil {
- t.Errorf("Could not change mode on file: %s", err)
- }
- text, err = checkPermsStderr()
- if err != nil {
- t.Fatalf("could not read from stderr: %s", err)
- }
- expectPrefix = "WARNING: Kubernetes configuration file is world-readable. This is insecure. Location:"
- if !strings.HasPrefix(text, expectPrefix) {
- t.Errorf("Expected to get a warning for world perms. Got %q", text)
- }
-}
diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml
deleted file mode 100644
index 63f2f12db..000000000
--- a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: fullenv
-usage: "show env vars"
-description: "show all env vars"
-command: "$HELM_PLUGIN_DIR/fullenv.sh"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml
deleted file mode 100644
index 21e28a7c2..000000000
--- a/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: args
-usage: "echo args"
-description: "This echos args"
-command: "$HELM_PLUGIN_DIR/args.sh"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml
deleted file mode 100644
index 7b9362a08..000000000
--- a/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: echo
-usage: "echo stuff"
-description: "This echos stuff"
-command: "echo hello"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml
deleted file mode 100644
index 52cb7a848..000000000
--- a/cmd/helm/testdata/helmhome/helm/plugins/env/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: env
-usage: "env stuff"
-description: "show the env"
-command: "echo $HELM_PLUGIN_NAME"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml
deleted file mode 100644
index 5691d1712..000000000
--- a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: exitwith
-usage: "exitwith code"
-description: "This exits with the specified exit code"
-command: "$HELM_PLUGIN_DIR/exitwith.sh"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml
deleted file mode 100644
index 63f2f12db..000000000
--- a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: fullenv
-usage: "show env vars"
-description: "show all env vars"
-command: "$HELM_PLUGIN_DIR/fullenv.sh"
diff --git a/cmd/helm/testdata/output/empty_nofile_comp.txt b/cmd/helm/testdata/output/empty_nofile_comp.txt
deleted file mode 100644
index 8d9fad576..000000000
--- a/cmd/helm/testdata/output/empty_nofile_comp.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-:4
-Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/get-metadata.json b/cmd/helm/testdata/output/get-metadata.json
deleted file mode 100644
index 1d5152b24..000000000
--- a/cmd/helm/testdata/output/get-metadata.json
+++ /dev/null
@@ -1 +0,0 @@
-{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"}
diff --git a/cmd/helm/testdata/output/get-metadata.yaml b/cmd/helm/testdata/output/get-metadata.yaml
deleted file mode 100644
index b6d49b038..000000000
--- a/cmd/helm/testdata/output/get-metadata.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-appVersion: "1.0"
-chart: foo
-deployedAt: "1977-09-02T22:04:05Z"
-name: thomas-guide
-namespace: default
-revision: 1
-status: deployed
-version: 0.1.0-beta.1
diff --git a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt b/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt
deleted file mode 100644
index 7c898b89f..000000000
--- a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-==> Linting testdata/testcharts/chart-with-bad-subcharts
-[INFO] Chart.yaml: icon is recommended
-[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
-[ERROR] : unable to load chart
- error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
-
-Error: 1 chart(s) linted, 1 chart(s) failed
diff --git a/cmd/helm/testdata/output/rollback-wrong-args-comp.txt b/cmd/helm/testdata/output/rollback-wrong-args-comp.txt
deleted file mode 100644
index 8d9fad576..000000000
--- a/cmd/helm/testdata/output/rollback-wrong-args-comp.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-:4
-Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/status-wrong-args-comp.txt b/cmd/helm/testdata/output/status-wrong-args-comp.txt
deleted file mode 100644
index 8d9fad576..000000000
--- a/cmd/helm/testdata/output/status-wrong-args-comp.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-:4
-Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/version-client-shorthand.txt b/cmd/helm/testdata/output/version-client-shorthand.txt
deleted file mode 100644
index 9a42dcba7..000000000
--- a/cmd/helm/testdata/output/version-client-shorthand.txt
+++ /dev/null
@@ -1 +0,0 @@
-version.BuildInfo{Version:"v3.13", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/cmd/helm/testdata/output/version-client.txt b/cmd/helm/testdata/output/version-client.txt
deleted file mode 100644
index 9a42dcba7..000000000
--- a/cmd/helm/testdata/output/version-client.txt
+++ /dev/null
@@ -1 +0,0 @@
-version.BuildInfo{Version:"v3.13", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/cmd/helm/testdata/output/version-short.txt b/cmd/helm/testdata/output/version-short.txt
deleted file mode 100644
index 588b5b7c5..000000000
--- a/cmd/helm/testdata/output/version-short.txt
+++ /dev/null
@@ -1 +0,0 @@
-v3.13
diff --git a/cmd/helm/testdata/output/version-template.txt b/cmd/helm/testdata/output/version-template.txt
deleted file mode 100644
index b6f541d94..000000000
--- a/cmd/helm/testdata/output/version-template.txt
+++ /dev/null
@@ -1 +0,0 @@
-Version: v3.13
\ No newline at end of file
diff --git a/cmd/helm/testdata/output/version.txt b/cmd/helm/testdata/output/version.txt
deleted file mode 100644
index 9a42dcba7..000000000
--- a/cmd/helm/testdata/output/version.txt
+++ /dev/null
@@ -1 +0,0 @@
-version.BuildInfo{Version:"v3.13", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/cmd/helm/testdata/testplugin/plugin.yaml b/cmd/helm/testdata/testplugin/plugin.yaml
deleted file mode 100644
index 890292cbf..000000000
--- a/cmd/helm/testdata/testplugin/plugin.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-name: testplugin
-usage: "echo test"
-description: "This echos test"
-command: "echo test"
diff --git a/go.mod b/go.mod
index c169a6283..6557d7663 100644
--- a/go.mod
+++ b/go.mod
@@ -1,164 +1,180 @@
-module helm.sh/helm/v3
+module helm.sh/helm/v4
-go 1.19
+go 1.24.0
require (
- github.com/BurntSushi/toml v1.3.2
- github.com/DATA-DOG/go-sqlmock v1.5.0
- github.com/Masterminds/semver/v3 v3.2.1
- github.com/Masterminds/sprig/v3 v3.2.3
+ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24
+ github.com/BurntSushi/toml v1.5.0
+ github.com/DATA-DOG/go-sqlmock v1.5.2
+ github.com/Masterminds/semver/v3 v3.4.0
+ github.com/Masterminds/sprig/v3 v3.3.0
github.com/Masterminds/squirrel v1.5.4
github.com/Masterminds/vcs v1.13.3
- github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535
- github.com/containerd/containerd v1.7.6
- github.com/cyphar/filepath-securejoin v0.2.4
- github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2
- github.com/evanphx/json-patch v5.6.0+incompatible
- github.com/foxcpp/go-mockdns v1.0.0
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
+ github.com/cyphar/filepath-securejoin v0.4.1
+ github.com/distribution/distribution/v3 v3.0.0
+ github.com/evanphx/json-patch/v5 v5.9.11
+ github.com/fatih/color v1.18.0
+ github.com/fluxcd/cli-utils v0.36.0-flux.14
+ github.com/foxcpp/go-mockdns v1.1.0
github.com/gobwas/glob v0.2.3
- github.com/gofrs/flock v0.8.1
+ github.com/gofrs/flock v0.12.1
github.com/gosuri/uitable v0.0.4
- github.com/hashicorp/go-multierror v1.1.1
- github.com/jmoiron/sqlx v1.3.5
+ github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.10.9
github.com/mattn/go-shellwords v1.0.12
github.com/mitchellh/copystructure v1.2.0
- github.com/moby/term v0.5.0
- github.com/opencontainers/image-spec v1.1.0-rc5
- github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5
- github.com/pkg/errors v0.9.1
- github.com/rubenv/sql-migrate v1.5.2
- github.com/sirupsen/logrus v1.9.3
- github.com/spf13/cobra v1.7.0
- github.com/spf13/pflag v1.0.5
- github.com/stretchr/testify v1.8.4
- github.com/xeipuuv/gojsonschema v1.2.0
- golang.org/x/crypto v0.13.0
- golang.org/x/term v0.12.0
- golang.org/x/text v0.13.0
- k8s.io/api v0.28.2
- k8s.io/apiextensions-apiserver v0.28.2
- k8s.io/apimachinery v0.28.2
- k8s.io/apiserver v0.28.2
- k8s.io/cli-runtime v0.28.2
- k8s.io/client-go v0.28.2
- k8s.io/klog/v2 v2.100.1
- k8s.io/kubectl v0.28.2
- oras.land/oras-go v1.2.4
- sigs.k8s.io/yaml v1.3.0
+ github.com/moby/term v0.5.2
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1
+ github.com/rubenv/sql-migrate v1.8.0
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
+ github.com/spf13/cobra v1.9.1
+ github.com/spf13/pflag v1.0.7
+ github.com/stretchr/testify v1.10.0
+ go.yaml.in/yaml/v3 v3.0.4
+ golang.org/x/crypto v0.41.0
+ golang.org/x/term v0.34.0
+ golang.org/x/text v0.28.0
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/api v0.33.4
+ k8s.io/apiextensions-apiserver v0.33.4
+ k8s.io/apimachinery v0.33.4
+ k8s.io/apiserver v0.33.4
+ k8s.io/cli-runtime v0.33.4
+ k8s.io/client-go v0.33.4
+ k8s.io/klog/v2 v2.130.1
+ k8s.io/kubectl v0.33.4
+ oras.land/oras-go/v2 v2.6.0
+ sigs.k8s.io/controller-runtime v0.21.0
+ sigs.k8s.io/kustomize/kyaml v0.20.1
+ sigs.k8s.io/yaml v1.6.0
)
require (
- github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
+ dario.cat/mergo v1.0.1 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/Microsoft/hcsshim v0.11.0 // indirect
- github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // 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/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd // indirect
- github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b // indirect
- github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 // indirect
- github.com/cespare/xxhash/v2 v2.2.0 // indirect
+ github.com/carapace-sh/carapace-shlex v1.0.1 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/docker/cli v24.0.6+incompatible // indirect
- github.com/docker/distribution v2.8.2+incompatible // indirect
- github.com/docker/docker v24.0.6+incompatible // indirect
- github.com/docker/docker-credential-helpers v0.7.0 // indirect
- github.com/docker/go-connections v0.4.0 // indirect
+ github.com/coreos/go-systemd/v22 v22.5.0 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ 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/go-metrics v0.0.1 // indirect
- github.com/docker/go-units v0.5.0 // indirect
- github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect
- github.com/emicklei/go-restful/v3 v3.10.1 // indirect
- github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
- github.com/fatih/color v1.13.0 // indirect
- github.com/felixge/httpsnoop v1.0.3 // indirect
- github.com/fvbommel/sortorder v1.1.0 // indirect
- github.com/go-errors/errors v1.4.2 // indirect
+ github.com/emicklei/go-restful/v3 v3.12.2 // 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.8.0 // indirect
+ github.com/go-errors/errors v1.5.1 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
- github.com/go-logr/logr v1.2.4 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-openapi/jsonpointer v0.19.6 // indirect
- github.com/go-openapi/jsonreference v0.20.2 // indirect
- github.com/go-openapi/swag v0.22.3 // indirect
+ github.com/go-openapi/jsonpointer v0.21.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang/protobuf v1.5.3 // indirect
- github.com/gomodule/redigo v1.8.2 // indirect
- github.com/google/btree v1.0.1 // indirect
- github.com/google/gnostic-models v0.6.8 // indirect
- github.com/google/go-cmp v0.5.9 // indirect
- github.com/google/gofuzz v1.2.0 // indirect
- github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
- github.com/google/uuid v1.3.0 // indirect
- github.com/gorilla/handlers v1.5.1 // indirect
- github.com/gorilla/mux v1.8.0 // indirect
- github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
- github.com/hashicorp/errwrap v1.1.0 // indirect
- github.com/hashicorp/golang-lru v0.5.4 // indirect
- github.com/huandu/xstrings v1.4.0 // indirect
- github.com/imdario/mergo v0.3.13 // indirect
+ github.com/google/btree v1.1.3 // indirect
+ github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/handlers v1.5.2 // indirect
+ github.com/gorilla/mux v1.8.1 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.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
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.16.0 // indirect
+ github.com/klauspost/compress v1.18.0 // 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.7.7 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.17 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
- github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
- github.com/miekg/dns v1.1.25 // indirect
+ github.com/miekg/dns v1.1.57 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/moby/locker v1.0.1 // indirect
- github.com/moby/spdystream v0.2.0 // indirect
+ github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
- github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+ github.com/onsi/gomega v1.37.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/prometheus/client_golang v1.16.0 // indirect
- github.com/prometheus/client_model v0.4.0 // indirect
- github.com/prometheus/common v0.44.0 // indirect
- github.com/prometheus/procfs v0.10.1 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.22.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.65.0 // indirect
+ github.com/prometheus/procfs v0.17.0 // indirect
+ github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
+ github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
+ github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
- github.com/shopspring/decimal v1.3.1 // indirect
- github.com/spf13/cast v1.5.0 // indirect
- github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
- github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/spf13/cast v1.7.0 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
- github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 // indirect
- github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 // indirect
- github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f // indirect
- go.opentelemetry.io/otel v1.14.0 // indirect
- go.opentelemetry.io/otel/trace v1.14.0 // indirect
- go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
- golang.org/x/net v0.13.0 // indirect
- golang.org/x/oauth2 v0.8.0 // indirect
- golang.org/x/sync v0.3.0 // indirect
- golang.org/x/sys v0.12.0 // indirect
- golang.org/x/time v0.3.0 // indirect
- google.golang.org/appengine v1.6.7 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 // indirect
- google.golang.org/grpc v1.54.0 // indirect
- google.golang.org/protobuf v1.30.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.58.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.33.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.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.33.0 // indirect
+ go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.4.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ golang.org/x/mod v0.26.0 // indirect
+ golang.org/x/net v0.42.0 // indirect
+ golang.org/x/oauth2 v0.30.0 // indirect
+ golang.org/x/sync v0.16.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/time v0.12.0 // indirect
+ golang.org/x/tools v0.35.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
+ google.golang.org/grpc v1.68.1 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/component-base v0.28.2 // indirect
- k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect
- k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
- sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
- sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect
- sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect
- sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
+ k8s.io/component-base v0.33.4 // indirect
+ k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 // indirect
+ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
+ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ sigs.k8s.io/kustomize/api v0.20.0 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
)
diff --git a/go.sum b/go.sum
index 974a2c68d..b76d921d3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,207 +1,173 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
+dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
-github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
-github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
-github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
-github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
+github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
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.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
-github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
-github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
-github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
-github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
+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/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/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
-github.com/Microsoft/hcsshim v0.11.0 h1:7EFNIY4igHEXUdj1zXgAyU3fLc7QfOKHbkldRVTBdiM=
-github.com/Microsoft/hcsshim v0.11.0/go.mod h1:OEthFdQv/AD2RAdzR6Mm1N1KPCztGKDurW1Z8b8VGMM=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
-github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
-github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 h1:4daAzAu0S6Vi7/lbWECcX0j45yZReDZ56BQsrVBOEEY=
-github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
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/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
+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/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng=
-github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
-github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembjv71DPz3uX/V/6MMlSyD9JBQ6kQ=
-github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
-github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o=
-github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
+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/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c=
+github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M=
+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/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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
-github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
-github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
-github.com/containerd/containerd v1.7.6 h1:oNAVsnhPoy4BTPQivLgTzI9Oleml9l/+eYIDYXRCYo8=
-github.com/containerd/containerd v1.7.6/go.mod h1:SY6lrkkuJT40BVNO37tlYTSnKJnP5AXBc0fhx0q+TJ4=
-github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM=
-github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
-github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
-github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
-github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc=
-github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
-github.com/docker/cli v24.0.6+incompatible h1:fF+XCQCgJjjQNIMjzaSmiKJSCcfcXb3TWTcc7GAneOY=
-github.com/docker/cli v24.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
-github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v24.0.6+incompatible h1:hceabKCtUgDqPu+qm0NgsaXf28Ljf4/pWFL7xjWWDgE=
-github.com/docker/docker v24.0.6+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
-github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
-github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
-github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
+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/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/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
-github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
-github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4=
-github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
-github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ=
-github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
-github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
-github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM=
-github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=
-github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
-github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
-github.com/foxcpp/go-mockdns v1.0.0 h1:7jBqxd3WDWwi/6WhDvacvH1XsN3rOLXyHM1uhvIx6FI=
-github.com/foxcpp/go-mockdns v1.0.0/go.mod h1:lgRN6+KxQBawyIghpnl5CezHFGS9VLzvtVlwxvzXTQ4=
-github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
-github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
-github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
-github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
-github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+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/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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fluxcd/cli-utils v0.36.0-flux.14 h1:I//AMVUXTc+M04UtIXArMXQZCazGMwfemodV1j/yG8c=
+github.com/fluxcd/cli-utils v0.36.0-flux.14/go.mod h1:uDo7BYOfbdmk/asnHuI0IQPl6u0FCgcN54AHDu3Y5As=
+github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
+github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
+github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
+github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
-github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
-github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
-github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
-github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
-github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
-github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
+github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
+github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
+github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
+github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
+github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
+github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
-github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU=
-github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0=
-github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+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/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
-github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
+github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
-github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k=
-github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0=
-github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
-github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
-github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
-github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
+github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
-github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
-github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
-github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
-github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
-github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+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/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=
+github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
-github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=
-github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
-github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
-github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
-github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
-github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
-github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
-github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
-github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
-github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk=
-github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg=
+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.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
+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=
+github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
-github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
+github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
+github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -209,68 +175,53 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
-github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
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.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI=
-github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY=
-github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI=
-github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+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.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
-github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+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-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
-github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
+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=
-github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
-github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
-github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg=
-github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
-github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
+github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
+github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
-github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ=
-github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
-github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
-github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
-github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
-github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
+github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -280,278 +231,312 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
-github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
-github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
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.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE=
-github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
+github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
+github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/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.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
-github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
-github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
-github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
+github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
-github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
+github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
-github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
-github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/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.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
-github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0=
-github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is=
+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/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=
+github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ=
+github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
+github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
+github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o=
+github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8=
-github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+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/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w=
-github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
-github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
-github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
-github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI=
-github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
-github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE=
-github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA=
-github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY=
-github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM=
-go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU=
-go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M=
-go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8=
-go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY=
-go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds=
+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.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
+go.opentelemetry.io/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.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
+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.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
+go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
+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.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
+go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
+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.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
+go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
+go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
+golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.13.0 h1:Nvo8UFsZ8X3BhAC9699Z1j7XQ3rsZnUUm7jfBEk1ueY=
-golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
-golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
+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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
+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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+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.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
+golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+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/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
+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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
+golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
-google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19 h1:0nDDozoAU19Qb2HwhXadU8OcsiO/09cnTqhUtq2MEOM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230525234030-28d5490b6b19/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
-google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
-google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
+google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
+google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
+google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
+gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0/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=
-gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-k8s.io/api v0.28.2 h1:9mpl5mOb6vXZvqbQmankOfPIGiudghwCoLl1EYfUZbw=
-k8s.io/api v0.28.2/go.mod h1:RVnJBsjU8tcMq7C3iaRSGMeaKt2TWEUXcpIt/90fjEg=
-k8s.io/apiextensions-apiserver v0.28.2 h1:J6/QRWIKV2/HwBhHRVITMLYoypCoPY1ftigDM0Kn+QU=
-k8s.io/apiextensions-apiserver v0.28.2/go.mod h1:5tnkxLGa9nefefYzWuAlWZ7RZYuN/765Au8cWLA6SRg=
-k8s.io/apimachinery v0.28.2 h1:KCOJLrc6gu+wV1BYgwik4AF4vXOlVJPdiqn0yAWWwXQ=
-k8s.io/apimachinery v0.28.2/go.mod h1:RdzF87y/ngqk9H4z3EL2Rppv5jj95vGS/HaFXrLDApU=
-k8s.io/apiserver v0.28.2 h1:rBeYkLvF94Nku9XfXyUIirsVzCzJBs6jMn3NWeHieyI=
-k8s.io/apiserver v0.28.2/go.mod h1:f7D5e8wH8MWcKD7azq6Csw9UN+CjdtXIVQUyUhrtb+E=
-k8s.io/cli-runtime v0.28.2 h1:64meB2fDj10/ThIMEJLO29a1oujSm0GQmKzh1RtA/uk=
-k8s.io/cli-runtime v0.28.2/go.mod h1:bTpGOvpdsPtDKoyfG4EG041WIyFZLV9qq4rPlkyYfDA=
-k8s.io/client-go v0.28.2 h1:DNoYI1vGq0slMBN/SWKMZMw0Rq+0EQW6/AK4v9+3VeY=
-k8s.io/client-go v0.28.2/go.mod h1:sMkApowspLuc7omj1FOSUxSoqjr+d5Q0Yc0LOFnYFJY=
-k8s.io/component-base v0.28.2 h1:Yc1yU+6AQSlpJZyvehm/NkJBII72rzlEsd6MkBQ+G0E=
-k8s.io/component-base v0.28.2/go.mod h1:4IuQPQviQCg3du4si8GpMrhAIegxpsgPngPRR/zWpzc=
-k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg=
-k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
-k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 h1:LyMgNKD2P8Wn1iAwQU5OhxCKlKJy0sHc+PcDwFB24dQ=
-k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9/go.mod h1:wZK2AVp1uHCp4VamDVgBP2COHZjqD1T68Rf0CM3YjSM=
-k8s.io/kubectl v0.28.2 h1:fOWOtU6S0smdNjG1PB9WFbqEIMlkzU5ahyHkc7ESHgM=
-k8s.io/kubectl v0.28.2/go.mod h1:6EQWTPySF1fn7yKoQZHYf9TPwIl2AygHEcJoxFekr64=
-k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 h1:qY1Ad8PODbnymg2pRbkyMT/ylpTrCM8P2RJ0yroCyIk=
-k8s.io/utils v0.0.0-20230406110748-d93618cff8a2/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-oras.land/oras-go v1.2.4 h1:djpBY2/2Cs1PV87GSJlxv4voajVOMZxqqtq9AB8YNvY=
-oras.land/oras-go v1.2.4/go.mod h1:DYcGfb3YF1nKjcezfX2SNlDAeQFKSXmf+qrFmrh4324=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
-sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 h1:XX3Ajgzov2RKUdc5jW3t5jwY7Bo7dcRm+tFxT+NfgY0=
-sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3/go.mod h1:9n16EZKMhXBNSiUC5kSdFQJkdH3zbxS/JoO619G1VAY=
-sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 h1:W6cLQc5pnqM7vh3b7HvGNfXrJ/xL6BDMS0v1V/HHg5U=
-sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3/go.mod h1:JWP1Fj0VWGHyw3YUPjXSQnRnrwezrZSrApfX5S0nIag=
-sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
-sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
-sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
-sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+k8s.io/api v0.33.4 h1:oTzrFVNPXBjMu0IlpA2eDDIU49jsuEorGHB4cvKupkk=
+k8s.io/api v0.33.4/go.mod h1:VHQZ4cuxQ9sCUMESJV5+Fe8bGnqAARZ08tSTdHWfeAc=
+k8s.io/apiextensions-apiserver v0.33.4 h1:rtq5SeXiDbXmSwxsF0MLe2Mtv3SwprA6wp+5qh/CrOU=
+k8s.io/apiextensions-apiserver v0.33.4/go.mod h1:mWXcZQkQV1GQyxeIjYApuqsn/081hhXPZwZ2URuJeSs=
+k8s.io/apimachinery v0.33.4 h1:SOf/JW33TP0eppJMkIgQ+L6atlDiP/090oaX0y9pd9s=
+k8s.io/apimachinery v0.33.4/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
+k8s.io/apiserver v0.33.4 h1:6N0TEVA6kASUS3owYDIFJjUH6lgN8ogQmzZvaFFj1/Y=
+k8s.io/apiserver v0.33.4/go.mod h1:8ODgXMnOoSPLMUg1aAzMFx+7wTJM+URil+INjbTZCok=
+k8s.io/cli-runtime v0.33.4 h1:V8NSxGfh24XzZVhXmIGzsApdBpGq0RQS2u/Fz1GvJwk=
+k8s.io/cli-runtime v0.33.4/go.mod h1:V+ilyokfqjT5OI+XE+O515K7jihtr0/uncwoyVqXaIU=
+k8s.io/client-go v0.33.4 h1:TNH+CSu8EmXfitntjUPwaKVPN0AYMbc9F1bBS8/ABpw=
+k8s.io/client-go v0.33.4/go.mod h1:LsA0+hBG2DPwovjd931L/AoaezMPX9CmBgyVyBZmbCY=
+k8s.io/component-base v0.33.4 h1:Jvb/aw/tl3pfgnJ0E0qPuYLT0NwdYs1VXXYQmSuxJGY=
+k8s.io/component-base v0.33.4/go.mod h1:567TeSdixWW2Xb1yYUQ7qk5Docp2kNznKL87eygY8Rc=
+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-20250701173324-9bd5c66d9911 h1:gAXU86Fmbr/ktY17lkHwSjw5aoThQvhnstGGIYKlKYc=
+k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911/go.mod h1:GLOk5B+hDbRROvt0X2+hqX64v/zO3vXN7J78OUmBSKw=
+k8s.io/kubectl v0.33.4 h1:nXEI6Vi+oB9hXxoAHyHisXolm/l1qutK3oZQMak4N98=
+k8s.io/kubectl v0.33.4/go.mod h1:Xe7P9X4DfILvKmlBsVqUtzktkI56lEj22SJW7cFy6nE=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
+oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=
+sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=
+sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/kustomize/api v0.20.0 h1:xPLqcobHI0bThyRUteO+nCV8G4d1Rlo5HafO57VRcas=
+sigs.k8s.io/kustomize/api v0.20.0/go.mod h1:F6CfaV27oevRCMJgehLqyX81dlUnRX/Fc13Uo7+OSo4=
+sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78=
+sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po=
+sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+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/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
+sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
+sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
+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
new file mode 100644
index 000000000..4d59fa5ec
--- /dev/null
+++ b/internal/chart/v3/chart.go
@@ -0,0 +1,172 @@
+/*
+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 v3
+
+import (
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// APIVersionV3 is the API version number for version 3.
+const APIVersionV3 = "v3"
+
+// aliasNameFormat defines the characters that are legal in an alias name.
+var aliasNameFormat = regexp.MustCompile("^[a-zA-Z0-9_-]+$")
+
+// Chart is a helm package that contains metadata, a default config, zero or more
+// optionally parameterizable templates, and zero or more charts (dependencies).
+type Chart struct {
+ // Raw contains the raw contents of the files originally contained in the chart archive.
+ //
+ // This should not be used except in special cases like `helm show values`,
+ // where we want to display the raw values, comments and all.
+ Raw []*File `json:"-"`
+ // Metadata is the contents of the Chartfile.
+ Metadata *Metadata `json:"metadata"`
+ // Lock is the contents of Chart.lock.
+ Lock *Lock `json:"lock"`
+ // Templates for this chart.
+ Templates []*File `json:"templates"`
+ // Values are default config for this chart.
+ Values map[string]interface{} `json:"values"`
+ // Schema is an optional JSON schema for imposing structure on Values
+ Schema []byte `json:"schema"`
+ // Files are miscellaneous files in a chart archive,
+ // e.g. README, LICENSE, etc.
+ Files []*File `json:"files"`
+
+ parent *Chart
+ dependencies []*Chart
+}
+
+type CRD struct {
+ // Name is the File.Name for the crd file
+ Name string
+ // Filename is the File obj Name including (sub-)chart.ChartFullPath
+ Filename string
+ // File is the File obj for the crd
+ File *File
+}
+
+// SetDependencies replaces the chart dependencies.
+func (ch *Chart) SetDependencies(charts ...*Chart) {
+ ch.dependencies = nil
+ ch.AddDependency(charts...)
+}
+
+// Name returns the name of the chart.
+func (ch *Chart) Name() string {
+ if ch.Metadata == nil {
+ return ""
+ }
+ return ch.Metadata.Name
+}
+
+// AddDependency determines if the chart is a subchart.
+func (ch *Chart) AddDependency(charts ...*Chart) {
+ for i, x := range charts {
+ charts[i].parent = ch
+ ch.dependencies = append(ch.dependencies, x)
+ }
+}
+
+// Root finds the root chart.
+func (ch *Chart) Root() *Chart {
+ if ch.IsRoot() {
+ return ch
+ }
+ return ch.Parent().Root()
+}
+
+// Dependencies are the charts that this chart depends on.
+func (ch *Chart) Dependencies() []*Chart { return ch.dependencies }
+
+// IsRoot determines if the chart is the root chart.
+func (ch *Chart) IsRoot() bool { return ch.parent == nil }
+
+// Parent returns a subchart's parent chart.
+func (ch *Chart) Parent() *Chart { return ch.parent }
+
+// ChartPath returns the full path to this chart in dot notation.
+func (ch *Chart) ChartPath() string {
+ if !ch.IsRoot() {
+ return ch.Parent().ChartPath() + "." + ch.Name()
+ }
+ return ch.Name()
+}
+
+// ChartFullPath returns the full path to this chart.
+// Note that the path may not correspond to the path where the file can be found on the file system if the path
+// points to an aliased subchart.
+func (ch *Chart) ChartFullPath() string {
+ if !ch.IsRoot() {
+ return ch.Parent().ChartFullPath() + "/charts/" + ch.Name()
+ }
+ return ch.Name()
+}
+
+// Validate validates the metadata.
+func (ch *Chart) Validate() error {
+ return ch.Metadata.Validate()
+}
+
+// AppVersion returns the appversion of the chart.
+func (ch *Chart) AppVersion() string {
+ if ch.Metadata == nil {
+ return ""
+ }
+ return ch.Metadata.AppVersion
+}
+
+// CRDs returns a list of File objects in the 'crds/' directory of a Helm chart.
+// Deprecated: use CRDObjects()
+func (ch *Chart) CRDs() []*File {
+ files := []*File{}
+ // Find all resources in the crds/ directory
+ for _, f := range ch.Files {
+ if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) {
+ files = append(files, f)
+ }
+ }
+ // Get CRDs from dependencies, too.
+ for _, dep := range ch.Dependencies() {
+ files = append(files, dep.CRDs()...)
+ }
+ return files
+}
+
+// CRDObjects returns a list of CRD objects in the 'crds/' directory of a Helm chart & subcharts
+func (ch *Chart) CRDObjects() []CRD {
+ crds := []CRD{}
+ // Find all resources in the crds/ directory
+ for _, f := range ch.Files {
+ if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) {
+ mycrd := CRD{Name: f.Name, Filename: filepath.Join(ch.ChartFullPath(), f.Name), File: f}
+ crds = append(crds, mycrd)
+ }
+ }
+ // Get CRDs from dependencies, too.
+ for _, dep := range ch.Dependencies() {
+ crds = append(crds, dep.CRDObjects()...)
+ }
+ return crds
+}
+
+func hasManifestExtension(fname string) bool {
+ ext := filepath.Ext(fname)
+ return strings.EqualFold(ext, ".yaml") || strings.EqualFold(ext, ".yml") || strings.EqualFold(ext, ".json")
+}
diff --git a/internal/chart/v3/chart_test.go b/internal/chart/v3/chart_test.go
new file mode 100644
index 000000000..f93b3356b
--- /dev/null
+++ b/internal/chart/v3/chart_test.go
@@ -0,0 +1,211 @@
+/*
+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 v3
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCRDs(t *testing.T) {
+ chrt := Chart{
+ Files: []*File{
+ {
+ Name: "crds/foo.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "bar.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crds/foo/bar/baz.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crdsfoo/bar/baz.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crds/README.md",
+ Data: []byte("# hello"),
+ },
+ },
+ }
+
+ is := assert.New(t)
+ crds := chrt.CRDs()
+ is.Equal(2, len(crds))
+ is.Equal("crds/foo.yaml", crds[0].Name)
+ is.Equal("crds/foo/bar/baz.yaml", crds[1].Name)
+}
+
+func TestSaveChartNoRawData(t *testing.T) {
+ chrt := Chart{
+ Raw: []*File{
+ {
+ Name: "fhqwhgads.yaml",
+ Data: []byte("Everybody to the Limit"),
+ },
+ },
+ }
+
+ is := assert.New(t)
+ data, err := json.Marshal(chrt)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ res := &Chart{}
+ if err := json.Unmarshal(data, res); err != nil {
+ t.Fatal(err)
+ }
+
+ is.Equal([]*File(nil), res.Raw)
+}
+
+func TestMetadata(t *testing.T) {
+ chrt := Chart{
+ Metadata: &Metadata{
+ Name: "foo.yaml",
+ AppVersion: "1.0.0",
+ APIVersion: "v3",
+ Version: "1.0.0",
+ Type: "application",
+ },
+ }
+
+ is := assert.New(t)
+
+ is.Equal("foo.yaml", chrt.Name())
+ is.Equal("1.0.0", chrt.AppVersion())
+ is.Equal(nil, chrt.Validate())
+}
+
+func TestIsRoot(t *testing.T) {
+ chrt1 := Chart{
+ parent: &Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ },
+ }
+
+ chrt2 := Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ }
+
+ is := assert.New(t)
+
+ is.Equal(false, chrt1.IsRoot())
+ is.Equal(true, chrt2.IsRoot())
+}
+
+func TestChartPath(t *testing.T) {
+ chrt1 := Chart{
+ parent: &Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ },
+ }
+
+ chrt2 := Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ }
+
+ is := assert.New(t)
+
+ is.Equal("foo.", chrt1.ChartPath())
+ is.Equal("foo", chrt2.ChartPath())
+}
+
+func TestChartFullPath(t *testing.T) {
+ chrt1 := Chart{
+ parent: &Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ },
+ }
+
+ chrt2 := Chart{
+ Metadata: &Metadata{
+ Name: "foo",
+ },
+ }
+
+ is := assert.New(t)
+
+ is.Equal("foo/charts/", chrt1.ChartFullPath())
+ is.Equal("foo", chrt2.ChartFullPath())
+}
+
+func TestCRDObjects(t *testing.T) {
+ chrt := Chart{
+ Files: []*File{
+ {
+ Name: "crds/foo.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "bar.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crds/foo/bar/baz.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crdsfoo/bar/baz.yaml",
+ Data: []byte("hello"),
+ },
+ {
+ Name: "crds/README.md",
+ Data: []byte("# hello"),
+ },
+ },
+ }
+
+ expected := []CRD{
+ {
+ Name: "crds/foo.yaml",
+ Filename: "crds/foo.yaml",
+ File: &File{
+ Name: "crds/foo.yaml",
+ Data: []byte("hello"),
+ },
+ },
+ {
+ Name: "crds/foo/bar/baz.yaml",
+ Filename: "crds/foo/bar/baz.yaml",
+ File: &File{
+ Name: "crds/foo/bar/baz.yaml",
+ Data: []byte("hello"),
+ },
+ },
+ }
+
+ is := assert.New(t)
+ crds := chrt.CRDObjects()
+ is.Equal(expected, crds)
+}
diff --git a/pkg/chart/dependency.go b/internal/chart/v3/dependency.go
similarity index 83%
rename from pkg/chart/dependency.go
rename to internal/chart/v3/dependency.go
index 4ef5eeb32..2d956b548 100644
--- a/pkg/chart/dependency.go
+++ b/internal/chart/v3/dependency.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v3
import "time"
@@ -25,28 +25,28 @@ type Dependency struct {
// Name is the name of the dependency.
//
// This must mach the name in the dependency's Chart.yaml.
- Name string `json:"name"`
+ Name string `json:"name" yaml:"name"`
// Version is the version (range) of this chart.
//
// A lock file will always produce a single version, while a dependency
// may contain a semantic version range.
- Version string `json:"version,omitempty"`
+ Version string `json:"version,omitempty" yaml:"version,omitempty"`
// The URL to the repository.
//
// Appending `index.yaml` to this string should result in a URL that can be
// used to fetch the repository index.
- Repository string `json:"repository"`
+ Repository string `json:"repository" yaml:"repository"`
// A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled )
- Condition string `json:"condition,omitempty"`
+ Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
// Tags can be used to group charts for enabling/disabling together
- Tags []string `json:"tags,omitempty"`
+ Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
// Enabled bool determines if chart should be loaded
- Enabled bool `json:"enabled,omitempty"`
+ 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"`
+ ImportValues []interface{} `json:"import-values,omitempty" yaml:"import-values,omitempty"`
// Alias usable alias to be used for the chart
- Alias string `json:"alias,omitempty"`
+ Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
}
// Validate checks for common problems with the dependency datastructure in
diff --git a/pkg/chart/dependency_test.go b/internal/chart/v3/dependency_test.go
similarity index 98%
rename from pkg/chart/dependency_test.go
rename to internal/chart/v3/dependency_test.go
index 90488a966..fcea19aea 100644
--- a/pkg/chart/dependency_test.go
+++ b/internal/chart/v3/dependency_test.go
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v3
import (
"testing"
diff --git a/internal/chart/v3/doc.go b/internal/chart/v3/doc.go
new file mode 100644
index 000000000..e003833a0
--- /dev/null
+++ b/internal/chart/v3/doc.go
@@ -0,0 +1,21 @@
+/*
+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 v3 provides chart handling for apiVersion v3 charts
+
+This package and its sub-packages provide handling for apiVersion v3 charts.
+*/
+package v3
diff --git a/pkg/chart/errors.go b/internal/chart/v3/errors.go
similarity index 98%
rename from pkg/chart/errors.go
rename to internal/chart/v3/errors.go
index 2fad5f370..059e43f07 100644
--- a/pkg/chart/errors.go
+++ b/internal/chart/v3/errors.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v3
import "fmt"
diff --git a/pkg/chart/file.go b/internal/chart/v3/file.go
similarity index 98%
rename from pkg/chart/file.go
rename to internal/chart/v3/file.go
index 9dd7c08d5..ba04e106d 100644
--- a/pkg/chart/file.go
+++ b/internal/chart/v3/file.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v3
// File represents a file as a name/value pair.
//
diff --git a/internal/chart/v3/fuzz_test.go b/internal/chart/v3/fuzz_test.go
new file mode 100644
index 000000000..982c26489
--- /dev/null
+++ b/internal/chart/v3/fuzz_test.go
@@ -0,0 +1,48 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v3
+
+import (
+ "testing"
+
+ fuzz "github.com/AdaLogics/go-fuzz-headers"
+)
+
+func FuzzMetadataValidate(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ fdp := fuzz.NewConsumer(data)
+ // Add random values to the metadata
+ md := &Metadata{}
+ err := fdp.GenerateStruct(md)
+ if err != nil {
+ t.Skip()
+ }
+ md.Validate()
+ })
+}
+
+func FuzzDependencyValidate(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ f := fuzz.NewConsumer(data)
+ // Add random values to the dependenci
+ d := &Dependency{}
+ err := f.GenerateStruct(d)
+ if err != nil {
+ t.Skip()
+ }
+ d.Validate()
+ })
+}
diff --git a/internal/chart/v3/loader/archive.go b/internal/chart/v3/loader/archive.go
new file mode 100644
index 000000000..311959d56
--- /dev/null
+++ b/internal/chart/v3/loader/archive.go
@@ -0,0 +1,234 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package loader
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path"
+ "regexp"
+ "strings"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// MaxDecompressedChartSize is the maximum size of a chart archive that will be
+// decompressed. This is the decompressed size of all the files.
+// The default value is 100 MiB.
+var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
+
+// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
+// The size of the file is the decompressed version of it when it is stored in an archive.
+var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
+
+var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
+
+// FileLoader loads a chart from a file
+type FileLoader string
+
+// Load loads a chart
+func (l FileLoader) Load() (*chart.Chart, error) {
+ return LoadFile(string(l))
+}
+
+// LoadFile loads from an archive file.
+func LoadFile(name string) (*chart.Chart, error) {
+ if fi, err := os.Stat(name); err != nil {
+ return nil, err
+ } else if fi.IsDir() {
+ return nil, errors.New("cannot load a directory")
+ }
+
+ raw, err := os.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ defer raw.Close()
+
+ err = ensureArchive(name, raw)
+ if err != nil {
+ return nil, err
+ }
+
+ c, err := LoadArchive(raw)
+ if err != nil {
+ if err == gzip.ErrHeader {
+ return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err)
+ }
+ }
+ return c, err
+}
+
+// ensureArchive's job is to return an informative error if the file does not appear to be a gzipped archive.
+//
+// Sometimes users will provide a values.yaml for an argument where a chart is expected. One common occurrence
+// of this is invoking `helm template values.yaml mychart` which would otherwise produce a confusing error
+// if we didn't check for this.
+func ensureArchive(name string, raw *os.File) error {
+ defer raw.Seek(0, 0) // reset read offset to allow archive loading to proceed.
+
+ // Check the file format to give us a chance to provide the user with more actionable feedback.
+ buffer := make([]byte, 512)
+ _, err := raw.Read(buffer)
+ if err != nil && err != io.EOF {
+ return fmt.Errorf("file '%s' cannot be read: %s", name, err)
+ }
+
+ // Helm may identify achieve of the application/x-gzip as application/vnd.ms-fontobject.
+ // Fix for: https://github.com/helm/helm/issues/12261
+ if contentType := http.DetectContentType(buffer); contentType != "application/x-gzip" && !isGZipApplication(buffer) {
+ // TODO: Is there a way to reliably test if a file content is YAML? ghodss/yaml accepts a wide
+ // variety of content (Makefile, .zshrc) as valid YAML without errors.
+
+ // Wrong content type. Let's check if it's yaml and give an extra hint?
+ if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
+ return fmt.Errorf("file '%s' seems to be a YAML file, but expected a gzipped archive", name)
+ }
+ return fmt.Errorf("file '%s' does not appear to be a gzipped archive; got '%s'", name, contentType)
+ }
+ return nil
+}
+
+// isGZipApplication checks whether the archive is of the application/x-gzip type.
+func isGZipApplication(data []byte) bool {
+ sig := []byte("\x1F\x8B\x08")
+ return bytes.HasPrefix(data, sig)
+}
+
+// LoadArchiveFiles reads in files out of an archive into memory. This function
+// performs important path security checks and should always be used before
+// expanding a tarball
+func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
+ unzipped, err := gzip.NewReader(in)
+ if err != nil {
+ return nil, err
+ }
+ defer unzipped.Close()
+
+ files := []*BufferedFile{}
+ tr := tar.NewReader(unzipped)
+ remainingSize := MaxDecompressedChartSize
+ for {
+ b := bytes.NewBuffer(nil)
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if hd.FileInfo().IsDir() {
+ // Use this instead of hd.Typeflag because we don't have to do any
+ // inference chasing.
+ continue
+ }
+
+ switch hd.Typeflag {
+ // We don't want to process these extension header files.
+ case tar.TypeXGlobalHeader, tar.TypeXHeader:
+ continue
+ }
+
+ // Archive could contain \ if generated on Windows
+ delimiter := "/"
+ if strings.ContainsRune(hd.Name, '\\') {
+ delimiter = "\\"
+ }
+
+ parts := strings.Split(hd.Name, delimiter)
+ n := strings.Join(parts[1:], delimiter)
+
+ // Normalize the path to the / delimiter
+ n = strings.ReplaceAll(n, delimiter, "/")
+
+ if path.IsAbs(n) {
+ return nil, errors.New("chart illegally contains absolute paths")
+ }
+
+ n = path.Clean(n)
+ if n == "." {
+ // In this case, the original path was relative when it should have been absolute.
+ return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
+ }
+ if strings.HasPrefix(n, "..") {
+ return nil, errors.New("chart illegally references parent directory")
+ }
+
+ // In some particularly arcane acts of path creativity, it is possible to intermix
+ // UNIX and Windows style paths in such a way that you produce a result of the form
+ // c:/foo even after all the built-in absolute path checks. So we explicitly check
+ // for this condition.
+ if drivePathPattern.MatchString(n) {
+ return nil, errors.New("chart contains illegally named files")
+ }
+
+ if parts[0] == "Chart.yaml" {
+ return nil, errors.New("chart yaml not in base directory")
+ }
+
+ if hd.Size > remainingSize {
+ return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
+ }
+
+ if hd.Size > MaxDecompressedFileSize {
+ return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
+ }
+
+ limitedReader := io.LimitReader(tr, remainingSize)
+
+ bytesWritten, err := io.Copy(b, limitedReader)
+ if err != nil {
+ return nil, err
+ }
+
+ remainingSize -= bytesWritten
+ // When the bytesWritten are less than the file size it means the limit reader ended
+ // copying early. Here we report that error. This is important if the last file extracted
+ // is the one that goes over the limit. It assumes the Size stored in the tar header
+ // is correct, something many applications do.
+ if bytesWritten < hd.Size || remainingSize <= 0 {
+ return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
+ }
+
+ data := bytes.TrimPrefix(b.Bytes(), utf8bom)
+
+ files = append(files, &BufferedFile{Name: n, Data: data})
+ b.Reset()
+ }
+
+ if len(files) == 0 {
+ return nil, errors.New("no files in chart archive")
+ }
+ return files, nil
+}
+
+// LoadArchive loads from a reader containing a compressed tar archive.
+func LoadArchive(in io.Reader) (*chart.Chart, error) {
+ files, err := LoadArchiveFiles(in)
+ if err != nil {
+ return nil, err
+ }
+
+ return LoadFiles(files)
+}
diff --git a/pkg/chart/loader/archive_test.go b/internal/chart/v3/loader/archive_test.go
similarity index 94%
rename from pkg/chart/loader/archive_test.go
rename to internal/chart/v3/loader/archive_test.go
index 41b0af1aa..d16c47563 100644
--- a/pkg/chart/loader/archive_test.go
+++ b/internal/chart/v3/loader/archive_test.go
@@ -31,8 +31,9 @@ func TestLoadArchiveFiles(t *testing.T) {
}{
{
name: "empty input should return no files",
- generate: func(w *tar.Writer) {},
- check: func(t *testing.T, files []*BufferedFile, err error) {
+ generate: func(_ *tar.Writer) {},
+ check: func(t *testing.T, _ []*BufferedFile, err error) {
+ t.Helper()
if err.Error() != "no files in chart archive" {
t.Fatalf(`expected "no files in chart archive", got [%#v]`, err)
}
@@ -61,6 +62,7 @@ func TestLoadArchiveFiles(t *testing.T) {
}
},
check: func(t *testing.T, files []*BufferedFile, err error) {
+ t.Helper()
if err != nil {
t.Fatalf(`got unwanted error [%#v] for tar file with pax_global_header content`, err)
}
diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go
new file mode 100644
index 000000000..947051604
--- /dev/null
+++ b/internal/chart/v3/loader/directory.go
@@ -0,0 +1,121 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package loader
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/sympath"
+ "helm.sh/helm/v4/pkg/ignore"
+)
+
+var utf8bom = []byte{0xEF, 0xBB, 0xBF}
+
+// DirLoader loads a chart from a directory
+type DirLoader string
+
+// Load loads the chart
+func (l DirLoader) Load() (*chart.Chart, error) {
+ return LoadDir(string(l))
+}
+
+// LoadDir loads from a directory.
+//
+// This loads charts only from directories.
+func LoadDir(dir string) (*chart.Chart, error) {
+ topdir, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ // Just used for errors.
+ c := &chart.Chart{}
+
+ rules := ignore.Empty()
+ ifile := filepath.Join(topdir, ignore.HelmIgnore)
+ if _, err := os.Stat(ifile); err == nil {
+ r, err := ignore.ParseFile(ifile)
+ if err != nil {
+ return c, err
+ }
+ rules = r
+ }
+ rules.AddDefaults()
+
+ files := []*BufferedFile{}
+ topdir += string(filepath.Separator)
+
+ walk := func(name string, fi os.FileInfo, err error) error {
+ n := strings.TrimPrefix(name, topdir)
+ if n == "" {
+ // No need to process top level. Avoid bug with helmignore .* matching
+ // empty names. See issue 1779.
+ return nil
+ }
+
+ // Normalize to / since it will also work on Windows
+ n = filepath.ToSlash(n)
+
+ if err != nil {
+ return err
+ }
+ if fi.IsDir() {
+ // Directory-based ignore rules should involve skipping the entire
+ // contents of that directory.
+ if rules.Ignore(n, fi) {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ // If a .helmignore file matches, skip this file.
+ if rules.Ignore(n, fi) {
+ return nil
+ }
+
+ // Irregular files include devices, sockets, and other uses of files that
+ // are not regular files. In Go they have a file mode type bit set.
+ // See https://golang.org/pkg/os/#FileMode for examples.
+ if !fi.Mode().IsRegular() {
+ return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
+ }
+
+ if fi.Size() > MaxDecompressedFileSize {
+ return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize)
+ }
+
+ data, err := os.ReadFile(name)
+ if err != nil {
+ return fmt.Errorf("error reading %s: %w", n, err)
+ }
+
+ data = bytes.TrimPrefix(data, utf8bom)
+
+ files = append(files, &BufferedFile{Name: n, Data: data})
+ return nil
+ }
+ if err = sympath.Walk(topdir, walk); err != nil {
+ return c, err
+ }
+
+ return LoadFiles(files)
+}
diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go
new file mode 100644
index 000000000..30bafdad4
--- /dev/null
+++ b/internal/chart/v3/loader/load.go
@@ -0,0 +1,219 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package loader
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "maps"
+ "os"
+ "path/filepath"
+ "strings"
+
+ utilyaml "k8s.io/apimachinery/pkg/util/yaml"
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// ChartLoader loads a chart.
+type ChartLoader interface {
+ Load() (*chart.Chart, error)
+}
+
+// Loader returns a new ChartLoader appropriate for the given chart name
+func Loader(name string) (ChartLoader, error) {
+ fi, err := os.Stat(name)
+ if err != nil {
+ return nil, err
+ }
+ if fi.IsDir() {
+ return DirLoader(name), nil
+ }
+ return FileLoader(name), nil
+}
+
+// Load takes a string name, tries to resolve it to a file or directory, and then loads it.
+//
+// This is the preferred way to load a chart. It will discover the chart encoding
+// and hand off to the appropriate chart reader.
+//
+// If a .helmignore file is present, the directory loader will skip loading any files
+// matching it. But .helmignore is not evaluated when reading out of an archive.
+func Load(name string) (*chart.Chart, error) {
+ l, err := Loader(name)
+ if err != nil {
+ return nil, err
+ }
+ return l.Load()
+}
+
+// BufferedFile represents an archive file buffered for later processing.
+type BufferedFile struct {
+ Name string
+ Data []byte
+}
+
+// LoadFiles loads from in-memory files.
+func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
+ c := new(chart.Chart)
+ subcharts := make(map[string][]*BufferedFile)
+
+ // do not rely on assumed ordering of files in the chart and crash
+ // if Chart.yaml was not coming early enough to initialize metadata
+ for _, f := range files {
+ c.Raw = append(c.Raw, &chart.File{Name: f.Name, Data: f.Data})
+ if f.Name == "Chart.yaml" {
+ if c.Metadata == nil {
+ c.Metadata = new(chart.Metadata)
+ }
+ if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil {
+ return c, fmt.Errorf("cannot load Chart.yaml: %w", err)
+ }
+ // While the documentation says the APIVersion is required, in practice there
+ // are cases where that's not enforced. Since this package set is for v3 charts,
+ // when this function is used v3 is automatically added when not present.
+ if c.Metadata.APIVersion == "" {
+ c.Metadata.APIVersion = chart.APIVersionV3
+ }
+ }
+ }
+ for _, f := range files {
+ switch {
+ case f.Name == "Chart.yaml":
+ // already processed
+ continue
+ case f.Name == "Chart.lock":
+ c.Lock = new(chart.Lock)
+ if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil {
+ return c, fmt.Errorf("cannot load Chart.lock: %w", err)
+ }
+ case f.Name == "values.yaml":
+ values, err := LoadValues(bytes.NewReader(f.Data))
+ if err != nil {
+ return c, fmt.Errorf("cannot load values.yaml: %w", err)
+ }
+ c.Values = values
+ case f.Name == "values.schema.json":
+ c.Schema = f.Data
+
+ case strings.HasPrefix(f.Name, "templates/"):
+ c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data})
+ case strings.HasPrefix(f.Name, "charts/"):
+ if filepath.Ext(f.Name) == ".prov" {
+ c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data})
+ continue
+ }
+
+ fname := strings.TrimPrefix(f.Name, "charts/")
+ cname := strings.SplitN(fname, "/", 2)[0]
+ subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data})
+ default:
+ c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data})
+ }
+ }
+
+ if c.Metadata == nil {
+ return c, errors.New("Chart.yaml file is missing") //nolint:staticcheck
+ }
+
+ if err := c.Validate(); err != nil {
+ return c, err
+ }
+
+ for n, files := range subcharts {
+ var sc *chart.Chart
+ var err error
+ switch {
+ case strings.IndexAny(n, "_.") == 0:
+ continue
+ case filepath.Ext(n) == ".tgz":
+ file := files[0]
+ if file.Name != n {
+ return c, fmt.Errorf("error unpacking subchart tar in %s: expected %s, got %s", c.Name(), n, file.Name)
+ }
+ // Untar the chart and add to c.Dependencies
+ sc, err = LoadArchive(bytes.NewBuffer(file.Data))
+ default:
+ // We have to trim the prefix off of every file, and ignore any file
+ // that is in charts/, but isn't actually a chart.
+ buff := make([]*BufferedFile, 0, len(files))
+ for _, f := range files {
+ parts := strings.SplitN(f.Name, "/", 2)
+ if len(parts) < 2 {
+ continue
+ }
+ f.Name = parts[1]
+ buff = append(buff, f)
+ }
+ sc, err = LoadFiles(buff)
+ }
+
+ if err != nil {
+ return c, fmt.Errorf("error unpacking subchart %s in %s: %w", n, c.Name(), err)
+ }
+ c.AddDependency(sc)
+ }
+
+ return c, nil
+}
+
+// LoadValues loads values from a reader.
+//
+// The reader is expected to contain one or more YAML documents, the values of which are merged.
+// And the values can be either a chart's default values or a user-supplied values.
+func LoadValues(data io.Reader) (map[string]interface{}, error) {
+ values := map[string]interface{}{}
+ reader := utilyaml.NewYAMLReader(bufio.NewReader(data))
+ for {
+ currentMap := map[string]interface{}{}
+ raw, err := reader.Read()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return nil, fmt.Errorf("error reading yaml document: %w", err)
+ }
+ if err := yaml.Unmarshal(raw, ¤tMap); err != nil {
+ return nil, fmt.Errorf("cannot unmarshal yaml document: %w", err)
+ }
+ values = MergeMaps(values, currentMap)
+ }
+ return values, nil
+}
+
+// MergeMaps merges two maps. If a key exists in both maps, the value from b will be used.
+// If the value is a map, the maps will be merged recursively.
+func MergeMaps(a, b map[string]interface{}) map[string]interface{} {
+ out := make(map[string]interface{}, len(a))
+ maps.Copy(out, a)
+ for k, v := range b {
+ if v, ok := v.(map[string]interface{}); ok {
+ if bv, ok := out[k]; ok {
+ if bv, ok := bv.(map[string]interface{}); ok {
+ out[k] = MergeMaps(bv, v)
+ continue
+ }
+ }
+ }
+ out[k] = v
+ }
+ return out
+}
diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go
new file mode 100644
index 000000000..e770923ff
--- /dev/null
+++ b/internal/chart/v3/loader/load_test.go
@@ -0,0 +1,711 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package loader
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+func TestLoadDir(t *testing.T) {
+ l, err := Loader("testdata/frobnitz")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyFrobnitz(t, c)
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+ verifyDependenciesLock(t, c)
+}
+
+func TestLoadDirWithDevNull(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("test only works on unix systems with /dev/null present")
+ }
+
+ l, err := Loader("testdata/frobnitz_with_dev_null")
+ if err != nil {
+ 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")
+ }
+}
+
+func TestLoadDirWithSymlink(t *testing.T) {
+ sym := filepath.Join("..", "LICENSE")
+ link := filepath.Join("testdata", "frobnitz_with_symlink", "LICENSE")
+
+ if err := os.Symlink(sym, link); err != nil {
+ t.Fatal(err)
+ }
+
+ defer os.Remove(link)
+
+ l, err := Loader("testdata/frobnitz_with_symlink")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyFrobnitz(t, c)
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+ verifyDependenciesLock(t, c)
+}
+
+func TestBomTestData(t *testing.T) {
+ testFiles := []string{"frobnitz_with_bom/.helmignore", "frobnitz_with_bom/templates/template.tpl", "frobnitz_with_bom/Chart.yaml"}
+ for _, file := range testFiles {
+ data, err := os.ReadFile("testdata/" + file)
+ if err != nil || !bytes.HasPrefix(data, utf8bom) {
+ t.Errorf("Test file has no BOM or is invalid: testdata/%s", file)
+ }
+ }
+
+ archive, err := os.ReadFile("testdata/frobnitz_with_bom.tgz")
+ if err != nil {
+ t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err)
+ }
+ unzipped, err := gzip.NewReader(bytes.NewReader(archive))
+ if err != nil {
+ t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err)
+ }
+ defer unzipped.Close()
+ for _, testFile := range testFiles {
+ data := make([]byte, 3)
+ err := unzipped.Reset(bytes.NewReader(archive))
+ if err != nil {
+ t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err)
+ }
+ tr := tar.NewReader(unzipped)
+ for {
+ file, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err)
+ }
+ if file != nil && strings.EqualFold(file.Name, testFile) {
+ _, err := tr.Read(data)
+ if err != nil {
+ t.Fatalf("Error reading archive frobnitz_with_bom.tgz: %s", err)
+ } else {
+ break
+ }
+ }
+ }
+ if !bytes.Equal(data, utf8bom) {
+ t.Fatalf("Test file has no BOM or is invalid: frobnitz_with_bom.tgz/%s", testFile)
+ }
+ }
+}
+
+func TestLoadDirWithUTFBOM(t *testing.T) {
+ l, err := Loader("testdata/frobnitz_with_bom")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyFrobnitz(t, c)
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+ verifyDependenciesLock(t, c)
+ verifyBomStripped(t, c.Files)
+}
+
+func TestLoadArchiveWithUTFBOM(t *testing.T) {
+ l, err := Loader("testdata/frobnitz_with_bom.tgz")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyFrobnitz(t, c)
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+ verifyDependenciesLock(t, c)
+ verifyBomStripped(t, c.Files)
+}
+
+func TestLoadFile(t *testing.T) {
+ l, err := Loader("testdata/frobnitz-1.2.3.tgz")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyFrobnitz(t, c)
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+}
+
+func TestLoadFiles(t *testing.T) {
+ goodFiles := []*BufferedFile{
+ {
+ Name: "Chart.yaml",
+ Data: []byte(`apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+`),
+ },
+ {
+ Name: "values.yaml",
+ Data: []byte("var: some values"),
+ },
+ {
+ Name: "values.schema.json",
+ Data: []byte("type: Values"),
+ },
+ {
+ Name: "templates/deployment.yaml",
+ Data: []byte("some deployment"),
+ },
+ {
+ Name: "templates/service.yaml",
+ Data: []byte("some service"),
+ },
+ }
+
+ c, err := LoadFiles(goodFiles)
+ if err != nil {
+ t.Errorf("Expected good files to be loaded, got %v", err)
+ }
+
+ if c.Name() != "frobnitz" {
+ t.Errorf("Expected chart name to be 'frobnitz', got %s", c.Name())
+ }
+
+ if c.Values["var"] != "some values" {
+ t.Error("Expected chart values to be populated with default values")
+ }
+
+ if len(c.Raw) != 5 {
+ t.Errorf("Expected %d files, got %d", 5, len(c.Raw))
+ }
+
+ if !bytes.Equal(c.Schema, []byte("type: Values")) {
+ t.Error("Expected chart schema to be populated with default values")
+ }
+
+ if len(c.Templates) != 2 {
+ t.Errorf("Expected number of templates == 2, got %d", len(c.Templates))
+ }
+
+ if _, err = LoadFiles([]*BufferedFile{}); err == nil {
+ t.Fatal("Expected err to be non-nil")
+ }
+ if err.Error() != "Chart.yaml file is missing" {
+ t.Errorf("Expected chart metadata missing error, got '%s'", err.Error())
+ }
+}
+
+// Test the order of file loading. The Chart.yaml file needs to come first for
+// later comparison checks. See https://github.com/helm/helm/pull/8948
+func TestLoadFilesOrder(t *testing.T) {
+ goodFiles := []*BufferedFile{
+ {
+ Name: "requirements.yaml",
+ Data: []byte("dependencies:"),
+ },
+ {
+ Name: "values.yaml",
+ Data: []byte("var: some values"),
+ },
+
+ {
+ Name: "templates/deployment.yaml",
+ Data: []byte("some deployment"),
+ },
+ {
+ Name: "templates/service.yaml",
+ Data: []byte("some service"),
+ },
+ {
+ Name: "Chart.yaml",
+ Data: []byte(`apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+`),
+ },
+ }
+
+ // Capture stderr to make sure message about Chart.yaml handle dependencies
+ // is not present
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("Unable to create pipe: %s", err)
+ }
+ stderr := log.Writer()
+ log.SetOutput(w)
+ defer func() {
+ log.SetOutput(stderr)
+ }()
+
+ _, err = LoadFiles(goodFiles)
+ if err != nil {
+ t.Errorf("Expected good files to be loaded, got %v", err)
+ }
+ w.Close()
+
+ var text bytes.Buffer
+ io.Copy(&text, r)
+ if text.String() != "" {
+ t.Errorf("Expected no message to Stderr, got %s", text.String())
+ }
+
+}
+
+// Packaging the chart on a Windows machine will produce an
+// archive that has \\ as delimiters. Test that we support these archives
+func TestLoadFileBackslash(t *testing.T) {
+ c, err := Load("testdata/frobnitz_backslash-1.2.3.tgz")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyChartFileAndTemplate(t, c, "frobnitz_backslash")
+ verifyChart(t, c)
+ verifyDependencies(t, c)
+}
+
+func TestLoadV3WithReqs(t *testing.T) {
+ l, err := Loader("testdata/frobnitz.v3.reqs")
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ c, err := l.Load()
+ if err != nil {
+ t.Fatalf("Failed to load testdata: %s", err)
+ }
+ verifyDependencies(t, c)
+ verifyDependenciesLock(t, c)
+}
+
+func TestLoadInvalidArchive(t *testing.T) {
+ tmpdir := t.TempDir()
+
+ writeTar := func(filename, internalPath string, body []byte) {
+ dest, err := os.Create(filename)
+ if err != nil {
+ t.Fatal(err)
+ }
+ zipper := gzip.NewWriter(dest)
+ tw := tar.NewWriter(zipper)
+
+ h := &tar.Header{
+ Name: internalPath,
+ Mode: 0755,
+ Size: int64(len(body)),
+ ModTime: time.Now(),
+ }
+ if err := tw.WriteHeader(h); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tw.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ tw.Close()
+ zipper.Close()
+ dest.Close()
+ }
+
+ for _, tt := range []struct {
+ chartname string
+ internal string
+ expectError string
+ }{
+ {"illegal-dots.tgz", "../../malformed-helm-test", "chart illegally references parent directory"},
+ {"illegal-dots2.tgz", "/foo/../../malformed-helm-test", "chart illegally references parent directory"},
+ {"illegal-dots3.tgz", "/../../malformed-helm-test", "chart illegally references parent directory"},
+ {"illegal-dots4.tgz", "./../../malformed-helm-test", "chart illegally references parent directory"},
+ {"illegal-name.tgz", "./.", "chart illegally contains content outside the base directory"},
+ {"illegal-name2.tgz", "/./.", "chart illegally contains content outside the base directory"},
+ {"illegal-name3.tgz", "missing-leading-slash", "chart illegally contains content outside the base directory"},
+ {"illegal-name4.tgz", "/missing-leading-slash", "Chart.yaml file is missing"},
+ {"illegal-abspath.tgz", "//foo", "chart illegally contains absolute paths"},
+ {"illegal-abspath2.tgz", "///foo", "chart illegally contains absolute paths"},
+ {"illegal-abspath3.tgz", "\\\\foo", "chart illegally contains absolute paths"},
+ {"illegal-abspath3.tgz", "\\..\\..\\foo", "chart illegally references parent directory"},
+
+ // Under special circumstances, this can get normalized to things that look like absolute Windows paths
+ {"illegal-abspath4.tgz", "\\.\\c:\\\\foo", "chart contains illegally named files"},
+ {"illegal-abspath5.tgz", "/./c://foo", "chart contains illegally named files"},
+ {"illegal-abspath6.tgz", "\\\\?\\Some\\windows\\magic", "chart illegally contains absolute paths"},
+ } {
+ illegalChart := filepath.Join(tmpdir, tt.chartname)
+ writeTar(illegalChart, tt.internal, []byte("hello: world"))
+ _, err := Load(illegalChart)
+ if err == nil {
+ t.Fatal("expected error when unpacking illegal files")
+ }
+ if !strings.Contains(err.Error(), tt.expectError) {
+ t.Errorf("Expected error to contain %q, got %q for %s", tt.expectError, err.Error(), tt.chartname)
+ }
+ }
+
+ // Make sure that absolute path gets interpreted as relative
+ illegalChart := filepath.Join(tmpdir, "abs-path.tgz")
+ writeTar(illegalChart, "/Chart.yaml", []byte("hello: world"))
+ _, err := Load(illegalChart)
+ if err.Error() != "validation: chart.metadata.name is required" {
+ t.Error(err)
+ }
+
+ // And just to validate that the above was not spurious
+ illegalChart = filepath.Join(tmpdir, "abs-path2.tgz")
+ writeTar(illegalChart, "files/whatever.yaml", []byte("hello: world"))
+ _, err = Load(illegalChart)
+ if err.Error() != "Chart.yaml file is missing" {
+ t.Errorf("Unexpected error message: %s", err)
+ }
+
+ // Finally, test that drive letter gets stripped off on Windows
+ illegalChart = filepath.Join(tmpdir, "abs-winpath.tgz")
+ writeTar(illegalChart, "c:\\Chart.yaml", []byte("hello: world"))
+ _, err = Load(illegalChart)
+ if err.Error() != "validation: chart.metadata.name is required" {
+ t.Error(err)
+ }
+}
+
+func TestLoadValues(t *testing.T) {
+ testCases := map[string]struct {
+ data []byte
+ expctedValues map[string]interface{}
+ }{
+ "It should load values correctly": {
+ data: []byte(`
+foo:
+ image: foo:v1
+bar:
+ version: v2
+`),
+ expctedValues: map[string]interface{}{
+ "foo": map[string]interface{}{
+ "image": "foo:v1",
+ },
+ "bar": map[string]interface{}{
+ "version": "v2",
+ },
+ },
+ },
+ "It should load values correctly with multiple documents in one file": {
+ data: []byte(`
+foo:
+ image: foo:v1
+bar:
+ version: v2
+---
+foo:
+ image: foo:v2
+`),
+ expctedValues: map[string]interface{}{
+ "foo": map[string]interface{}{
+ "image": "foo:v2",
+ },
+ "bar": map[string]interface{}{
+ "version": "v2",
+ },
+ },
+ },
+ }
+ for testName, testCase := range testCases {
+ t.Run(testName, func(tt *testing.T) {
+ values, err := LoadValues(bytes.NewReader(testCase.data))
+ if err != nil {
+ tt.Fatal(err)
+ }
+ if !reflect.DeepEqual(values, testCase.expctedValues) {
+ tt.Errorf("Expected values: %v, got %v", testCase.expctedValues, values)
+ }
+ })
+ }
+}
+
+func TestMergeValues(t *testing.T) {
+ nestedMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "stuff",
+ },
+ }
+ anotherNestedMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "things",
+ "awesome": "stuff",
+ },
+ }
+ flatMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": "stuff",
+ }
+ anotherFlatMap := map[string]interface{}{
+ "testing": "fun",
+ }
+
+ testMap := MergeMaps(flatMap, nestedMap)
+ equal := reflect.DeepEqual(testMap, nestedMap)
+ if !equal {
+ t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap)
+ }
+
+ testMap = MergeMaps(nestedMap, flatMap)
+ equal = reflect.DeepEqual(testMap, flatMap)
+ if !equal {
+ t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap)
+ }
+
+ testMap = MergeMaps(nestedMap, anotherNestedMap)
+ equal = reflect.DeepEqual(testMap, anotherNestedMap)
+ if !equal {
+ t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap)
+ }
+
+ testMap = MergeMaps(anotherFlatMap, anotherNestedMap)
+ expectedMap := map[string]interface{}{
+ "testing": "fun",
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "things",
+ "awesome": "stuff",
+ },
+ }
+ equal = reflect.DeepEqual(testMap, expectedMap)
+ if !equal {
+ t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap)
+ }
+}
+
+func verifyChart(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ if c.Name() == "" {
+ t.Fatalf("No chart metadata found on %v", c)
+ }
+ t.Logf("Verifying chart %s", c.Name())
+ if len(c.Templates) != 1 {
+ t.Errorf("Expected 1 template, got %d", len(c.Templates))
+ }
+
+ numfiles := 6
+ if len(c.Files) != numfiles {
+ t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files))
+ for _, n := range c.Files {
+ t.Logf("\t%s", n.Name)
+ }
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Errorf("Expected 2 dependencies, got %d (%v)", len(c.Dependencies()), c.Dependencies())
+ for _, d := range c.Dependencies() {
+ t.Logf("\tSubchart: %s\n", d.Name())
+ }
+ }
+
+ expect := map[string]map[string]string{
+ "alpine": {
+ "version": "0.1.0",
+ },
+ "mariner": {
+ "version": "4.3.2",
+ },
+ }
+
+ for _, dep := range c.Dependencies() {
+ if dep.Metadata == nil {
+ t.Fatalf("expected metadata on dependency: %v", dep)
+ }
+ exp, ok := expect[dep.Name()]
+ if !ok {
+ t.Fatalf("Unknown dependency %s", dep.Name())
+ }
+ if exp["version"] != dep.Metadata.Version {
+ t.Errorf("Expected %s version %s, got %s", dep.Name(), exp["version"], dep.Metadata.Version)
+ }
+ }
+
+}
+
+func verifyDependencies(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ if len(c.Metadata.Dependencies) != 2 {
+ t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies))
+ }
+ tests := []*chart.Dependency{
+ {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"},
+ {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"},
+ }
+ for i, tt := range tests {
+ d := c.Metadata.Dependencies[i]
+ if d.Name != tt.Name {
+ t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name)
+ }
+ if d.Version != tt.Version {
+ t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version)
+ }
+ if d.Repository != tt.Repository {
+ t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository)
+ }
+ }
+}
+
+func verifyDependenciesLock(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ if len(c.Metadata.Dependencies) != 2 {
+ t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies))
+ }
+ tests := []*chart.Dependency{
+ {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"},
+ {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"},
+ }
+ for i, tt := range tests {
+ d := c.Metadata.Dependencies[i]
+ if d.Name != tt.Name {
+ t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name)
+ }
+ if d.Version != tt.Version {
+ t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version)
+ }
+ if d.Repository != tt.Repository {
+ t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository)
+ }
+ }
+}
+
+func verifyFrobnitz(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ verifyChartFileAndTemplate(t, c, "frobnitz")
+}
+
+func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) {
+ t.Helper()
+ if c.Metadata == nil {
+ t.Fatal("Metadata is nil")
+ }
+ if c.Name() != name {
+ t.Errorf("Expected %s, got %s", name, c.Name())
+ }
+ if len(c.Templates) != 1 {
+ t.Fatalf("Expected 1 template, got %d", len(c.Templates))
+ }
+ if c.Templates[0].Name != "templates/template.tpl" {
+ t.Errorf("Unexpected template: %s", c.Templates[0].Name)
+ }
+ if len(c.Templates[0].Data) == 0 {
+ t.Error("No template data.")
+ }
+ if len(c.Files) != 6 {
+ t.Fatalf("Expected 6 Files, got %d", len(c.Files))
+ }
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("Expected 2 Dependency, got %d", len(c.Dependencies()))
+ }
+ if len(c.Metadata.Dependencies) != 2 {
+ t.Fatalf("Expected 2 Dependencies.Dependency, got %d", len(c.Metadata.Dependencies))
+ }
+ if len(c.Lock.Dependencies) != 2 {
+ t.Fatalf("Expected 2 Lock.Dependency, got %d", len(c.Lock.Dependencies))
+ }
+
+ for _, dep := range c.Dependencies() {
+ switch dep.Name() {
+ case "mariner":
+ case "alpine":
+ if len(dep.Templates) != 1 {
+ t.Fatalf("Expected 1 template, got %d", len(dep.Templates))
+ }
+ if dep.Templates[0].Name != "templates/alpine-pod.yaml" {
+ t.Errorf("Unexpected template: %s", dep.Templates[0].Name)
+ }
+ if len(dep.Templates[0].Data) == 0 {
+ t.Error("No template data.")
+ }
+ if len(dep.Files) != 1 {
+ t.Fatalf("Expected 1 Files, got %d", len(dep.Files))
+ }
+ if len(dep.Dependencies()) != 2 {
+ t.Fatalf("Expected 2 Dependency, got %d", len(dep.Dependencies()))
+ }
+ default:
+ t.Errorf("Unexpected dependency %s", dep.Name())
+ }
+ }
+}
+
+func verifyBomStripped(t *testing.T, files []*chart.File) {
+ t.Helper()
+ for _, file := range files {
+ if bytes.HasPrefix(file.Data, utf8bom) {
+ t.Errorf("Byte Order Mark still present in processed file %s", file.Name)
+ }
+ }
+}
diff --git a/pkg/chart/loader/testdata/LICENSE b/internal/chart/v3/loader/testdata/LICENSE
similarity index 100%
rename from pkg/chart/loader/testdata/LICENSE
rename to internal/chart/v3/loader/testdata/LICENSE
diff --git a/pkg/chart/loader/testdata/albatross/Chart.yaml b/internal/chart/v3/loader/testdata/albatross/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/albatross/Chart.yaml
rename to internal/chart/v3/loader/testdata/albatross/Chart.yaml
diff --git a/pkg/chart/loader/testdata/albatross/values.yaml b/internal/chart/v3/loader/testdata/albatross/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/albatross/values.yaml
rename to internal/chart/v3/loader/testdata/albatross/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz-1.2.3.tgz b/internal/chart/v3/loader/testdata/frobnitz-1.2.3.tgz
new file mode 100644
index 000000000..de28e4120
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz-1.2.3.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/.helmignore b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/.helmignore
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/.helmignore
diff --git a/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml
new file mode 100644
index 000000000..1b63fc3e2
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/INSTALL.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/LICENSE b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/LICENSE
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/LICENSE
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/README.md b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/README.md
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/_ignore_me
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/docs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/icon.svg b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/icon.svg
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/ignore/me.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/templates/template.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz.v3.reqs/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz.v3.reqs/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore b/internal/chart/v3/loader/testdata/frobnitz/.helmignore
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/Chart.lock b/internal/chart/v3/loader/testdata/frobnitz/Chart.lock
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/Chart.lock
rename to internal/chart/v3/loader/testdata/frobnitz/Chart.lock
diff --git a/internal/chart/v3/loader/testdata/frobnitz/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz/Chart.yaml
new file mode 100644
index 000000000..1b63fc3e2
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz/INSTALL.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE b/internal/chart/v3/loader/testdata/frobnitz/LICENSE
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/LICENSE
rename to internal/chart/v3/loader/testdata/frobnitz/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md b/internal/chart/v3/loader/testdata/frobnitz/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz/charts/_ignore_me
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz/charts/alpine/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..5c6bc4dcb
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz/docs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg b/internal/chart/v3/loader/testdata/frobnitz/icon.svg
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz/ignore/me.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz/templates/template.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml b/internal/chart/v3/loader/testdata/frobnitz/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_backslash-1.2.3.tgz b/internal/chart/v3/loader/testdata/frobnitz_backslash-1.2.3.tgz
new file mode 100644
index 000000000..dfbe88a73
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_backslash-1.2.3.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz/.helmignore b/internal/chart/v3/loader/testdata/frobnitz_backslash/.helmignore
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz/Chart.lock b/internal/chart/v3/loader/testdata/frobnitz_backslash/Chart.lock
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/Chart.lock
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/Chart.lock
diff --git a/internal/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml
new file mode 100755
index 000000000..6a952e333
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_backslash/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz_backslash
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz_backslash/INSTALL.txt
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz/LICENSE b/internal/chart/v3/loader/testdata/frobnitz_backslash/LICENSE
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/LICENSE
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz/README.md b/internal/chart/v3/loader/testdata/frobnitz_backslash/README.md
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/_ignore_me
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml
new file mode 100755
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/README.md
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml
new file mode 100755
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz
new file mode 100755
index 000000000..5c6bc4dcb
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz_backslash/docs/README.md
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz/icon.svg b/internal/chart/v3/loader/testdata/frobnitz_backslash/icon.svg
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz_backslash/ignore/me.txt
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz_backslash/templates/template.tpl
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_backslash/values.yaml
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_backslash/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_bom.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_bom.tgz
new file mode 100644
index 000000000..7f0edc6b2
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_with_bom.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore b/internal/chart/v3/loader/testdata/frobnitz_with_bom/.helmignore
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock b/internal/chart/v3/loader/testdata/frobnitz_with_bom/Chart.lock
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/Chart.lock
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/Chart.lock
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml
new file mode 100644
index 000000000..924fae6fc
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_bom/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz_with_bom/INSTALL.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE b/internal/chart/v3/loader/testdata/frobnitz_with_bom/LICENSE
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/LICENSE
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_bom/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/_ignore_me
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..6fe4f411f
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..0732c7d7d
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..5c6bc4dcb
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_bom/docs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/icon.svg b/internal/chart/v3/loader/testdata/frobnitz_with_bom/icon.svg
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz_with_bom/ignore/me.txt
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz_with_bom/templates/template.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_bom/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_bom/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/.helmignore b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/.helmignore
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.lock
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/Chart.lock
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.lock
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.yaml
new file mode 100644
index 000000000..1b63fc3e2
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/INSTALL.txt
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/LICENSE b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/LICENSE
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/LICENSE
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/README.md
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..5c6bc4dcb
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/docs/README.md
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/icon.svg
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/ignore/me.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/null b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/null
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/null
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/null
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/templates/template.tpl
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_dev_null/values.yaml
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_dev_null/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/.helmignore
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/.helmignore
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.lock
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.lock
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.lock
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.yaml
new file mode 100644
index 000000000..1b63fc3e2
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/INSTALL.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/INSTALL.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/_ignore_me
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/_ignore_me
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml
diff --git a/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..5c6bc4dcb
Binary files /dev/null and b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/docs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/docs/README.md
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/icon.svg
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/icon.svg
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/ignore/me.txt b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/ignore/me.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/ignore/me.txt
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/templates/template.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/templates/template.tpl
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml b/internal/chart/v3/loader/testdata/frobnitz_with_symlink/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/values.yaml
rename to internal/chart/v3/loader/testdata/frobnitz_with_symlink/values.yaml
diff --git a/internal/chart/v3/loader/testdata/genfrob.sh b/internal/chart/v3/loader/testdata/genfrob.sh
new file mode 100755
index 000000000..eae68906b
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/genfrob.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+# Pack the albatross chart into the mariner chart.
+echo "Packing albatross into mariner"
+tar -zcvf mariner/charts/albatross-0.1.0.tgz albatross
+
+echo "Packing mariner into frobnitz"
+tar -zcvf frobnitz/charts/mariner-4.3.2.tgz mariner
+cp frobnitz/charts/mariner-4.3.2.tgz frobnitz_backslash/charts/
+cp frobnitz/charts/mariner-4.3.2.tgz frobnitz_with_bom/charts/
+cp frobnitz/charts/mariner-4.3.2.tgz frobnitz_with_dev_null/charts/
+cp frobnitz/charts/mariner-4.3.2.tgz frobnitz_with_symlink/charts/
+
+# Pack the frobnitz chart.
+echo "Packing frobnitz"
+tar --exclude=ignore/* -zcvf frobnitz-1.2.3.tgz frobnitz
+tar --exclude=ignore/* -zcvf frobnitz_backslash-1.2.3.tgz frobnitz_backslash
+tar --exclude=ignore/* -zcvf frobnitz_with_bom.tgz frobnitz_with_bom
diff --git a/internal/chart/v3/loader/testdata/mariner/Chart.yaml b/internal/chart/v3/loader/testdata/mariner/Chart.yaml
new file mode 100644
index 000000000..4d3eea730
--- /dev/null
+++ b/internal/chart/v3/loader/testdata/mariner/Chart.yaml
@@ -0,0 +1,9 @@
+apiVersion: v3
+name: mariner
+description: A Helm chart for Kubernetes
+version: 4.3.2
+home: ""
+dependencies:
+ - name: albatross
+ repository: https://example.com/mariner/charts
+ version: "0.1.0"
diff --git a/internal/chart/v3/loader/testdata/mariner/charts/albatross-0.1.0.tgz b/internal/chart/v3/loader/testdata/mariner/charts/albatross-0.1.0.tgz
new file mode 100644
index 000000000..ec7bfbfcf
Binary files /dev/null and b/internal/chart/v3/loader/testdata/mariner/charts/albatross-0.1.0.tgz differ
diff --git a/pkg/chart/loader/testdata/mariner/templates/placeholder.tpl b/internal/chart/v3/loader/testdata/mariner/templates/placeholder.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/mariner/templates/placeholder.tpl
rename to internal/chart/v3/loader/testdata/mariner/templates/placeholder.tpl
diff --git a/pkg/chart/loader/testdata/mariner/values.yaml b/internal/chart/v3/loader/testdata/mariner/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/mariner/values.yaml
rename to internal/chart/v3/loader/testdata/mariner/values.yaml
diff --git a/pkg/chart/metadata.go b/internal/chart/v3/metadata.go
similarity index 92%
rename from pkg/chart/metadata.go
rename to internal/chart/v3/metadata.go
index ae572abb7..4629d571b 100644
--- a/pkg/chart/metadata.go
+++ b/internal/chart/v3/metadata.go
@@ -13,9 +13,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v3
import (
+ "path/filepath"
"strings"
"unicode"
@@ -110,6 +111,11 @@ func (md *Metadata) Validate() error {
if md.Name == "" {
return ValidationError("chart.metadata.name is required")
}
+
+ if md.Name != filepath.Base(md.Name) {
+ return ValidationErrorf("chart.metadata.name %q is invalid", md.Name)
+ }
+
if md.Version == "" {
return ValidationError("chart.metadata.version is required")
}
@@ -128,10 +134,19 @@ func (md *Metadata) Validate() error {
// Aliases need to be validated here to make sure that the alias name does
// not contain any illegal characters.
+ dependencies := map[string]*Dependency{}
for _, dependency := range md.Dependencies {
if err := dependency.Validate(); err != nil {
return err
}
+ key := dependency.Name
+ if dependency.Alias != "" {
+ key = dependency.Alias
+ }
+ if dependencies[key] != nil {
+ return ValidationErrorf("more than one dependency with name or alias %q", key)
+ }
+ dependencies[key] = dependency
}
return nil
}
diff --git a/internal/chart/v3/metadata_test.go b/internal/chart/v3/metadata_test.go
new file mode 100644
index 000000000..596a03695
--- /dev/null
+++ b/internal/chart/v3/metadata_test.go
@@ -0,0 +1,201 @@
+/*
+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 v3
+
+import (
+ "testing"
+)
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ md *Metadata
+ err error
+ }{
+ {
+ "chart without metadata",
+ nil,
+ ValidationError("chart.metadata is required"),
+ },
+ {
+ "chart without apiVersion",
+ &Metadata{Name: "test", Version: "1.0"},
+ ValidationError("chart.metadata.apiVersion is required"),
+ },
+ {
+ "chart without name",
+ &Metadata{APIVersion: "v3", Version: "1.0"},
+ ValidationError("chart.metadata.name is required"),
+ },
+ {
+ "chart without name",
+ &Metadata{Name: "../../test", APIVersion: "v3", Version: "1.0"},
+ ValidationError("chart.metadata.name \"../../test\" is invalid"),
+ },
+ {
+ "chart without version",
+ &Metadata{Name: "test", APIVersion: "v3"},
+ ValidationError("chart.metadata.version is required"),
+ },
+ {
+ "chart with bad type",
+ &Metadata{Name: "test", APIVersion: "v3", Version: "1.0", Type: "test"},
+ ValidationError("chart.metadata.type must be application or library"),
+ },
+ {
+ "chart without dependency",
+ &Metadata{Name: "test", APIVersion: "v3", Version: "1.0", Type: "application"},
+ nil,
+ },
+ {
+ "dependency with valid alias",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "dependency", Alias: "legal-alias"},
+ },
+ },
+ nil,
+ },
+ {
+ "dependency with bad characters in alias",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "bad", Alias: "illegal alias"},
+ },
+ },
+ ValidationError("dependency \"bad\" has disallowed characters in the alias"),
+ },
+ {
+ "same dependency twice",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: ""},
+ {Name: "foo", Alias: ""},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ "two dependencies with alias from second dependency shadowing first one",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: ""},
+ {Name: "bar", Alias: "foo"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ // this case would make sense and could work in future versions of Helm, currently template rendering would
+ // result in undefined behaviour
+ "same dependency twice with different version",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: "", Version: "1.2.3"},
+ {Name: "foo", Alias: "", Version: "1.0.0"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ // this case would make sense and could work in future versions of Helm, currently template rendering would
+ // result in undefined behaviour
+ "two dependencies with same name but different repos",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Repository: "repo-0"},
+ {Name: "foo", Repository: "repo-1"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ "dependencies has nil",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ nil,
+ },
+ },
+ ValidationError("dependencies must not contain empty or null nodes"),
+ },
+ {
+ "maintainer not empty",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v3",
+ Version: "1.0",
+ Type: "application",
+ Maintainers: []*Maintainer{
+ nil,
+ },
+ },
+ ValidationError("maintainers must not contain empty or null nodes"),
+ },
+ {
+ "version invalid",
+ &Metadata{APIVersion: "3", Name: "test", Version: "1.2.3.4"},
+ ValidationError("chart.metadata.version \"1.2.3.4\" is invalid"),
+ },
+ }
+
+ for _, tt := range tests {
+ result := tt.md.Validate()
+ if result != tt.err {
+ t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name)
+ }
+ }
+}
+
+func TestValidate_sanitize(t *testing.T) {
+ md := &Metadata{APIVersion: "3", Name: "test", Version: "1.0", Description: "\adescr\u0081iption\rtest", Maintainers: []*Maintainer{{Name: "\r"}}}
+ if err := md.Validate(); err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ if md.Description != "description test" {
+ t.Fatalf("description was not sanitized: %q", md.Description)
+ }
+ if md.Maintainers[0].Name != " " {
+ t.Fatal("maintainer name was not sanitized")
+ }
+}
diff --git a/pkg/chartutil/capabilities.go b/internal/chart/v3/util/capabilities.go
similarity index 94%
rename from pkg/chartutil/capabilities.go
rename to internal/chart/v3/util/capabilities.go
index 5f57e11a5..23b6d46fa 100644
--- a/pkg/chartutil/capabilities.go
+++ b/internal/chart/v3/util/capabilities.go
@@ -13,10 +13,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"fmt"
+ "slices"
"strconv"
"github.com/Masterminds/semver/v3"
@@ -25,7 +26,7 @@ import (
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
- helmversion "helm.sh/helm/v3/internal/version"
+ helmversion "helm.sh/helm/v4/internal/version"
)
var (
@@ -53,7 +54,7 @@ var (
type Capabilities struct {
// KubeVersion is the Kubernetes version.
KubeVersion KubeVersion
- // APIversions are supported Kubernetes API versions.
+ // APIVersions are supported Kubernetes API versions.
APIVersions VersionSet
// HelmVersion is the build information for this helm version
HelmVersion helmversion.BuildInfo
@@ -102,12 +103,7 @@ type VersionSet []string
//
// vs.Has("apps/v1")
func (v VersionSet) Has(apiVersion string) bool {
- for _, x := range v {
- if x == apiVersion {
- return true
- }
- }
- return false
+ return slices.Contains(v, apiVersion)
}
func allKnownVersions() VersionSet {
diff --git a/pkg/chartutil/capabilities_test.go b/internal/chart/v3/util/capabilities_test.go
similarity index 94%
rename from pkg/chartutil/capabilities_test.go
rename to internal/chart/v3/util/capabilities_test.go
index af56907eb..aa9be9db8 100644
--- a/pkg/chartutil/capabilities_test.go
+++ b/internal/chart/v3/util/capabilities_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"testing"
@@ -62,8 +62,8 @@ func TestDefaultCapabilities(t *testing.T) {
func TestDefaultCapabilitiesHelmVersion(t *testing.T) {
hv := DefaultCapabilities.HelmVersion
- if hv.Version != "v3.13" {
- t.Errorf("Expected default HelmVersion to be v3.13, got %q", hv.Version)
+ if hv.Version != "v4.0" {
+ t.Errorf("Expected default HelmVersion to be v4.0, got %q", hv.Version)
}
}
diff --git a/internal/chart/v3/util/chartfile.go b/internal/chart/v3/util/chartfile.go
new file mode 100644
index 000000000..25271e1cf
--- /dev/null
+++ b/internal/chart/v3/util/chartfile.go
@@ -0,0 +1,96 @@
+/*
+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 util
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// LoadChartfile loads a Chart.yaml file into a *chart.Metadata.
+func LoadChartfile(filename string) (*chart.Metadata, error) {
+ b, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, err
+ }
+ y := new(chart.Metadata)
+ err = yaml.Unmarshal(b, y)
+ return y, err
+}
+
+// StrictLoadChartfile loads a Chart.yaml into a *chart.Metadata using a strict unmarshaling
+func StrictLoadChartfile(filename string) (*chart.Metadata, error) {
+ b, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, err
+ }
+ y := new(chart.Metadata)
+ err = yaml.UnmarshalStrict(b, y)
+ return y, err
+}
+
+// SaveChartfile saves the given metadata as a Chart.yaml file at the given path.
+//
+// 'filename' should be the complete path and filename ('foo/Chart.yaml')
+func SaveChartfile(filename string, cf *chart.Metadata) error {
+ out, err := yaml.Marshal(cf)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filename, out, 0644)
+}
+
+// IsChartDir validate a chart directory.
+//
+// Checks for a valid Chart.yaml.
+func IsChartDir(dirName string) (bool, error) {
+ if fi, err := os.Stat(dirName); err != nil {
+ return false, err
+ } else if !fi.IsDir() {
+ return false, fmt.Errorf("%q is not a directory", dirName)
+ }
+
+ chartYaml := filepath.Join(dirName, ChartfileName)
+ if _, err := os.Stat(chartYaml); errors.Is(err, fs.ErrNotExist) {
+ return false, fmt.Errorf("no %s exists in directory %q", ChartfileName, dirName)
+ }
+
+ chartYamlContent, err := os.ReadFile(chartYaml)
+ if err != nil {
+ return false, fmt.Errorf("cannot read %s in directory %q", ChartfileName, dirName)
+ }
+
+ chartContent := new(chart.Metadata)
+ if err := yaml.Unmarshal(chartYamlContent, &chartContent); err != nil {
+ return false, err
+ }
+ if chartContent == nil {
+ return false, fmt.Errorf("chart metadata (%s) missing", ChartfileName)
+ }
+ if chartContent.Name == "" {
+ return false, fmt.Errorf("invalid chart (%s): name must not be empty", ChartfileName)
+ }
+
+ return true, nil
+}
diff --git a/internal/chart/v3/util/chartfile_test.go b/internal/chart/v3/util/chartfile_test.go
new file mode 100644
index 000000000..c3d19c381
--- /dev/null
+++ b/internal/chart/v3/util/chartfile_test.go
@@ -0,0 +1,117 @@
+/*
+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 util
+
+import (
+ "testing"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+const testfile = "testdata/chartfiletest.yaml"
+
+func TestLoadChartfile(t *testing.T) {
+ f, err := LoadChartfile(testfile)
+ if err != nil {
+ t.Errorf("Failed to open %s: %s", testfile, err)
+ return
+ }
+ verifyChartfile(t, f, "frobnitz")
+}
+
+func verifyChartfile(t *testing.T, f *chart.Metadata, name string) {
+ t.Helper()
+ if f == nil { //nolint:staticcheck
+ t.Fatal("Failed verifyChartfile because f is nil")
+ }
+
+ if f.Name != name {
+ t.Errorf("Expected %s, got %s", name, f.Name)
+ }
+
+ if f.Description != "This is a frobnitz." {
+ t.Errorf("Unexpected description %q", f.Description)
+ }
+
+ if f.Version != "1.2.3" {
+ t.Errorf("Unexpected version %q", f.Version)
+ }
+
+ if len(f.Maintainers) != 2 {
+ t.Errorf("Expected 2 maintainers, got %d", len(f.Maintainers))
+ }
+
+ if f.Maintainers[0].Name != "The Helm Team" {
+ t.Errorf("Unexpected maintainer name.")
+ }
+
+ if f.Maintainers[1].Email != "nobody@example.com" {
+ t.Errorf("Unexpected maintainer email.")
+ }
+
+ if len(f.Sources) != 1 {
+ t.Fatalf("Unexpected number of sources")
+ }
+
+ if f.Sources[0] != "https://example.com/foo/bar" {
+ t.Errorf("Expected https://example.com/foo/bar, got %s", f.Sources)
+ }
+
+ if f.Home != "http://example.com" {
+ t.Error("Unexpected home.")
+ }
+
+ if f.Icon != "https://example.com/64x64.png" {
+ t.Errorf("Unexpected icon: %q", f.Icon)
+ }
+
+ if len(f.Keywords) != 3 {
+ t.Error("Unexpected keywords")
+ }
+
+ if len(f.Annotations) != 2 {
+ t.Fatalf("Unexpected annotations")
+ }
+
+ if want, got := "extravalue", f.Annotations["extrakey"]; want != got {
+ t.Errorf("Want %q, but got %q", want, got)
+ }
+
+ if want, got := "anothervalue", f.Annotations["anotherkey"]; want != got {
+ t.Errorf("Want %q, but got %q", want, got)
+ }
+
+ kk := []string{"frobnitz", "sprocket", "dodad"}
+ for i, k := range f.Keywords {
+ if kk[i] != k {
+ t.Errorf("Expected %q, got %q", kk[i], k)
+ }
+ }
+}
+
+func TestIsChartDir(t *testing.T) {
+ validChartDir, err := IsChartDir("testdata/frobnitz")
+ if !validChartDir {
+ t.Errorf("unexpected error while reading chart-directory: (%v)", err)
+ return
+ }
+ validChartDir, err = IsChartDir("testdata")
+ if validChartDir || err == nil {
+ t.Errorf("expected error but did not get any")
+ return
+ }
+}
diff --git a/internal/chart/v3/util/coalesce.go b/internal/chart/v3/util/coalesce.go
new file mode 100644
index 000000000..caea2e119
--- /dev/null
+++ b/internal/chart/v3/util/coalesce.go
@@ -0,0 +1,308 @@
+/*
+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 util
+
+import (
+ "fmt"
+ "log"
+ "maps"
+
+ "github.com/mitchellh/copystructure"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+func concatPrefix(a, b string) string {
+ if a == "" {
+ return b
+ }
+ return fmt.Sprintf("%s.%s", a, b)
+}
+
+// CoalesceValues coalesces all of the values in a chart (and its subcharts).
+//
+// Values are coalesced together using the following rules:
+//
+// - Values in a higher level chart always override values in a lower-level
+// dependency chart
+// - Scalar values and arrays are replaced, maps are merged
+// - A chart has access to all of the variables for it, as well as all of
+// the values destined for its dependencies.
+func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) {
+ valsCopy, err := copyValues(vals)
+ if err != nil {
+ return vals, err
+ }
+ return coalesce(log.Printf, chrt, valsCopy, "", false)
+}
+
+// MergeValues is used to merge the values in a chart and its subcharts. This
+// is different from Coalescing as nil/null values are preserved.
+//
+// Values are coalesced together using the following rules:
+//
+// - Values in a higher level chart always override values in a lower-level
+// dependency chart
+// - Scalar values and arrays are replaced, maps are merged
+// - A chart has access to all of the variables for it, as well as all of
+// the values destined for its dependencies.
+//
+// Retaining Nils is useful when processes early in a Helm action or business
+// logic need to retain them for when Coalescing will happen again later in the
+// business logic.
+func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) {
+ valsCopy, err := copyValues(vals)
+ if err != nil {
+ return vals, err
+ }
+ return coalesce(log.Printf, chrt, valsCopy, "", true)
+}
+
+func copyValues(vals map[string]interface{}) (Values, error) {
+ v, err := copystructure.Copy(vals)
+ if err != nil {
+ return vals, err
+ }
+
+ valsCopy := v.(map[string]interface{})
+ // if we have an empty map, make sure it is initialized
+ if valsCopy == nil {
+ valsCopy = make(map[string]interface{})
+ }
+
+ return valsCopy, nil
+}
+
+type printFn func(format string, v ...interface{})
+
+// coalesce coalesces the dest values and the chart values, giving priority to the dest values.
+//
+// This is a helper function for CoalesceValues and MergeValues.
+//
+// Note, the merge argument specifies whether this is being used by MergeValues
+// or CoalesceValues. Coalescing removes null values and their keys in some
+// situations while merging keeps the null values.
+func coalesce(printf printFn, ch *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) {
+ coalesceValues(printf, ch, dest, prefix, merge)
+ return coalesceDeps(printf, ch, dest, prefix, merge)
+}
+
+// coalesceDeps coalesces the dependencies of the given chart.
+func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) {
+ for _, subchart := range chrt.Dependencies() {
+ if c, ok := dest[subchart.Name()]; !ok {
+ // If dest doesn't already have the key, create it.
+ dest[subchart.Name()] = make(map[string]interface{})
+ } else if !istable(c) {
+ return dest, fmt.Errorf("type mismatch on %s: %t", subchart.Name(), c)
+ }
+ if dv, ok := dest[subchart.Name()]; ok {
+ dvmap := dv.(map[string]interface{})
+ subPrefix := concatPrefix(prefix, chrt.Metadata.Name)
+ // Get globals out of dest and merge them into dvmap.
+ coalesceGlobals(printf, dvmap, dest, subPrefix, merge)
+ // Now coalesce the rest of the values.
+ var err error
+ dest[subchart.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge)
+ if err != nil {
+ return dest, err
+ }
+ }
+ }
+ return dest, nil
+}
+
+// coalesceGlobals copies the globals out of src and merges them into dest.
+//
+// For convenience, returns dest.
+func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) {
+ var dg, sg map[string]interface{}
+
+ if destglob, ok := dest[GlobalKey]; !ok {
+ dg = make(map[string]interface{})
+ } else if dg, ok = destglob.(map[string]interface{}); !ok {
+ printf("warning: skipping globals because destination %s is not a table.", GlobalKey)
+ return
+ }
+
+ if srcglob, ok := src[GlobalKey]; !ok {
+ sg = make(map[string]interface{})
+ } else if sg, ok = srcglob.(map[string]interface{}); !ok {
+ printf("warning: skipping globals because source %s is not a table.", GlobalKey)
+ return
+ }
+
+ // EXPERIMENTAL: In the past, we have disallowed globals to test tables. This
+ // reverses that decision. It may somehow be possible to introduce a loop
+ // here, but I haven't found a way. So for the time being, let's allow
+ // tables in globals.
+ for key, val := range sg {
+ if istable(val) {
+ vv := copyMap(val.(map[string]interface{}))
+ if destv, ok := dg[key]; !ok {
+ // Here there is no merge. We're just adding.
+ dg[key] = vv
+ } else {
+ if destvmap, ok := destv.(map[string]interface{}); !ok {
+ printf("Conflict: cannot merge map onto non-map for %q. Skipping.", key)
+ } else {
+ // Basically, we reverse order of coalesce here to merge
+ // top-down.
+ subPrefix := concatPrefix(prefix, key)
+ // In this location coalesceTablesFullKey should always have
+ // merge set to true. The output of coalesceGlobals is run
+ // through coalesce where any nils will be removed.
+ coalesceTablesFullKey(printf, vv, destvmap, subPrefix, true)
+ dg[key] = vv
+ }
+ }
+ } else if dv, ok := dg[key]; ok && istable(dv) {
+ // It's not clear if this condition can actually ever trigger.
+ printf("key %s is table. Skipping", key)
+ } else {
+ // TODO: Do we need to do any additional checking on the value?
+ dg[key] = val
+ }
+ }
+ dest[GlobalKey] = dg
+}
+
+func copyMap(src map[string]interface{}) map[string]interface{} {
+ m := make(map[string]interface{}, len(src))
+ maps.Copy(m, src)
+ return m
+}
+
+// coalesceValues builds up a values map for a particular chart.
+//
+// Values in v will override the values in the chart.
+func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, prefix string, merge bool) {
+ subPrefix := concatPrefix(prefix, c.Metadata.Name)
+
+ // Using c.Values directly when coalescing a table can cause problems where
+ // the original c.Values is altered. Creating a deep copy stops the problem.
+ // This section is fault-tolerant as there is no ability to return an error.
+ valuesCopy, err := copystructure.Copy(c.Values)
+ var vc map[string]interface{}
+ var ok bool
+ if err != nil {
+ // If there is an error something is wrong with copying c.Values it
+ // means there is a problem in the deep copying package or something
+ // wrong with c.Values. In this case we will use c.Values and report
+ // an error.
+ printf("warning: unable to copy values, err: %s", err)
+ vc = c.Values
+ } else {
+ vc, ok = valuesCopy.(map[string]interface{})
+ if !ok {
+ // c.Values has a map[string]interface{} structure. If the copy of
+ // it cannot be treated as map[string]interface{} there is something
+ // strangely wrong. Log it and use c.Values
+ printf("warning: unable to convert values copy to values type")
+ vc = c.Values
+ }
+ }
+
+ for key, val := range vc {
+ if value, ok := v[key]; ok {
+ if value == nil && !merge {
+ // When the YAML value is null and we are coalescing instead of
+ // merging, we remove the value's key.
+ // This allows Helm's various sources of values (value files or --set) to
+ // remove incompatible keys from any previous chart, file, or set values.
+ delete(v, key)
+ } else if dest, ok := value.(map[string]interface{}); ok {
+ // if v[key] is a table, merge nv's val table into v[key].
+ src, ok := val.(map[string]interface{})
+ if !ok {
+ // If the original value is nil, there is nothing to coalesce, so we don't print
+ // the warning
+ if val != nil {
+ printf("warning: skipped value for %s.%s: Not a table.", subPrefix, key)
+ }
+ } else {
+ // If the key is a child chart, coalesce tables with Merge set to true
+ merge := childChartMergeTrue(c, key, merge)
+
+ // Because v has higher precedence than nv, dest values override src
+ // values.
+ coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge)
+ }
+ }
+ } else {
+ // If the key is not in v, copy it from nv.
+ v[key] = val
+ }
+ }
+}
+
+func childChartMergeTrue(chrt *chart.Chart, key string, merge bool) bool {
+ for _, subchart := range chrt.Dependencies() {
+ if subchart.Name() == key {
+ return true
+ }
+ }
+ return merge
+}
+
+// CoalesceTables merges a source map into a destination map.
+//
+// dest is considered authoritative.
+func CoalesceTables(dst, src map[string]interface{}) map[string]interface{} {
+ return coalesceTablesFullKey(log.Printf, dst, src, "", false)
+}
+
+func MergeTables(dst, src map[string]interface{}) map[string]interface{} {
+ return coalesceTablesFullKey(log.Printf, dst, src, "", true)
+}
+
+// coalesceTablesFullKey merges a source map into a destination map.
+//
+// dest is considered authoritative.
+func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, prefix string, merge bool) map[string]interface{} {
+ // When --reuse-values is set but there are no modifications yet, return new values
+ if src == nil {
+ return dst
+ }
+ if dst == nil {
+ return src
+ }
+ for key, val := range dst {
+ if val == nil {
+ src[key] = nil
+ }
+ }
+ // Because dest has higher precedence than src, dest values override src
+ // values.
+ for key, val := range src {
+ fullkey := concatPrefix(prefix, key)
+ if dv, ok := dst[key]; ok && !merge && dv == nil {
+ delete(dst, key)
+ } else if !ok {
+ dst[key] = val
+ } else if istable(val) {
+ if istable(dv) {
+ coalesceTablesFullKey(printf, dv.(map[string]interface{}), val.(map[string]interface{}), fullkey, merge)
+ } else {
+ printf("warning: cannot overwrite table with non table for %s (%v)", fullkey, val)
+ }
+ } else if istable(dv) && val != nil {
+ printf("warning: destination for %s is a table. Ignoring non-table value (%v)", fullkey, val)
+ }
+ }
+ return dst
+}
diff --git a/internal/chart/v3/util/coalesce_test.go b/internal/chart/v3/util/coalesce_test.go
new file mode 100644
index 000000000..4770b601d
--- /dev/null
+++ b/internal/chart/v3/util/coalesce_test.go
@@ -0,0 +1,723 @@
+/*
+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 util
+
+import (
+ "encoding/json"
+ "fmt"
+ "maps"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// ref: http://www.yaml.org/spec/1.2/spec.html#id2803362
+var testCoalesceValuesYaml = []byte(`
+top: yup
+bottom: null
+right: Null
+left: NULL
+front: ~
+back: ""
+nested:
+ boat: null
+
+global:
+ name: Ishmael
+ subject: Queequeg
+ nested:
+ boat: true
+
+pequod:
+ boat: null
+ global:
+ name: Stinky
+ harpooner: Tashtego
+ nested:
+ boat: false
+ sail: true
+ foo2: null
+ ahab:
+ scope: whale
+ boat: null
+ nested:
+ foo: true
+ boat: null
+ object: null
+`)
+
+func withDeps(c *chart.Chart, deps ...*chart.Chart) *chart.Chart {
+ c.AddDependency(deps...)
+ return c
+}
+
+func TestCoalesceValues(t *testing.T) {
+ is := assert.New(t)
+
+ c := withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "moby"},
+ Values: map[string]interface{}{
+ "back": "exists",
+ "bottom": "exists",
+ "front": "exists",
+ "left": "exists",
+ "name": "moby",
+ "nested": map[string]interface{}{"boat": true},
+ "override": "bad",
+ "right": "exists",
+ "scope": "moby",
+ "top": "nope",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l0": "moby"},
+ },
+ "pequod": map[string]interface{}{
+ "boat": "maybe",
+ "ahab": map[string]interface{}{
+ "boat": "maybe",
+ "nested": map[string]interface{}{"boat": "maybe"},
+ },
+ },
+ },
+ },
+ withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "pequod"},
+ Values: map[string]interface{}{
+ "name": "pequod",
+ "scope": "pequod",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l1": "pequod"},
+ },
+ "boat": false,
+ "ahab": map[string]interface{}{
+ "boat": false,
+ "nested": map[string]interface{}{"boat": false},
+ },
+ },
+ },
+ &chart.Chart{
+ Metadata: &chart.Metadata{Name: "ahab"},
+ Values: map[string]interface{}{
+ "global": map[string]interface{}{
+ "nested": map[string]interface{}{"foo": "bar", "foo2": "bar2"},
+ "nested2": map[string]interface{}{"l2": "ahab"},
+ },
+ "scope": "ahab",
+ "name": "ahab",
+ "boat": true,
+ "nested": map[string]interface{}{"foo": false, "boat": true},
+ "object": map[string]interface{}{"foo": "bar"},
+ },
+ },
+ ),
+ &chart.Chart{
+ Metadata: &chart.Metadata{Name: "spouter"},
+ Values: map[string]interface{}{
+ "scope": "spouter",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l1": "spouter"},
+ },
+ },
+ },
+ )
+
+ vals, err := ReadValues(testCoalesceValuesYaml)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // taking a copy of the values before passing it
+ // to CoalesceValues as argument, so that we can
+ // use it for asserting later
+ valsCopy := make(Values, len(vals))
+ maps.Copy(valsCopy, vals)
+
+ v, err := CoalesceValues(c, vals)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, _ := json.MarshalIndent(v, "", " ")
+ t.Logf("Coalesced Values: %s", string(j))
+
+ tests := []struct {
+ tpl string
+ expect string
+ }{
+ {"{{.top}}", "yup"},
+ {"{{.back}}", ""},
+ {"{{.name}}", "moby"},
+ {"{{.global.name}}", "Ishmael"},
+ {"{{.global.subject}}", "Queequeg"},
+ {"{{.global.harpooner}}", ""},
+ {"{{.pequod.name}}", "pequod"},
+ {"{{.pequod.ahab.name}}", "ahab"},
+ {"{{.pequod.ahab.scope}}", "whale"},
+ {"{{.pequod.ahab.nested.foo}}", "true"},
+ {"{{.pequod.ahab.global.name}}", "Ishmael"},
+ {"{{.pequod.ahab.global.nested.foo}}", "bar"},
+ {"{{.pequod.ahab.global.nested.foo2}}", ""},
+ {"{{.pequod.ahab.global.subject}}", "Queequeg"},
+ {"{{.pequod.ahab.global.harpooner}}", "Tashtego"},
+ {"{{.pequod.global.name}}", "Ishmael"},
+ {"{{.pequod.global.nested.foo}}", ""},
+ {"{{.pequod.global.subject}}", "Queequeg"},
+ {"{{.spouter.global.name}}", "Ishmael"},
+ {"{{.spouter.global.harpooner}}", ""},
+
+ {"{{.global.nested.boat}}", "true"},
+ {"{{.pequod.global.nested.boat}}", "true"},
+ {"{{.spouter.global.nested.boat}}", "true"},
+ {"{{.pequod.global.nested.sail}}", "true"},
+ {"{{.spouter.global.nested.sail}}", ""},
+
+ {"{{.global.nested2.l0}}", "moby"},
+ {"{{.global.nested2.l1}}", ""},
+ {"{{.global.nested2.l2}}", ""},
+ {"{{.pequod.global.nested2.l0}}", "moby"},
+ {"{{.pequod.global.nested2.l1}}", "pequod"},
+ {"{{.pequod.global.nested2.l2}}", ""},
+ {"{{.pequod.ahab.global.nested2.l0}}", "moby"},
+ {"{{.pequod.ahab.global.nested2.l1}}", "pequod"},
+ {"{{.pequod.ahab.global.nested2.l2}}", "ahab"},
+ {"{{.spouter.global.nested2.l0}}", "moby"},
+ {"{{.spouter.global.nested2.l1}}", "spouter"},
+ {"{{.spouter.global.nested2.l2}}", ""},
+ }
+
+ for _, tt := range tests {
+ if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect {
+ t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o)
+ }
+ }
+
+ nullKeys := []string{"bottom", "right", "left", "front"}
+ for _, nullKey := range nullKeys {
+ if _, ok := v[nullKey]; ok {
+ t.Errorf("Expected key %q to be removed, still present", nullKey)
+ }
+ }
+
+ if _, ok := v["nested"].(map[string]interface{})["boat"]; ok {
+ t.Error("Expected nested boat key to be removed, still present")
+ }
+
+ subchart := v["pequod"].(map[string]interface{})
+ if _, ok := subchart["boat"]; ok {
+ t.Error("Expected subchart boat key to be removed, still present")
+ }
+
+ subsubchart := subchart["ahab"].(map[string]interface{})
+ if _, ok := subsubchart["boat"]; ok {
+ t.Error("Expected sub-subchart ahab boat key to be removed, still present")
+ }
+
+ if _, ok := subsubchart["nested"].(map[string]interface{})["boat"]; ok {
+ t.Error("Expected sub-subchart nested boat key to be removed, still present")
+ }
+
+ if _, ok := subsubchart["object"]; ok {
+ t.Error("Expected sub-subchart object map to be removed, still present")
+ }
+
+ // CoalesceValues should not mutate the passed arguments
+ is.Equal(valsCopy, vals)
+}
+
+func TestMergeValues(t *testing.T) {
+ is := assert.New(t)
+
+ c := withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "moby"},
+ Values: map[string]interface{}{
+ "back": "exists",
+ "bottom": "exists",
+ "front": "exists",
+ "left": "exists",
+ "name": "moby",
+ "nested": map[string]interface{}{"boat": true},
+ "override": "bad",
+ "right": "exists",
+ "scope": "moby",
+ "top": "nope",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l0": "moby"},
+ },
+ },
+ },
+ withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "pequod"},
+ Values: map[string]interface{}{
+ "name": "pequod",
+ "scope": "pequod",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l1": "pequod"},
+ },
+ },
+ },
+ &chart.Chart{
+ Metadata: &chart.Metadata{Name: "ahab"},
+ Values: map[string]interface{}{
+ "global": map[string]interface{}{
+ "nested": map[string]interface{}{"foo": "bar"},
+ "nested2": map[string]interface{}{"l2": "ahab"},
+ },
+ "scope": "ahab",
+ "name": "ahab",
+ "boat": true,
+ "nested": map[string]interface{}{"foo": false, "bar": true},
+ },
+ },
+ ),
+ &chart.Chart{
+ Metadata: &chart.Metadata{Name: "spouter"},
+ Values: map[string]interface{}{
+ "scope": "spouter",
+ "global": map[string]interface{}{
+ "nested2": map[string]interface{}{"l1": "spouter"},
+ },
+ },
+ },
+ )
+
+ vals, err := ReadValues(testCoalesceValuesYaml)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // taking a copy of the values before passing it
+ // to MergeValues as argument, so that we can
+ // use it for asserting later
+ valsCopy := make(Values, len(vals))
+ maps.Copy(valsCopy, vals)
+
+ v, err := MergeValues(c, vals)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, _ := json.MarshalIndent(v, "", " ")
+ t.Logf("Coalesced Values: %s", string(j))
+
+ tests := []struct {
+ tpl string
+ expect string
+ }{
+ {"{{.top}}", "yup"},
+ {"{{.back}}", ""},
+ {"{{.name}}", "moby"},
+ {"{{.global.name}}", "Ishmael"},
+ {"{{.global.subject}}", "Queequeg"},
+ {"{{.global.harpooner}}", ""},
+ {"{{.pequod.name}}", "pequod"},
+ {"{{.pequod.ahab.name}}", "ahab"},
+ {"{{.pequod.ahab.scope}}", "whale"},
+ {"{{.pequod.ahab.nested.foo}}", "true"},
+ {"{{.pequod.ahab.global.name}}", "Ishmael"},
+ {"{{.pequod.ahab.global.nested.foo}}", "bar"},
+ {"{{.pequod.ahab.global.subject}}", "Queequeg"},
+ {"{{.pequod.ahab.global.harpooner}}", "Tashtego"},
+ {"{{.pequod.global.name}}", "Ishmael"},
+ {"{{.pequod.global.nested.foo}}", ""},
+ {"{{.pequod.global.subject}}", "Queequeg"},
+ {"{{.spouter.global.name}}", "Ishmael"},
+ {"{{.spouter.global.harpooner}}", ""},
+
+ {"{{.global.nested.boat}}", "true"},
+ {"{{.pequod.global.nested.boat}}", "true"},
+ {"{{.spouter.global.nested.boat}}", "true"},
+ {"{{.pequod.global.nested.sail}}", "true"},
+ {"{{.spouter.global.nested.sail}}", ""},
+
+ {"{{.global.nested2.l0}}", "moby"},
+ {"{{.global.nested2.l1}}", ""},
+ {"{{.global.nested2.l2}}", ""},
+ {"{{.pequod.global.nested2.l0}}", "moby"},
+ {"{{.pequod.global.nested2.l1}}", "pequod"},
+ {"{{.pequod.global.nested2.l2}}", ""},
+ {"{{.pequod.ahab.global.nested2.l0}}", "moby"},
+ {"{{.pequod.ahab.global.nested2.l1}}", "pequod"},
+ {"{{.pequod.ahab.global.nested2.l2}}", "ahab"},
+ {"{{.spouter.global.nested2.l0}}", "moby"},
+ {"{{.spouter.global.nested2.l1}}", "spouter"},
+ {"{{.spouter.global.nested2.l2}}", ""},
+ }
+
+ for _, tt := range tests {
+ if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect {
+ t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o)
+ }
+ }
+
+ // nullKeys is different from coalescing. Here the null/nil values are not
+ // removed.
+ nullKeys := []string{"bottom", "right", "left", "front"}
+ for _, nullKey := range nullKeys {
+ if vv, ok := v[nullKey]; !ok {
+ t.Errorf("Expected key %q to be present but it was removed", nullKey)
+ } else if vv != nil {
+ t.Errorf("Expected key %q to be null but it has a value of %v", nullKey, vv)
+ }
+ }
+
+ if _, ok := v["nested"].(map[string]interface{})["boat"]; !ok {
+ t.Error("Expected nested boat key to be present but it was removed")
+ }
+
+ subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{})
+ if _, ok := subchart["boat"]; !ok {
+ t.Error("Expected subchart boat key to be present but it was removed")
+ }
+
+ if _, ok := subchart["nested"].(map[string]interface{})["bar"]; !ok {
+ t.Error("Expected subchart nested bar key to be present but it was removed")
+ }
+
+ // CoalesceValues should not mutate the passed arguments
+ is.Equal(valsCopy, vals)
+}
+
+func TestCoalesceTables(t *testing.T) {
+ dst := map[string]interface{}{
+ "name": "Ishmael",
+ "address": map[string]interface{}{
+ "street": "123 Spouter Inn Ct.",
+ "city": "Nantucket",
+ "country": nil,
+ },
+ "details": map[string]interface{}{
+ "friends": []string{"Tashtego"},
+ },
+ "boat": "pequod",
+ "hole": nil,
+ }
+ src := map[string]interface{}{
+ "occupation": "whaler",
+ "address": map[string]interface{}{
+ "state": "MA",
+ "street": "234 Spouter Inn Ct.",
+ "country": "US",
+ },
+ "details": "empty",
+ "boat": map[string]interface{}{
+ "mast": true,
+ },
+ "hole": "black",
+ }
+
+ // What we expect is that anything in dst overrides anything in src, but that
+ // otherwise the values are coalesced.
+ CoalesceTables(dst, src)
+
+ if dst["name"] != "Ishmael" {
+ t.Errorf("Unexpected name: %s", dst["name"])
+ }
+ if dst["occupation"] != "whaler" {
+ t.Errorf("Unexpected occupation: %s", dst["occupation"])
+ }
+
+ addr, ok := dst["address"].(map[string]interface{})
+ if !ok {
+ t.Fatal("Address went away.")
+ }
+
+ if addr["street"].(string) != "123 Spouter Inn Ct." {
+ t.Errorf("Unexpected address: %v", addr["street"])
+ }
+
+ if addr["city"].(string) != "Nantucket" {
+ t.Errorf("Unexpected city: %v", addr["city"])
+ }
+
+ if addr["state"].(string) != "MA" {
+ t.Errorf("Unexpected state: %v", addr["state"])
+ }
+
+ if _, ok = addr["country"]; ok {
+ t.Error("The country is not left out.")
+ }
+
+ if det, ok := dst["details"].(map[string]interface{}); !ok {
+ t.Fatalf("Details is the wrong type: %v", dst["details"])
+ } else if _, ok := det["friends"]; !ok {
+ t.Error("Could not find your friends. Maybe you don't have any. :-(")
+ }
+
+ if dst["boat"].(string) != "pequod" {
+ t.Errorf("Expected boat string, got %v", dst["boat"])
+ }
+
+ if _, ok = dst["hole"]; ok {
+ t.Error("The hole still exists.")
+ }
+
+ dst2 := map[string]interface{}{
+ "name": "Ishmael",
+ "address": map[string]interface{}{
+ "street": "123 Spouter Inn Ct.",
+ "city": "Nantucket",
+ "country": "US",
+ },
+ "details": map[string]interface{}{
+ "friends": []string{"Tashtego"},
+ },
+ "boat": "pequod",
+ "hole": "black",
+ }
+
+ // What we expect is that anything in dst should have all values set,
+ // this happens when the --reuse-values flag is set but the chart has no modifications yet
+ CoalesceTables(dst2, nil)
+
+ if dst2["name"] != "Ishmael" {
+ t.Errorf("Unexpected name: %s", dst2["name"])
+ }
+
+ addr2, ok := dst2["address"].(map[string]interface{})
+ if !ok {
+ t.Fatal("Address went away.")
+ }
+
+ if addr2["street"].(string) != "123 Spouter Inn Ct." {
+ t.Errorf("Unexpected address: %v", addr2["street"])
+ }
+
+ if addr2["city"].(string) != "Nantucket" {
+ t.Errorf("Unexpected city: %v", addr2["city"])
+ }
+
+ if addr2["country"].(string) != "US" {
+ t.Errorf("Unexpected Country: %v", addr2["country"])
+ }
+
+ if det2, ok := dst2["details"].(map[string]interface{}); !ok {
+ t.Fatalf("Details is the wrong type: %v", dst2["details"])
+ } else if _, ok := det2["friends"]; !ok {
+ t.Error("Could not find your friends. Maybe you don't have any. :-(")
+ }
+
+ if dst2["boat"].(string) != "pequod" {
+ t.Errorf("Expected boat string, got %v", dst2["boat"])
+ }
+
+ if dst2["hole"].(string) != "black" {
+ t.Errorf("Expected hole string, got %v", dst2["boat"])
+ }
+}
+
+func TestMergeTables(t *testing.T) {
+ dst := map[string]interface{}{
+ "name": "Ishmael",
+ "address": map[string]interface{}{
+ "street": "123 Spouter Inn Ct.",
+ "city": "Nantucket",
+ "country": nil,
+ },
+ "details": map[string]interface{}{
+ "friends": []string{"Tashtego"},
+ },
+ "boat": "pequod",
+ "hole": nil,
+ }
+ src := map[string]interface{}{
+ "occupation": "whaler",
+ "address": map[string]interface{}{
+ "state": "MA",
+ "street": "234 Spouter Inn Ct.",
+ "country": "US",
+ },
+ "details": "empty",
+ "boat": map[string]interface{}{
+ "mast": true,
+ },
+ "hole": "black",
+ }
+
+ // What we expect is that anything in dst overrides anything in src, but that
+ // otherwise the values are coalesced.
+ MergeTables(dst, src)
+
+ if dst["name"] != "Ishmael" {
+ t.Errorf("Unexpected name: %s", dst["name"])
+ }
+ if dst["occupation"] != "whaler" {
+ t.Errorf("Unexpected occupation: %s", dst["occupation"])
+ }
+
+ addr, ok := dst["address"].(map[string]interface{})
+ if !ok {
+ t.Fatal("Address went away.")
+ }
+
+ if addr["street"].(string) != "123 Spouter Inn Ct." {
+ t.Errorf("Unexpected address: %v", addr["street"])
+ }
+
+ if addr["city"].(string) != "Nantucket" {
+ t.Errorf("Unexpected city: %v", addr["city"])
+ }
+
+ if addr["state"].(string) != "MA" {
+ t.Errorf("Unexpected state: %v", addr["state"])
+ }
+
+ // This is one test that is different from CoalesceTables. Because country
+ // is a nil value and it's not removed it's still present.
+ if _, ok = addr["country"]; !ok {
+ t.Error("The country is left out.")
+ }
+
+ if det, ok := dst["details"].(map[string]interface{}); !ok {
+ t.Fatalf("Details is the wrong type: %v", dst["details"])
+ } else if _, ok := det["friends"]; !ok {
+ t.Error("Could not find your friends. Maybe you don't have any. :-(")
+ }
+
+ if dst["boat"].(string) != "pequod" {
+ t.Errorf("Expected boat string, got %v", dst["boat"])
+ }
+
+ // This is one test that is different from CoalesceTables. Because hole
+ // is a nil value and it's not removed it's still present.
+ if _, ok = dst["hole"]; !ok {
+ t.Error("The hole no longer exists.")
+ }
+
+ dst2 := map[string]interface{}{
+ "name": "Ishmael",
+ "address": map[string]interface{}{
+ "street": "123 Spouter Inn Ct.",
+ "city": "Nantucket",
+ "country": "US",
+ },
+ "details": map[string]interface{}{
+ "friends": []string{"Tashtego"},
+ },
+ "boat": "pequod",
+ "hole": "black",
+ "nilval": nil,
+ }
+
+ // What we expect is that anything in dst should have all values set,
+ // this happens when the --reuse-values flag is set but the chart has no modifications yet
+ MergeTables(dst2, nil)
+
+ if dst2["name"] != "Ishmael" {
+ t.Errorf("Unexpected name: %s", dst2["name"])
+ }
+
+ addr2, ok := dst2["address"].(map[string]interface{})
+ if !ok {
+ t.Fatal("Address went away.")
+ }
+
+ if addr2["street"].(string) != "123 Spouter Inn Ct." {
+ t.Errorf("Unexpected address: %v", addr2["street"])
+ }
+
+ if addr2["city"].(string) != "Nantucket" {
+ t.Errorf("Unexpected city: %v", addr2["city"])
+ }
+
+ if addr2["country"].(string) != "US" {
+ t.Errorf("Unexpected Country: %v", addr2["country"])
+ }
+
+ if det2, ok := dst2["details"].(map[string]interface{}); !ok {
+ t.Fatalf("Details is the wrong type: %v", dst2["details"])
+ } else if _, ok := det2["friends"]; !ok {
+ t.Error("Could not find your friends. Maybe you don't have any. :-(")
+ }
+
+ if dst2["boat"].(string) != "pequod" {
+ t.Errorf("Expected boat string, got %v", dst2["boat"])
+ }
+
+ if dst2["hole"].(string) != "black" {
+ t.Errorf("Expected hole string, got %v", dst2["boat"])
+ }
+
+ if dst2["nilval"] != nil {
+ t.Error("Expected nilvalue to have nil value but it does not")
+ }
+}
+
+func TestCoalesceValuesWarnings(t *testing.T) {
+
+ c := withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "level1"},
+ Values: map[string]interface{}{
+ "name": "moby",
+ },
+ },
+ withDeps(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "level2"},
+ Values: map[string]interface{}{
+ "name": "pequod",
+ },
+ },
+ &chart.Chart{
+ Metadata: &chart.Metadata{Name: "level3"},
+ Values: map[string]interface{}{
+ "name": "ahab",
+ "boat": true,
+ "spear": map[string]interface{}{
+ "tip": true,
+ "sail": map[string]interface{}{
+ "cotton": true,
+ },
+ },
+ },
+ },
+ ),
+ )
+
+ vals := map[string]interface{}{
+ "level2": map[string]interface{}{
+ "level3": map[string]interface{}{
+ "boat": map[string]interface{}{"mast": true},
+ "spear": map[string]interface{}{
+ "tip": map[string]interface{}{
+ "sharp": true,
+ },
+ "sail": true,
+ },
+ },
+ },
+ }
+
+ warnings := make([]string, 0)
+ printf := func(format string, v ...interface{}) {
+ t.Logf(format, v...)
+ warnings = append(warnings, fmt.Sprintf(format, v...))
+ }
+
+ _, err := coalesce(printf, c, vals, "", false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Logf("vals: %v", vals)
+ 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) {
+ assert.Equal(t, "b", concatPrefix("", "b"))
+ assert.Equal(t, "a.b", concatPrefix("a", "b"))
+}
diff --git a/pkg/chartutil/compatible.go b/internal/chart/v3/util/compatible.go
similarity index 98%
rename from pkg/chartutil/compatible.go
rename to internal/chart/v3/util/compatible.go
index f4656c913..d384d2d45 100644
--- a/pkg/chartutil/compatible.go
+++ b/internal/chart/v3/util/compatible.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import "github.com/Masterminds/semver/v3"
diff --git a/pkg/chartutil/compatible_test.go b/internal/chart/v3/util/compatible_test.go
similarity index 98%
rename from pkg/chartutil/compatible_test.go
rename to internal/chart/v3/util/compatible_test.go
index df7be6161..e17d33e35 100644
--- a/pkg/chartutil/compatible_test.go
+++ b/internal/chart/v3/util/compatible_test.go
@@ -15,7 +15,7 @@ limitations under the License.
*/
// Package version represents the current version of the project.
-package chartutil
+package util
import "testing"
diff --git a/pkg/chartutil/create.go b/internal/chart/v3/util/create.go
similarity index 73%
rename from pkg/chartutil/create.go
rename to internal/chart/v3/util/create.go
index a94f56a8a..6a28f99d4 100644
--- a/pkg/chartutil/create.go
+++ b/internal/chart/v3/util/create.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"fmt"
@@ -24,11 +24,10 @@ import (
"regexp"
"strings"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/chart/v3/loader"
)
// chartName is a regular expression for testing the supplied name of a chart.
@@ -54,6 +53,8 @@ const (
IgnorefileName = ".helmignore"
// IngressFileName is the name of the example ingress file.
IngressFileName = TemplatesDir + sep + "ingress.yaml"
+ // HTTPRouteFileName is the name of the example HTTPRoute file.
+ HTTPRouteFileName = TemplatesDir + sep + "httproute.yaml"
// DeploymentName is the name of the example deployment file.
DeploymentName = TemplatesDir + sep + "deployment.yaml"
// ServiceName is the name of the example service file.
@@ -76,7 +77,7 @@ const maxChartNameLength = 250
const sep = string(filepath.Separator)
-const defaultChartfile = `apiVersion: v2
+const defaultChartfile = `apiVersion: v3
name: %s
description: A Helm chart for Kubernetes
@@ -106,18 +107,24 @@ const defaultValues = `# Default values for %s.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
+# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
replicaCount: 1
+# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
image:
repository: nginx
+ # This sets the pull policy for images.
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
+# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
imagePullSecrets: []
+# This is to override the chart name.
nameOverride: ""
fullnameOverride: ""
+# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
serviceAccount:
# Specifies whether a service account should be created
create: true
@@ -129,7 +136,11 @@ serviceAccount:
# If not set and create is true, a name is generated using the fullname template
name: ""
+# This is for setting Kubernetes Annotations to a Pod.
+# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
podAnnotations: {}
+# This is for setting Kubernetes Labels to a Pod.
+# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
podLabels: {}
podSecurityContext: {}
@@ -143,10 +154,14 @@ securityContext: {}
# runAsNonRoot: true
# runAsUser: 1000
+# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
service:
+ # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
type: ClusterIP
+ # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
port: 80
+# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
enabled: false
className: ""
@@ -163,6 +178,44 @@ ingress:
# hosts:
# - chart-example.local
+# -- Expose the service via gateway-api HTTPRoute
+# Requires Gateway API resources and suitable controller installed within the cluster
+# (see: https://gateway-api.sigs.k8s.io/guides/)
+httpRoute:
+ # HTTPRoute enabled.
+ enabled: false
+ # HTTPRoute annotations.
+ annotations: {}
+ # Which Gateways this Route is attached to.
+ parentRefs:
+ - name: gateway
+ sectionName: http
+ # namespace: default
+ # Hostnames matching HTTP header.
+ hostnames:
+ - chart-example.local
+ # List of rules and filters applied.
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /headers
+ # filters:
+ # - type: RequestHeaderModifier
+ # requestHeaderModifier:
+ # set:
+ # - name: My-Overwrite-Header
+ # value: this-is-the-only-value
+ # remove:
+ # - User-Agent
+ # - matches:
+ # - path:
+ # type: PathPrefix
+ # value: /echo
+ # headers:
+ # - name: version
+ # value: v2
+
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
@@ -175,6 +228,17 @@ resources: {}
# cpu: 100m
# memory: 128Mi
+# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
+livenessProbe:
+ httpGet:
+ path: /
+ port: http
+readinessProbe:
+ httpGet:
+ path: /
+ port: http
+
+# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/
autoscaling:
enabled: false
minReplicas: 1
@@ -228,23 +292,10 @@ const defaultIgnore = `# Patterns to ignore when building packages.
`
const defaultIngress = `{{- if .Values.ingress.enabled -}}
-{{- $fullName := include ".fullname" . -}}
-{{- $svcPort := .Values.service.port -}}
-{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
- {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
- {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
- {{- end }}
-{{- end }}
-{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
-{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
-apiVersion: networking.k8s.io/v1beta1
-{{- else -}}
-apiVersion: extensions/v1beta1
-{{- end }}
kind: Ingress
metadata:
- name: {{ $fullName }}
+ name: {{ include ".fullname" . }}
labels:
{{- include ".labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
@@ -252,8 +303,8 @@ metadata:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
- {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
- ingressClassName: {{ .Values.ingress.className }}
+ {{- with .Values.ingress.className }}
+ ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
@@ -272,24 +323,59 @@ spec:
paths:
{{- range .paths }}
- path: {{ .path }}
- {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
- pathType: {{ .pathType }}
+ {{- with .pathType }}
+ pathType: {{ . }}
{{- end }}
backend:
- {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
- name: {{ $fullName }}
+ name: {{ include ".fullname" $ }}
port:
- number: {{ $svcPort }}
- {{- else }}
- serviceName: {{ $fullName }}
- servicePort: {{ $svcPort }}
- {{- end }}
+ number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
`
+const defaultHTTPRoute = `{{- if .Values.httpRoute.enabled -}}
+{{- $fullName := include ".fullname" . -}}
+{{- $svcPort := .Values.service.port -}}
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: {{ $fullName }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+ {{- with .Values.httpRoute.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ parentRefs:
+ {{- with .Values.httpRoute.parentRefs }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- with .Values.httpRoute.hostnames }}
+ hostnames:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ {{- range .Values.httpRoute.rules }}
+ {{- with .matches }}
+ - matches:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .filters }}
+ filters:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ backendRefs:
+ - name: {{ $fullName }}
+ port: {{ $svcPort }}
+ weight: 1
+ {{- end }}
+{{- end }}
+`
+
const defaultDeployment = `apiVersion: apps/v1
kind: Deployment
metadata:
@@ -311,7 +397,7 @@ spec:
{{- end }}
labels:
{{- include ".labels" . | nindent 8 }}
- {{- with .Values.podLabels }}
+ {{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
@@ -320,28 +406,34 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include ".serviceAccountName" . }}
+ {{- with .Values.podSecurityContext }}
securityContext:
- {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
containers:
- name: {{ .Chart.Name }}
+ {{- with .Values.securityContext }}
securityContext:
- {{- toYaml .Values.securityContext | nindent 12 }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
+ {{- with .Values.livenessProbe }}
livenessProbe:
- httpGet:
- path: /
- port: http
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.readinessProbe }}
readinessProbe:
- httpGet:
- path: /
- port: http
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.resources }}
resources:
- {{- toYaml .Values.resources | nindent 12 }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
@@ -431,7 +523,20 @@ spec:
`
const defaultNotes = `1. Get the application URL by running these commands:
-{{- if .Values.ingress.enabled }}
+{{- if .Values.httpRoute.enabled }}
+{{- if .Values.httpRoute.hostnames }}
+ export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }}
+{{- else }}
+ export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}")
+ {{- end }}
+{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }}
+ echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application"
+
+ NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules.
+ The rules can be set for path, method, header and query parameters.
+ You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml'
+{{- end }}
+{{- else if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
@@ -443,7 +548,7 @@ const defaultNotes = `1. Get the application URL by running these commands:
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
- You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}'
+ You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include ".fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
@@ -545,7 +650,7 @@ var Stderr io.Writer = os.Stderr
func CreateFrom(chartfile *chart.Metadata, dest, src string) error {
schart, err := loader.Load(src)
if err != nil {
- return errors.Wrapf(err, "could not load %s", src)
+ return fmt.Errorf("could not load %s: %w", src, err)
}
schart.Metadata = chartfile
@@ -560,12 +665,12 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error {
schart.Templates = updatedTemplates
b, err := yaml.Marshal(schart.Values)
if err != nil {
- return errors.Wrap(err, "reading values file")
+ return fmt.Errorf("reading values file: %w", err)
}
var m map[string]interface{}
if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil {
- return errors.Wrap(err, "transforming values file")
+ return fmt.Errorf("transforming values file: %w", err)
}
schart.Values = m
@@ -609,14 +714,18 @@ func Create(name, dir string) (string, error) {
if fi, err := os.Stat(path); err != nil {
return path, err
} else if !fi.IsDir() {
- return path, errors.Errorf("no such directory %s", path)
+ return path, fmt.Errorf("no such directory %s", path)
}
cdir := filepath.Join(path, name)
if fi, err := os.Stat(cdir); err == nil && !fi.IsDir() {
- return cdir, errors.Errorf("file %s already exists and is not a directory", cdir)
+ return cdir, fmt.Errorf("file %s already exists and is not a directory", cdir)
}
+ // Note: If adding a new template below (i.e., to `helm create`) which is disabled by default (similar to hpa and
+ // ingress below); or making an existing template disabled by default, add the enabling condition in
+ // `TestHelmCreateChart_CheckDeprecatedWarnings` in `pkg/lint/lint_test.go` to make it run through deprecation checks
+ // with latest Kubernetes version.
files := []struct {
path string
content []byte
@@ -624,12 +733,12 @@ func Create(name, dir string) (string, error) {
{
// Chart.yaml
path: filepath.Join(cdir, ChartfileName),
- content: []byte(fmt.Sprintf(defaultChartfile, name)),
+ content: fmt.Appendf(nil, defaultChartfile, name),
},
{
// values.yaml
path: filepath.Join(cdir, ValuesfileName),
- content: []byte(fmt.Sprintf(defaultValues, name)),
+ content: fmt.Appendf(nil, defaultValues, name),
},
{
// .helmignore
@@ -641,6 +750,11 @@ func Create(name, dir string) (string, error) {
path: filepath.Join(cdir, IngressFileName),
content: transform(defaultIngress, name),
},
+ {
+ // httproute.yaml
+ path: filepath.Join(cdir, HTTPRouteFileName),
+ content: transform(defaultHTTPRoute, name),
+ },
{
// deployment.yaml
path: filepath.Join(cdir, DeploymentName),
diff --git a/internal/chart/v3/util/create_test.go b/internal/chart/v3/util/create_test.go
new file mode 100644
index 000000000..b3b58cc5a
--- /dev/null
+++ b/internal/chart/v3/util/create_test.go
@@ -0,0 +1,172 @@
+/*
+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 util
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/chart/v3/loader"
+)
+
+func TestCreate(t *testing.T) {
+ tdir := t.TempDir()
+
+ c, err := Create("foo", tdir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ dir := filepath.Join(tdir, "foo")
+
+ mychart, err := loader.LoadDir(c)
+ if err != nil {
+ t.Fatalf("Failed to load newly created chart %q: %s", c, err)
+ }
+
+ if mychart.Name() != "foo" {
+ t.Errorf("Expected name to be 'foo', got %q", mychart.Name())
+ }
+
+ for _, f := range []string{
+ ChartfileName,
+ DeploymentName,
+ HelpersName,
+ IgnorefileName,
+ NotesName,
+ ServiceAccountName,
+ ServiceName,
+ TemplatesDir,
+ TemplatesTestsDir,
+ TestConnectionName,
+ ValuesfileName,
+ } {
+ if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
+ t.Errorf("Expected %s file: %s", f, err)
+ }
+ }
+}
+
+func TestCreateFrom(t *testing.T) {
+ tdir := t.TempDir()
+
+ cf := &chart.Metadata{
+ APIVersion: chart.APIVersionV3,
+ Name: "foo",
+ Version: "0.1.0",
+ }
+ srcdir := "./testdata/frobnitz/charts/mariner"
+
+ if err := CreateFrom(cf, tdir, srcdir); err != nil {
+ t.Fatal(err)
+ }
+
+ dir := filepath.Join(tdir, "foo")
+ c := filepath.Join(tdir, cf.Name)
+ mychart, err := loader.LoadDir(c)
+ if err != nil {
+ t.Fatalf("Failed to load newly created chart %q: %s", c, err)
+ }
+
+ if mychart.Name() != "foo" {
+ t.Errorf("Expected name to be 'foo', got %q", mychart.Name())
+ }
+
+ for _, f := range []string{
+ ChartfileName,
+ ValuesfileName,
+ filepath.Join(TemplatesDir, "placeholder.tpl"),
+ } {
+ if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
+ t.Errorf("Expected %s file: %s", f, err)
+ }
+
+ // Check each file to make sure has been replaced
+ b, err := os.ReadFile(filepath.Join(dir, f))
+ if err != nil {
+ t.Errorf("Unable to read file %s: %s", f, err)
+ }
+ if bytes.Contains(b, []byte("")) {
+ t.Errorf("File %s contains ", f)
+ }
+ }
+}
+
+// TestCreate_Overwrite is a regression test for making sure that files are overwritten.
+func TestCreate_Overwrite(t *testing.T) {
+ tdir := t.TempDir()
+
+ var errlog bytes.Buffer
+
+ if _, err := Create("foo", tdir); err != nil {
+ t.Fatal(err)
+ }
+
+ dir := filepath.Join(tdir, "foo")
+
+ tplname := filepath.Join(dir, "templates/hpa.yaml")
+ writeFile(tplname, []byte("FOO"))
+
+ // Now re-run the create
+ Stderr = &errlog
+ if _, err := Create("foo", tdir); err != nil {
+ t.Fatal(err)
+ }
+
+ data, err := os.ReadFile(tplname)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if string(data) == "FOO" {
+ t.Fatal("File that should have been modified was not.")
+ }
+
+ if errlog.Len() == 0 {
+ t.Errorf("Expected warnings about overwriting files.")
+ }
+}
+
+func TestValidateChartName(t *testing.T) {
+ for name, shouldPass := range map[string]bool{
+ "": false,
+ "abcdefghijklmnopqrstuvwxyz-_.": true,
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": true,
+ "$hello": false,
+ "Hellô": false,
+ "he%%o": false,
+ "he\nllo": false,
+
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "abcdefghijklmnopqrstuvwxyz-_." +
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ-_.": false,
+ } {
+ if err := validateChartName(name); (err != nil) == shouldPass {
+ t.Errorf("test for %q failed", name)
+ }
+ }
+}
diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go
new file mode 100644
index 000000000..129c46372
--- /dev/null
+++ b/internal/chart/v3/util/dependencies.go
@@ -0,0 +1,367 @@
+/*
+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 util
+
+import (
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/mitchellh/copystructure"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// ProcessDependencies checks through this chart's dependencies, processing accordingly.
+func ProcessDependencies(c *chart.Chart, v Values) error {
+ if err := processDependencyEnabled(c, v, ""); err != nil {
+ return err
+ }
+ return processDependencyImportValues(c, true)
+}
+
+// processDependencyConditions disables charts based on condition path value in values
+func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath string) {
+ if reqs == nil {
+ return
+ }
+ for _, r := range reqs {
+ for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") {
+ if len(c) > 0 {
+ // retrieve value
+ vv, err := cvals.PathValue(cpath + c)
+ if err == nil {
+ // if not bool, warn
+ if bv, ok := vv.(bool); ok {
+ r.Enabled = bv
+ break
+ }
+ slog.Warn("returned non-bool value", "path", c, "chart", r.Name)
+ } else if _, ok := err.(ErrNoValue); !ok {
+ // this is a real error
+ slog.Warn("the method PathValue returned error", slog.Any("error", err))
+ }
+ }
+ }
+ }
+}
+
+// processDependencyTags disables charts based on tags in values
+func processDependencyTags(reqs []*chart.Dependency, cvals Values) {
+ if reqs == nil {
+ return
+ }
+ vt, err := cvals.Table("tags")
+ if err != nil {
+ return
+ }
+ for _, r := range reqs {
+ var hasTrue, hasFalse bool
+ for _, k := range r.Tags {
+ if b, ok := vt[k]; ok {
+ // if not bool, warn
+ if bv, ok := b.(bool); ok {
+ if bv {
+ hasTrue = true
+ } else {
+ hasFalse = true
+ }
+ } else {
+ slog.Warn("returned non-bool value", "tag", k, "chart", r.Name)
+ }
+ }
+ }
+ if !hasTrue && hasFalse {
+ r.Enabled = false
+ } else if hasTrue || !hasTrue && !hasFalse {
+ r.Enabled = true
+ }
+ }
+}
+
+// getAliasDependency finds the chart for an alias dependency and copies parts that will be modified
+func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Chart {
+ for _, c := range charts {
+ if c == nil {
+ continue
+ }
+ if c.Name() != dep.Name {
+ continue
+ }
+ if !IsCompatibleRange(dep.Version, c.Metadata.Version) {
+ continue
+ }
+
+ out := *c
+ out.Metadata = copyMetadata(c.Metadata)
+
+ // empty dependencies and shallow copy all dependencies, otherwise parent info may be corrupted if
+ // there is more than one dependency aliasing this chart
+ out.SetDependencies()
+ for _, dependency := range c.Dependencies() {
+ cpy := *dependency
+ out.AddDependency(&cpy)
+ }
+
+ if dep.Alias != "" {
+ out.Metadata.Name = dep.Alias
+ }
+ return &out
+ }
+ return nil
+}
+
+func copyMetadata(metadata *chart.Metadata) *chart.Metadata {
+ md := *metadata
+
+ if md.Dependencies != nil {
+ dependencies := make([]*chart.Dependency, len(md.Dependencies))
+ for i := range md.Dependencies {
+ dependency := *md.Dependencies[i]
+ dependencies[i] = &dependency
+ }
+ md.Dependencies = dependencies
+ }
+ return &md
+}
+
+// processDependencyEnabled removes disabled charts from dependencies
+func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error {
+ if c.Metadata.Dependencies == nil {
+ return nil
+ }
+
+ var chartDependencies []*chart.Chart
+ // If any dependency is not a part of Chart.yaml
+ // then this should be added to chartDependencies.
+ // However, if the dependency is already specified in Chart.yaml
+ // we should not add it, as it would be processed from Chart.yaml anyway.
+
+Loop:
+ for _, existing := range c.Dependencies() {
+ for _, req := range c.Metadata.Dependencies {
+ if existing.Name() == req.Name && IsCompatibleRange(req.Version, existing.Metadata.Version) {
+ continue Loop
+ }
+ }
+ chartDependencies = append(chartDependencies, existing)
+ }
+
+ for _, req := range c.Metadata.Dependencies {
+ if req == nil {
+ continue
+ }
+ if chartDependency := getAliasDependency(c.Dependencies(), req); chartDependency != nil {
+ chartDependencies = append(chartDependencies, chartDependency)
+ }
+ if req.Alias != "" {
+ req.Name = req.Alias
+ }
+ }
+ c.SetDependencies(chartDependencies...)
+
+ // set all to true
+ for _, lr := range c.Metadata.Dependencies {
+ lr.Enabled = true
+ }
+ cvals, err := CoalesceValues(c, v)
+ if err != nil {
+ return err
+ }
+ // flag dependencies as enabled/disabled
+ processDependencyTags(c.Metadata.Dependencies, cvals)
+ processDependencyConditions(c.Metadata.Dependencies, cvals, path)
+ // make a map of charts to remove
+ rm := map[string]struct{}{}
+ for _, r := range c.Metadata.Dependencies {
+ if !r.Enabled {
+ // remove disabled chart
+ rm[r.Name] = struct{}{}
+ }
+ }
+ // don't keep disabled charts in new slice
+ cd := []*chart.Chart{}
+ copy(cd, c.Dependencies()[:0])
+ for _, n := range c.Dependencies() {
+ if _, ok := rm[n.Metadata.Name]; !ok {
+ cd = append(cd, n)
+ }
+ }
+ // don't keep disabled charts in metadata
+ cdMetadata := []*chart.Dependency{}
+ copy(cdMetadata, c.Metadata.Dependencies[:0])
+ for _, n := range c.Metadata.Dependencies {
+ if _, ok := rm[n.Name]; !ok {
+ cdMetadata = append(cdMetadata, n)
+ }
+ }
+
+ // recursively call self to process sub dependencies
+ for _, t := range cd {
+ subpath := path + t.Metadata.Name + "."
+ if err := processDependencyEnabled(t, cvals, subpath); err != nil {
+ return err
+ }
+ }
+ // set the correct dependencies in metadata
+ c.Metadata.Dependencies = nil
+ c.Metadata.Dependencies = append(c.Metadata.Dependencies, cdMetadata...)
+ c.SetDependencies(cd...)
+
+ return nil
+}
+
+// pathToMap creates a nested map given a YAML path in dot notation.
+func pathToMap(path string, data map[string]interface{}) map[string]interface{} {
+ if path == "." {
+ return data
+ }
+ return set(parsePath(path), data)
+}
+
+func set(path []string, data map[string]interface{}) map[string]interface{} {
+ if len(path) == 0 {
+ return nil
+ }
+ cur := data
+ for i := len(path) - 1; i >= 0; i-- {
+ cur = map[string]interface{}{path[i]: cur}
+ }
+ return cur
+}
+
+// processImportValues merges values from child to parent based on the chart's dependencies' ImportValues field.
+func processImportValues(c *chart.Chart, merge bool) error {
+ if c.Metadata.Dependencies == nil {
+ return nil
+ }
+ // combine chart values and empty config to get Values
+ var cvals Values
+ var err error
+ if merge {
+ cvals, err = MergeValues(c, nil)
+ } else {
+ cvals, err = CoalesceValues(c, nil)
+ }
+ if err != nil {
+ return err
+ }
+ b := make(map[string]interface{})
+ // import values from each dependency if specified in import-values
+ for _, r := range c.Metadata.Dependencies {
+ var outiv []interface{}
+ for _, riv := range r.ImportValues {
+ switch iv := riv.(type) {
+ case map[string]interface{}:
+ child := fmt.Sprintf("%v", iv["child"])
+ parent := fmt.Sprintf("%v", iv["parent"])
+
+ outiv = append(outiv, map[string]string{
+ "child": child,
+ "parent": parent,
+ })
+
+ // get child table
+ vv, err := cvals.Table(r.Name + "." + child)
+ if err != nil {
+ slog.Warn("ImportValues missing table from chart", "chart", r.Name, slog.Any("error", err))
+ continue
+ }
+ // create value map from child to be merged into parent
+ if merge {
+ b = MergeTables(b, pathToMap(parent, vv.AsMap()))
+ } else {
+ b = CoalesceTables(b, pathToMap(parent, vv.AsMap()))
+ }
+ case string:
+ child := "exports." + iv
+ outiv = append(outiv, map[string]string{
+ "child": child,
+ "parent": ".",
+ })
+ vm, err := cvals.Table(r.Name + "." + child)
+ if err != nil {
+ slog.Warn("ImportValues missing table", slog.Any("error", err))
+ continue
+ }
+ if merge {
+ b = MergeTables(b, vm.AsMap())
+ } else {
+ b = CoalesceTables(b, vm.AsMap())
+ }
+ }
+ }
+ r.ImportValues = outiv
+ }
+
+ // Imported values from a child to a parent chart have a lower priority than
+ // the parents values. This enables parent charts to import a large section
+ // from a child and then override select parts. This is why b is merged into
+ // cvals in the code below and not the other way around.
+ if merge {
+ // deep copying the cvals as there are cases where pointers can end
+ // up in the cvals when they are copied onto b in ways that break things.
+ cvals = deepCopyMap(cvals)
+ c.Values = MergeTables(cvals, b)
+ } else {
+ // Trimming the nil values from cvals is needed for backwards compatibility.
+ // Previously, the b value had been populated with cvals along with some
+ // overrides. This caused the coalescing functionality to remove the
+ // nil/null values. This trimming is for backwards compat.
+ cvals = trimNilValues(cvals)
+ c.Values = CoalesceTables(cvals, b)
+ }
+
+ return nil
+}
+
+func deepCopyMap(vals map[string]interface{}) map[string]interface{} {
+ valsCopy, err := copystructure.Copy(vals)
+ if err != nil {
+ return vals
+ }
+ return valsCopy.(map[string]interface{})
+}
+
+func trimNilValues(vals map[string]interface{}) map[string]interface{} {
+ valsCopy, err := copystructure.Copy(vals)
+ if err != nil {
+ return vals
+ }
+ valsCopyMap := valsCopy.(map[string]interface{})
+ 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{}))
+ }
+ }
+
+ return valsCopyMap
+}
+
+// processDependencyImportValues imports specified chart values from child to parent.
+func processDependencyImportValues(c *chart.Chart, merge bool) error {
+ for _, d := range c.Dependencies() {
+ // recurse
+ if err := processDependencyImportValues(d, merge); err != nil {
+ return err
+ }
+ }
+ return processImportValues(c, merge)
+}
diff --git a/internal/chart/v3/util/dependencies_test.go b/internal/chart/v3/util/dependencies_test.go
new file mode 100644
index 000000000..55839fe65
--- /dev/null
+++ b/internal/chart/v3/util/dependencies_test.go
@@ -0,0 +1,569 @@
+/*
+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 util
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "testing"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/chart/v3/loader"
+)
+
+func loadChart(t *testing.T, path string) *chart.Chart {
+ t.Helper()
+ c, err := loader.Load(path)
+ if err != nil {
+ t.Fatalf("failed to load testdata: %s", err)
+ }
+ return c
+}
+
+func TestLoadDependency(t *testing.T) {
+ tests := []*chart.Dependency{
+ {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"},
+ {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"},
+ }
+
+ check := func(deps []*chart.Dependency) {
+ if len(deps) != 2 {
+ t.Errorf("expected 2 dependencies, got %d", len(deps))
+ }
+ for i, tt := range tests {
+ if deps[i].Name != tt.Name {
+ t.Errorf("expected dependency named %q, got %q", tt.Name, deps[i].Name)
+ }
+ if deps[i].Version != tt.Version {
+ t.Errorf("expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, deps[i].Version)
+ }
+ if deps[i].Repository != tt.Repository {
+ t.Errorf("expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, deps[i].Repository)
+ }
+ }
+ }
+ c := loadChart(t, "testdata/frobnitz")
+ check(c.Metadata.Dependencies)
+ check(c.Lock.Dependencies)
+}
+
+func TestDependencyEnabled(t *testing.T) {
+ type M = map[string]interface{}
+ tests := []struct {
+ name string
+ v M
+ e []string // expected charts including duplicates in alphanumeric order
+ }{{
+ "tags with no effect",
+ M{"tags": M{"nothinguseful": false}},
+ []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"},
+ }, {
+ "tags disabling a group",
+ M{"tags": M{"front-end": false}},
+ []string{"parentchart"},
+ }, {
+ "tags disabling a group and enabling a different group",
+ M{"tags": M{"front-end": false, "back-end": true}},
+ []string{"parentchart", "parentchart.subchart2", "parentchart.subchart2.subchartb", "parentchart.subchart2.subchartc"},
+ }, {
+ "tags disabling only children, children still enabled since tag front-end=true in values.yaml",
+ M{"tags": M{"subcharta": false, "subchartb": false}},
+ []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb"},
+ }, {
+ "tags disabling all parents/children with additional tag re-enabling a parent",
+ M{"tags": M{"front-end": false, "subchart1": true, "back-end": false}},
+ []string{"parentchart", "parentchart.subchart1"},
+ }, {
+ "conditions enabling the parent charts, but back-end (b, c) is still disabled via values.yaml",
+ M{"subchart1": M{"enabled": true}, "subchart2": M{"enabled": true}},
+ []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2"},
+ }, {
+ "conditions disabling the parent charts, effectively disabling children",
+ M{"subchart1": M{"enabled": false}, "subchart2": M{"enabled": false}},
+ []string{"parentchart"},
+ }, {
+ "conditions a child using the second condition path of child's condition",
+ M{"subchart1": M{"subcharta": M{"enabled": false}}},
+ []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subchartb"},
+ }, {
+ "tags enabling a parent/child group with condition disabling one child",
+ M{"subchart2": M{"subchartc": M{"enabled": false}}, "tags": M{"back-end": true}},
+ []string{"parentchart", "parentchart.subchart1", "parentchart.subchart1.subcharta", "parentchart.subchart1.subchartb", "parentchart.subchart2", "parentchart.subchart2.subchartb"},
+ }, {
+ "tags will not enable a child if parent is explicitly disabled with condition",
+ M{"subchart1": M{"enabled": false}, "tags": M{"front-end": true}},
+ []string{"parentchart"},
+ }, {
+ "subcharts with alias also respect conditions",
+ M{"subchart1": M{"enabled": false}, "subchart2alias": M{"enabled": true, "subchartb": M{"enabled": true}}},
+ []string{"parentchart", "parentchart.subchart2alias", "parentchart.subchart2alias.subchartb"},
+ }}
+
+ for _, tc := range tests {
+ c := loadChart(t, "testdata/subpop")
+ t.Run(tc.name, func(t *testing.T) {
+ if err := processDependencyEnabled(c, tc.v, ""); err != nil {
+ t.Fatalf("error processing enabled dependencies %v", err)
+ }
+
+ names := extractChartNames(c)
+ if len(names) != len(tc.e) {
+ t.Fatalf("slice lengths do not match got %v, expected %v", len(names), len(tc.e))
+ }
+ for i := range names {
+ if names[i] != tc.e[i] {
+ t.Fatalf("slice values do not match got %v, expected %v", names, tc.e)
+ }
+ }
+ })
+ }
+}
+
+// extractChartNames recursively searches chart dependencies returning all charts found
+func extractChartNames(c *chart.Chart) []string {
+ var out []string
+ var fn func(c *chart.Chart)
+ fn = func(c *chart.Chart) {
+ out = append(out, c.ChartPath())
+ for _, d := range c.Dependencies() {
+ fn(d)
+ }
+ }
+ fn(c)
+ sort.Strings(out)
+ return out
+}
+
+func TestProcessDependencyImportValues(t *testing.T) {
+ c := loadChart(t, "testdata/subpop")
+
+ e := make(map[string]string)
+
+ e["imported-chart1.SC1bool"] = "true"
+ e["imported-chart1.SC1float"] = "3.14"
+ e["imported-chart1.SC1int"] = "100"
+ e["imported-chart1.SC1string"] = "dollywood"
+ e["imported-chart1.SC1extra1"] = "11"
+ e["imported-chart1.SPextra1"] = "helm rocks"
+ e["imported-chart1.SC1extra1"] = "11"
+
+ e["imported-chartA.SCAbool"] = "false"
+ e["imported-chartA.SCAfloat"] = "3.1"
+ e["imported-chartA.SCAint"] = "55"
+ e["imported-chartA.SCAstring"] = "jabba"
+ e["imported-chartA.SPextra3"] = "1.337"
+ e["imported-chartA.SC1extra2"] = "1.337"
+ e["imported-chartA.SCAnested1.SCAnested2"] = "true"
+
+ e["imported-chartA-B.SCAbool"] = "false"
+ e["imported-chartA-B.SCAfloat"] = "3.1"
+ e["imported-chartA-B.SCAint"] = "55"
+ e["imported-chartA-B.SCAstring"] = "jabba"
+
+ e["imported-chartA-B.SCBbool"] = "true"
+ e["imported-chartA-B.SCBfloat"] = "7.77"
+ e["imported-chartA-B.SCBint"] = "33"
+ e["imported-chartA-B.SCBstring"] = "boba"
+ e["imported-chartA-B.SPextra5"] = "k8s"
+ e["imported-chartA-B.SC1extra5"] = "tiller"
+
+ // These values are imported from the child chart to the parent. Parent
+ // values take precedence over imported values. This enables importing a
+ // large section from a child chart and overriding a selection from it.
+ e["overridden-chart1.SC1bool"] = "false"
+ e["overridden-chart1.SC1float"] = "3.141592"
+ e["overridden-chart1.SC1int"] = "99"
+ e["overridden-chart1.SC1string"] = "pollywog"
+ e["overridden-chart1.SPextra2"] = "42"
+
+ e["overridden-chartA.SCAbool"] = "true"
+ e["overridden-chartA.SCAfloat"] = "41.3"
+ e["overridden-chartA.SCAint"] = "808"
+ e["overridden-chartA.SCAstring"] = "jabberwocky"
+ e["overridden-chartA.SPextra4"] = "true"
+
+ // These values are imported from the child chart to the parent. Parent
+ // values take precedence over imported values. This enables importing a
+ // large section from a child chart and overriding a selection from it.
+ e["overridden-chartA-B.SCAbool"] = "true"
+ e["overridden-chartA-B.SCAfloat"] = "41.3"
+ e["overridden-chartA-B.SCAint"] = "808"
+ e["overridden-chartA-B.SCAstring"] = "jabberwocky"
+ e["overridden-chartA-B.SCBbool"] = "false"
+ e["overridden-chartA-B.SCBfloat"] = "1.99"
+ e["overridden-chartA-B.SCBint"] = "77"
+ e["overridden-chartA-B.SCBstring"] = "jango"
+ e["overridden-chartA-B.SPextra6"] = "111"
+ e["overridden-chartA-B.SCAextra1"] = "23"
+ e["overridden-chartA-B.SCBextra1"] = "13"
+ e["overridden-chartA-B.SC1extra6"] = "77"
+
+ // `exports` style
+ e["SCBexported1B"] = "1965"
+ e["SC1extra7"] = "true"
+ e["SCBexported2A"] = "blaster"
+ e["global.SC1exported2.all.SC1exported3"] = "SC1expstr"
+
+ if err := processDependencyImportValues(c, false); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+ cc := Values(c.Values)
+ for kk, vv := range e {
+ pv, err := cc.PathValue(kk)
+ if err != nil {
+ t.Fatalf("retrieving import values table %v %v", kk, err)
+ }
+
+ switch pv := pv.(type) {
+ case float64:
+ if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv {
+ t.Errorf("failed to match imported float value %v with expected %v for key %q", s, vv, kk)
+ }
+ case bool:
+ if b := strconv.FormatBool(pv); b != vv {
+ t.Errorf("failed to match imported bool value %v with expected %v for key %q", b, vv, kk)
+ }
+ default:
+ if pv != vv {
+ t.Errorf("failed to match imported string value %q with expected %q for key %q", pv, vv, kk)
+ }
+ }
+ }
+
+ // Since this was processed with coalescing there should be no null values.
+ // Here we verify that.
+ _, err := cc.PathValue("ensurenull")
+ if err == nil {
+ t.Error("expect nil value not found but found it")
+ }
+ switch xerr := err.(type) {
+ case ErrNoValue:
+ // We found what we expected
+ default:
+ t.Errorf("expected an ErrNoValue but got %q instead", xerr)
+ }
+
+ c = loadChart(t, "testdata/subpop")
+ if err := processDependencyImportValues(c, true); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+ cc = Values(c.Values)
+ val, err := cc.PathValue("ensurenull")
+ if err != nil {
+ t.Error("expect value but ensurenull was not found")
+ }
+ if val != nil {
+ t.Errorf("expect nil value but got %q instead", val)
+ }
+}
+
+func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T) {
+ c := loadChart(t, "testdata/chart-with-import-from-aliased-dependencies")
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+ if err := processDependencyImportValues(c, true); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+ e := make(map[string]string)
+
+ e["foo-defaults.defaultValue"] = "42"
+ e["bar-defaults.defaultValue"] = "42"
+
+ e["foo.defaults.defaultValue"] = "42"
+ e["bar.defaults.defaultValue"] = "42"
+
+ e["foo.grandchild.defaults.defaultValue"] = "42"
+ e["bar.grandchild.defaults.defaultValue"] = "42"
+
+ cValues := Values(c.Values)
+ for kk, vv := range e {
+ pv, err := cValues.PathValue(kk)
+ if err != nil {
+ t.Fatalf("retrieving import values table %v %v", kk, err)
+ }
+ if pv != vv {
+ t.Errorf("failed to match imported value %v with expected %v", pv, vv)
+ }
+ }
+}
+
+func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) {
+ c := loadChart(t, "testdata/three-level-dependent-chart/umbrella")
+
+ e := make(map[string]string)
+
+ // The order of precedence should be:
+ // 1. User specified values (e.g CLI)
+ // 2. Parent chart values
+ // 3. Imported values
+ // 4. Sub-chart values
+ // The 4 app charts here deal with things differently:
+ // - app1 has a port value set in the umbrella chart. It does not import any
+ // values so the value from the umbrella chart should be used.
+ // - app2 has a value in the app chart and imports from the library. The
+ // app chart value should take precedence.
+ // - app3 has no value in the app chart and imports the value from the library
+ // chart. The library chart value should be used.
+ // - app4 has a value in the app chart and does not import the value from the
+ // library chart. The app charts value should be used.
+ e["app1.service.port"] = "3456"
+ e["app2.service.port"] = "8080"
+ e["app3.service.port"] = "9090"
+ e["app4.service.port"] = "1234"
+ if err := processDependencyImportValues(c, true); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+ cc := Values(c.Values)
+ for kk, vv := range e {
+ pv, err := cc.PathValue(kk)
+ if err != nil {
+ t.Fatalf("retrieving import values table %v %v", kk, err)
+ }
+
+ switch pv := pv.(type) {
+ case float64:
+ if s := strconv.FormatFloat(pv, 'f', -1, 64); s != vv {
+ t.Errorf("failed to match imported float value %v with expected %v", s, vv)
+ }
+ default:
+ if pv != vv {
+ t.Errorf("failed to match imported string value %q with expected %q", pv, vv)
+ }
+ }
+ }
+}
+
+func TestProcessDependencyImportValuesForEnabledCharts(t *testing.T) {
+ c := loadChart(t, "testdata/import-values-from-enabled-subchart/parent-chart")
+ nameOverride := "parent-chart-prod"
+
+ if err := processDependencyImportValues(c, true); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 1 {
+ t.Fatal("expected no changes in dependencies")
+ }
+
+ if len(c.Metadata.Dependencies) != 1 {
+ t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies))
+ }
+
+ prodDependencyValues := c.Dependencies()[0].Values
+ if prodDependencyValues["nameOverride"] != nameOverride {
+ t.Fatalf("dependency chart name should be %s but got %s", nameOverride, prodDependencyValues["nameOverride"])
+ }
+}
+
+func TestGetAliasDependency(t *testing.T) {
+ c := loadChart(t, "testdata/frobnitz")
+ req := c.Metadata.Dependencies
+
+ if len(req) == 0 {
+ t.Fatalf("there are no dependencies to test")
+ }
+
+ // Success case
+ aliasChart := getAliasDependency(c.Dependencies(), req[0])
+ if aliasChart == nil {
+ t.Fatalf("failed to get dependency chart for alias %s", req[0].Name)
+ }
+ if req[0].Alias != "" {
+ if aliasChart.Name() != req[0].Alias {
+ t.Fatalf("dependency chart name should be %s but got %s", req[0].Alias, aliasChart.Name())
+ }
+ } else if aliasChart.Name() != req[0].Name {
+ t.Fatalf("dependency chart name should be %s but got %s", req[0].Name, aliasChart.Name())
+ }
+
+ if req[0].Version != "" {
+ if !IsCompatibleRange(req[0].Version, aliasChart.Metadata.Version) {
+ t.Fatalf("dependency chart version is not in the compatible range")
+ }
+ }
+
+ // Failure case
+ req[0].Name = "something-else"
+ if aliasChart := getAliasDependency(c.Dependencies(), req[0]); aliasChart != nil {
+ t.Fatalf("expected no chart but got %s", aliasChart.Name())
+ }
+
+ 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 ")
+ }
+}
+
+func TestDependentChartAliases(t *testing.T) {
+ c := loadChart(t, "testdata/dependent-chart-alias")
+ req := c.Metadata.Dependencies
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 3 {
+ t.Fatal("expected alias dependencies to be added")
+ }
+
+ if len(c.Dependencies()) != len(c.Metadata.Dependencies) {
+ t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies()))
+ }
+
+ aliasChart := getAliasDependency(c.Dependencies(), req[2])
+
+ if aliasChart == nil {
+ t.Fatalf("failed to get dependency chart for alias %s", req[2].Name)
+ }
+ if aliasChart.Parent() != c {
+ t.Fatalf("dependency chart has wrong parent, expected %s but got %s", c.Name(), aliasChart.Parent().Name())
+ }
+ if req[2].Alias != "" {
+ if aliasChart.Name() != req[2].Alias {
+ t.Fatalf("dependency chart name should be %s but got %s", req[2].Alias, aliasChart.Name())
+ }
+ } else if aliasChart.Name() != req[2].Name {
+ t.Fatalf("dependency chart name should be %s but got %s", req[2].Name, aliasChart.Name())
+ }
+
+ req[2].Name = "dummy-name"
+ if aliasChart := getAliasDependency(c.Dependencies(), req[2]); aliasChart != nil {
+ t.Fatalf("expected no chart but got %s", aliasChart.Name())
+ }
+
+}
+
+func TestDependentChartWithSubChartsAbsentInDependency(t *testing.T) {
+ c := loadChart(t, "testdata/dependent-chart-no-requirements-yaml")
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatal("expected no changes in dependencies")
+ }
+}
+
+func TestDependentChartWithSubChartsHelmignore(t *testing.T) {
+ // FIXME what does this test?
+ loadChart(t, "testdata/dependent-chart-helmignore")
+}
+
+func TestDependentChartsWithSubChartsSymlink(t *testing.T) {
+ joonix := filepath.Join("testdata", "joonix")
+ if err := os.Symlink(filepath.Join("..", "..", "frobnitz"), filepath.Join(joonix, "charts", "frobnitz")); err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(filepath.Join(joonix, "charts", "frobnitz"))
+ c := loadChart(t, joonix)
+
+ if c.Name() != "joonix" {
+ t.Fatalf("unexpected chart name: %s", c.Name())
+ }
+ if n := len(c.Dependencies()); n != 1 {
+ t.Fatalf("expected 1 dependency for this chart, but got %d", n)
+ }
+}
+
+func TestDependentChartsWithSubchartsAllSpecifiedInDependency(t *testing.T) {
+ c := loadChart(t, "testdata/dependent-chart-with-all-in-requirements-yaml")
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatal("expected no changes in dependencies")
+ }
+
+ if len(c.Dependencies()) != len(c.Metadata.Dependencies) {
+ t.Fatalf("expected number of chart dependencies %d, but got %d", len(c.Metadata.Dependencies), len(c.Dependencies()))
+ }
+}
+
+func TestDependentChartsWithSomeSubchartsSpecifiedInDependency(t *testing.T) {
+ c := loadChart(t, "testdata/dependent-chart-with-mixed-requirements-yaml")
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatalf("expected 2 dependencies for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatal("expected no changes in dependencies")
+ }
+
+ if len(c.Metadata.Dependencies) != 1 {
+ t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies))
+ }
+}
+
+func validateDependencyTree(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ for _, dependency := range c.Dependencies() {
+ if dependency.Parent() != c {
+ if dependency.Parent() != c {
+ t.Fatalf("dependency chart %s has wrong parent, expected %s but got %s", dependency.Name(), c.Name(), dependency.Parent().Name())
+ }
+ }
+ // recurse entire tree
+ validateDependencyTree(t, dependency)
+ }
+}
+
+func TestChartWithDependencyAliasedTwiceAndDoublyReferencedSubDependency(t *testing.T) {
+ c := loadChart(t, "testdata/chart-with-dependency-aliased-twice")
+
+ if len(c.Dependencies()) != 1 {
+ t.Fatalf("expected one dependency for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatal("expected two dependencies after processing aliases")
+ }
+ validateDependencyTree(t, c)
+}
diff --git a/internal/chart/v3/util/doc.go b/internal/chart/v3/util/doc.go
new file mode 100644
index 000000000..002d5babc
--- /dev/null
+++ b/internal/chart/v3/util/doc.go
@@ -0,0 +1,45 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+package util contains tools for working with charts.
+
+Charts are described in the chart package (pkg/chart).
+This package provides utilities for serializing and deserializing charts.
+
+A chart can be represented on the file system in one of two ways:
+
+ - As a directory that contains a Chart.yaml file and other chart things.
+ - As a tarred gzipped file containing a directory that then contains a
+ Chart.yaml file.
+
+This package provides utilities for working with those file formats.
+
+The preferred way of loading a chart is using 'loader.Load`:
+
+ chart, err := loader.Load(filename)
+
+This will attempt to discover whether the file at 'filename' is a directory or
+a chart archive. It will then load accordingly.
+
+For accepting raw compressed tar file data from an io.Reader, the
+'loader.LoadArchive()' will read in the data, uncompress it, and unpack it
+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"
diff --git a/pkg/chartutil/errors.go b/internal/chart/v3/util/errors.go
similarity index 84%
rename from pkg/chartutil/errors.go
rename to internal/chart/v3/util/errors.go
index fcdcc27ea..a175b9758 100644
--- a/pkg/chartutil/errors.go
+++ b/internal/chart/v3/util/errors.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"fmt"
@@ -33,3 +33,11 @@ type ErrNoValue struct {
}
func (e ErrNoValue) Error() string { return fmt.Sprintf("%q is not a value", e.Key) }
+
+type ErrInvalidChartName struct {
+ Name string
+}
+
+func (e ErrInvalidChartName) Error() string {
+ return fmt.Sprintf("%q is not a valid chart name", e.Name)
+}
diff --git a/pkg/chartutil/errors_test.go b/internal/chart/v3/util/errors_test.go
similarity index 97%
rename from pkg/chartutil/errors_test.go
rename to internal/chart/v3/util/errors_test.go
index 3f63e3733..b8ae86384 100644
--- a/pkg/chartutil/errors_test.go
+++ b/internal/chart/v3/util/errors_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"testing"
diff --git a/internal/chart/v3/util/expand.go b/internal/chart/v3/util/expand.go
new file mode 100644
index 000000000..6cbbeabf2
--- /dev/null
+++ b/internal/chart/v3/util/expand.go
@@ -0,0 +1,94 @@
+/*
+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 util
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ securejoin "github.com/cyphar/filepath-securejoin"
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/chart/v3/loader"
+)
+
+// Expand uncompresses and extracts a chart into the specified directory.
+func Expand(dir string, r io.Reader) error {
+ files, err := loader.LoadArchiveFiles(r)
+ if err != nil {
+ return err
+ }
+
+ // Get the name of the chart
+ var chartName string
+ for _, file := range files {
+ if file.Name == "Chart.yaml" {
+ ch := &chart.Metadata{}
+ if err := yaml.Unmarshal(file.Data, ch); err != nil {
+ return fmt.Errorf("cannot load Chart.yaml: %w", err)
+ }
+ chartName = ch.Name
+ }
+ }
+ if chartName == "" {
+ return errors.New("chart name not specified")
+ }
+
+ // 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.
+ dir = filepath.Clean(dir)
+ chartdir, err := securejoin.SecureJoin(dir, chartName)
+ if err != nil {
+ return err
+ }
+
+ // Copy all files verbatim. We don't parse these files because parsing can remove
+ // comments.
+ for _, file := range files {
+ outpath, err := securejoin.SecureJoin(chartdir, file.Name)
+ if err != nil {
+ return err
+ }
+
+ // Make sure the necessary subdirs get created.
+ basedir := filepath.Dir(outpath)
+ if err := os.MkdirAll(basedir, 0755); err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(outpath, file.Data, 0644); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// ExpandFile expands the src file into the dest directory.
+func ExpandFile(dest, src string) error {
+ h, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer h.Close()
+ return Expand(dest, h)
+}
diff --git a/pkg/chartutil/expand_test.go b/internal/chart/v3/util/expand_test.go
similarity index 95%
rename from pkg/chartutil/expand_test.go
rename to internal/chart/v3/util/expand_test.go
index f31a3d290..280995f7e 100644
--- a/pkg/chartutil/expand_test.go
+++ b/internal/chart/v3/util/expand_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"os"
@@ -64,7 +64,7 @@ func TestExpand(t *testing.T) {
t.Fatal(err)
}
// os.Stat can return different values for directories, based on the OS
- // for Linux, for example, os.Stat alwaty returns the size of the directory
+ // for Linux, for example, os.Stat always returns the size of the directory
// (value-4096) regardless of the size of the contents of the directory
mode := expect.Mode()
if !mode.IsDir() {
@@ -112,7 +112,7 @@ func TestExpandFile(t *testing.T) {
t.Fatal(err)
}
// os.Stat can return different values for directories, based on the OS
- // for Linux, for example, os.Stat alwaty returns the size of the directory
+ // for Linux, for example, os.Stat always returns the size of the directory
// (value-4096) regardless of the size of the contents of the directory
mode := expect.Mode()
if !mode.IsDir() {
diff --git a/pkg/chartutil/jsonschema.go b/internal/chart/v3/util/jsonschema.go
similarity index 52%
rename from pkg/chartutil/jsonschema.go
rename to internal/chart/v3/util/jsonschema.go
index 7b9768fd3..9fe35904e 100644
--- a/pkg/chartutil/jsonschema.go
+++ b/internal/chart/v3/util/jsonschema.go
@@ -14,31 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"bytes"
+ "errors"
"fmt"
+ "log/slog"
"strings"
- "github.com/pkg/errors"
- "github.com/xeipuuv/gojsonschema"
- "sigs.k8s.io/yaml"
+ "github.com/santhosh-tekuri/jsonschema/v6"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/internal/chart/v3"
)
// ValidateAgainstSchema checks that values does not violate the structure laid out in schema
func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error {
var sb strings.Builder
if chrt.Schema != nil {
+ slog.Debug("chart name", "chart-name", chrt.Name())
err := ValidateAgainstSingleSchema(values, chrt.Schema)
if err != nil {
sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name()))
sb.WriteString(err.Error())
}
}
-
+ slog.Debug("number of dependencies in the chart", "dependencies", len(chrt.Dependencies()))
// For each dependency, recursively call this function with the coalesced values
for _, subchart := range chrt.Dependencies() {
subchartValues := values[subchart.Name()].(map[string]interface{})
@@ -62,32 +63,51 @@ func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error
}
}()
- valuesData, err := yaml.Marshal(values)
+ // This unmarshal function leverages UseNumber() for number precision. The parser
+ // used for values does this as well.
+ schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
if err != nil {
return err
}
- valuesJSON, err := yaml.YAMLToJSON(valuesData)
+ slog.Debug("unmarshalled JSON schema", "schema", schemaJSON)
+
+ compiler := jsonschema.NewCompiler()
+ err = compiler.AddResource("file:///values.schema.json", schema)
if err != nil {
return err
}
- if bytes.Equal(valuesJSON, []byte("null")) {
- valuesJSON = []byte("{}")
- }
- schemaLoader := gojsonschema.NewBytesLoader(schemaJSON)
- valuesLoader := gojsonschema.NewBytesLoader(valuesJSON)
- result, err := gojsonschema.Validate(schemaLoader, valuesLoader)
+ validator, err := compiler.Compile("file:///values.schema.json")
if err != nil {
return err
}
- if !result.Valid() {
- var sb strings.Builder
- for _, desc := range result.Errors() {
- sb.WriteString(fmt.Sprintf("- %s\n", desc))
- }
- return errors.New(sb.String())
+ err = validator.Validate(values.AsMap())
+ if err != nil {
+ return JSONSchemaValidationError{err}
}
return nil
}
+
+// Note, JSONSchemaValidationError is used to wrap the error from the underlying
+// validation package so that Helm has a clean interface and the validation package
+// could be replaced without changing the Helm SDK API.
+
+// JSONSchemaValidationError is the error returned when there is a schema validation
+// error.
+type JSONSchemaValidationError struct {
+ embeddedErr error
+}
+
+// Error prints the error message
+func (e JSONSchemaValidationError) Error() string {
+ errStr := e.embeddedErr.Error()
+
+ // This string prefixes all of our error details. Further up the stack of helm error message
+ // building more detail is provided to users. This is removed.
+ errStr = strings.TrimPrefix(errStr, "jsonschema validation failed with 'file:///values.schema.json#'\n")
+
+ // The extra new line is needed for when there are sub-charts.
+ return errStr + "\n"
+}
diff --git a/pkg/chartutil/jsonschema_test.go b/internal/chart/v3/util/jsonschema_test.go
similarity index 63%
rename from pkg/chartutil/jsonschema_test.go
rename to internal/chart/v3/util/jsonschema_test.go
index 7610db337..0a3820377 100644
--- a/pkg/chartutil/jsonschema_test.go
+++ b/internal/chart/v3/util/jsonschema_test.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"os"
"testing"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/internal/chart/v3"
)
func TestValidateAgainstSingleSchema(t *testing.T) {
@@ -55,8 +55,8 @@ func TestValidateAgainstInvalidSingleSchema(t *testing.T) {
errString = err.Error()
}
- expectedErrString := "unable to validate schema: runtime error: invalid " +
- "memory address or nil pointer dereference"
+ expectedErrString := `"file:///values.schema.json#" is not valid against metaschema: jsonschema validation failed with 'https://json-schema.org/draft/2020-12/schema#'
+- at '': got number, want boolean or object`
if errString != expectedErrString {
t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
}
@@ -69,7 +69,7 @@ func TestValidateAgainstSingleSchemaNegative(t *testing.T) {
}
schema, err := os.ReadFile("./testdata/test-values.schema.json")
if err != nil {
- t.Fatalf("Error reading YAML file: %s", err)
+ t.Fatalf("Error reading JSON file: %s", err)
}
var errString string
@@ -79,8 +79,8 @@ func TestValidateAgainstSingleSchemaNegative(t *testing.T) {
errString = err.Error()
}
- expectedErrString := `- (root): employmentInfo is required
-- age: Must be greater than or equal to 0
+ expectedErrString := `- at '': missing property 'employmentInfo'
+- at '/age': minimum: got -5, want 0
`
if errString != expectedErrString {
t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
@@ -104,6 +104,21 @@ const subchartSchema = `{
}
`
+const subchartSchema2020 = `{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Values",
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "contains": { "type": "string" },
+ "unevaluatedItems": { "type": "number" }
+ }
+ },
+ "required": ["data"]
+}
+`
+
func TestValidateAgainstSchema(t *testing.T) {
subchartJSON := []byte(subchartSchema)
subchart := &chart.Chart{
@@ -159,7 +174,72 @@ func TestValidateAgainstSchemaNegative(t *testing.T) {
}
expectedErrString := `subchart:
-- (root): age is required
+- at '': missing property 'age'
+`
+ if errString != expectedErrString {
+ t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
+ }
+}
+
+func TestValidateAgainstSchema2020(t *testing.T) {
+ subchartJSON := []byte(subchartSchema2020)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{
+ "data": []any{"hello", 12},
+ },
+ }
+
+ if err := ValidateAgainstSchema(chrt, vals); err != nil {
+ t.Errorf("Error validating Values against Schema: %s", err)
+ }
+}
+
+func TestValidateAgainstSchema2020Negative(t *testing.T) {
+ subchartJSON := []byte(subchartSchema2020)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{
+ "data": []any{12},
+ },
+ }
+
+ var errString string
+ if err := ValidateAgainstSchema(chrt, vals); err == nil {
+ t.Fatalf("Expected an error, but got nil")
+ } else {
+ errString = err.Error()
+ }
+
+ expectedErrString := `subchart:
+- at '/data': no items match contains schema
+ - at '/data/0': got number, want string
`
if errString != expectedErrString {
t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
diff --git a/internal/chart/v3/util/save.go b/internal/chart/v3/util/save.go
new file mode 100644
index 000000000..3125cc3c9
--- /dev/null
+++ b/internal/chart/v3/util/save.go
@@ -0,0 +1,253 @@
+/*
+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 util
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "time"
+
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=")
+
+// SaveDir saves a chart as files in a directory.
+//
+// This takes the chart name, and creates a new subdirectory inside of the given dest
+// directory, writing the chart's contents to that subdirectory.
+func SaveDir(c *chart.Chart, dest string) error {
+ // Create the chart directory
+ err := validateName(c.Name())
+ if err != nil {
+ return err
+ }
+ outdir := filepath.Join(dest, c.Name())
+ if fi, err := os.Stat(outdir); err == nil && !fi.IsDir() {
+ return fmt.Errorf("file %s already exists and is not a directory", outdir)
+ }
+ if err := os.MkdirAll(outdir, 0755); err != nil {
+ return err
+ }
+
+ // Save the chart file.
+ if err := SaveChartfile(filepath.Join(outdir, ChartfileName), c.Metadata); err != nil {
+ return err
+ }
+
+ // Save values.yaml
+ for _, f := range c.Raw {
+ if f.Name == ValuesfileName {
+ vf := filepath.Join(outdir, ValuesfileName)
+ if err := writeFile(vf, f.Data); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Save values.schema.json if it exists
+ if c.Schema != nil {
+ filename := filepath.Join(outdir, SchemafileName)
+ if err := writeFile(filename, c.Schema); err != nil {
+ return err
+ }
+ }
+
+ // Save templates and files
+ for _, o := range [][]*chart.File{c.Templates, c.Files} {
+ for _, f := range o {
+ n := filepath.Join(outdir, f.Name)
+ if err := writeFile(n, f.Data); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Save dependencies
+ base := filepath.Join(outdir, ChartsDir)
+ for _, dep := range c.Dependencies() {
+ // Here, we write each dependency as a tar file.
+ if _, err := Save(dep, base); err != nil {
+ return fmt.Errorf("saving %s: %w", dep.ChartFullPath(), err)
+ }
+ }
+ return nil
+}
+
+// Save creates an archived chart to the given directory.
+//
+// This takes an existing chart and a destination directory.
+//
+// If the directory is /foo, and the chart is named bar, with version 1.0.0, this
+// will generate /foo/bar-1.0.0.tgz.
+//
+// This returns the absolute path to the chart archive file.
+func Save(c *chart.Chart, outDir string) (string, error) {
+ if err := c.Validate(); err != nil {
+ return "", fmt.Errorf("chart validation: %w", err)
+ }
+
+ filename := fmt.Sprintf("%s-%s.tgz", c.Name(), c.Metadata.Version)
+ filename = filepath.Join(outDir, filename)
+ dir := filepath.Dir(filename)
+ if stat, err := os.Stat(dir); err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ if err2 := os.MkdirAll(dir, 0755); err2 != nil {
+ return "", err2
+ }
+ } else {
+ return "", fmt.Errorf("stat %s: %w", dir, err)
+ }
+ } else if !stat.IsDir() {
+ return "", fmt.Errorf("is not a directory: %s", dir)
+ }
+
+ f, err := os.Create(filename)
+ if err != nil {
+ return "", err
+ }
+
+ // Wrap in gzip writer
+ zipper := gzip.NewWriter(f)
+ zipper.Extra = headerBytes
+ zipper.Comment = "Helm"
+
+ // Wrap in tar writer
+ twriter := tar.NewWriter(zipper)
+ rollback := false
+ defer func() {
+ twriter.Close()
+ zipper.Close()
+ f.Close()
+ if rollback {
+ os.Remove(filename)
+ }
+ }()
+
+ if err := writeTarContents(twriter, c, ""); err != nil {
+ rollback = true
+ return filename, err
+ }
+ return filename, nil
+}
+
+func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
+ err := validateName(c.Name())
+ if err != nil {
+ return err
+ }
+ base := filepath.Join(prefix, c.Name())
+
+ // Save Chart.yaml
+ cdata, err := yaml.Marshal(c.Metadata)
+ if err != nil {
+ return err
+ }
+ if err := writeToTar(out, filepath.Join(base, ChartfileName), cdata); err != nil {
+ return err
+ }
+
+ // Save Chart.lock
+ if c.Lock != nil {
+ ldata, err := yaml.Marshal(c.Lock)
+ if err != nil {
+ return err
+ }
+ if err := writeToTar(out, filepath.Join(base, "Chart.lock"), ldata); err != nil {
+ return err
+ }
+ }
+
+ // Save values.yaml
+ for _, f := range c.Raw {
+ if f.Name == ValuesfileName {
+ if err := writeToTar(out, filepath.Join(base, ValuesfileName), f.Data); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Save values.schema.json if it exists
+ if c.Schema != nil {
+ if !json.Valid(c.Schema) {
+ return errors.New("invalid JSON in " + SchemafileName)
+ }
+ if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil {
+ return err
+ }
+ }
+
+ // Save templates
+ for _, f := range c.Templates {
+ n := filepath.Join(base, f.Name)
+ if err := writeToTar(out, n, f.Data); err != nil {
+ return err
+ }
+ }
+
+ // Save files
+ for _, f := range c.Files {
+ n := filepath.Join(base, f.Name)
+ if err := writeToTar(out, n, f.Data); err != nil {
+ return err
+ }
+ }
+
+ // Save dependencies
+ for _, dep := range c.Dependencies() {
+ if err := writeTarContents(out, dep, filepath.Join(base, ChartsDir)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// writeToTar writes a single file to a tar archive.
+func writeToTar(out *tar.Writer, name string, body []byte) error {
+ // TODO: Do we need to create dummy parent directory names if none exist?
+ h := &tar.Header{
+ Name: filepath.ToSlash(name),
+ Mode: 0644,
+ Size: int64(len(body)),
+ ModTime: time.Now(),
+ }
+ if err := out.WriteHeader(h); err != nil {
+ return err
+ }
+ _, err := out.Write(body)
+ return err
+}
+
+// If the name has directory name has characters which would change the location
+// they need to be removed.
+func validateName(name string) error {
+ nname := filepath.Base(name)
+
+ if nname != name {
+ return ErrInvalidChartName{name}
+ }
+
+ return nil
+}
diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go
new file mode 100644
index 000000000..852675bb0
--- /dev/null
+++ b/internal/chart/v3/util/save_test.go
@@ -0,0 +1,261 @@
+/*
+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 util
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+ "helm.sh/helm/v4/internal/chart/v3/loader"
+)
+
+func TestSave(t *testing.T) {
+ tmp := t.TempDir()
+
+ for _, dest := range []string{tmp, filepath.Join(tmp, "newdir")} {
+ t.Run("outDir="+dest, func(t *testing.T) {
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV3,
+ Name: "ahab",
+ Version: "1.2.3",
+ },
+ Lock: &chart.Lock{
+ Digest: "testdigest",
+ },
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ Schema: []byte("{\n \"title\": \"Values\"\n}"),
+ }
+ chartWithInvalidJSON := withSchema(*c, []byte("{"))
+
+ where, err := Save(c, dest)
+ if err != nil {
+ t.Fatalf("Failed to save: %s", err)
+ }
+ if !strings.HasPrefix(where, dest) {
+ t.Fatalf("Expected %q to start with %q", where, dest)
+ }
+ if !strings.HasSuffix(where, ".tgz") {
+ t.Fatalf("Expected %q to end with .tgz", where)
+ }
+
+ c2, err := loader.LoadFile(where)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c2.Name() != c.Name() {
+ t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name())
+ }
+ if len(c2.Files) != 1 || c2.Files[0].Name != "scheherazade/shahryar.txt" {
+ t.Fatal("Files data did not match")
+ }
+
+ if !bytes.Equal(c.Schema, c2.Schema) {
+ indentation := 4
+ formattedExpected := Indent(indentation, string(c.Schema))
+ formattedActual := Indent(indentation, string(c2.Schema))
+ 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")
+ }
+
+ c.Metadata.APIVersion = chart.APIVersionV3
+ where, err = Save(c, dest)
+ if err != nil {
+ t.Fatalf("Failed to save: %s", err)
+ }
+ c2, err = loader.LoadFile(where)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c2.Lock == nil {
+ t.Fatal("Expected v3 chart archive to contain a Chart.lock file")
+ }
+ if c2.Lock.Digest != c.Lock.Digest {
+ t.Fatal("Chart.lock data did not match")
+ }
+ })
+ }
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV3,
+ Name: "../ahab",
+ Version: "1.2.3",
+ },
+ Lock: &chart.Lock{
+ Digest: "testdigest",
+ },
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ }
+ _, err := Save(c, tmp)
+ if err == nil {
+ t.Fatal("Expected error saving chart with invalid name")
+ }
+}
+
+// Creates a copy with a different schema; does not modify anything.
+func withSchema(chart chart.Chart, schema []byte) chart.Chart {
+ chart.Schema = schema
+ return chart
+}
+
+func Indent(n int, text string) string {
+ startOfLine := regexp.MustCompile(`(?m)^`)
+ indentation := strings.Repeat(" ", n)
+ return startOfLine.ReplaceAllLiteralString(text, indentation)
+}
+
+func TestSavePreservesTimestamps(t *testing.T) {
+ // Test executes so quickly that if we don't subtract a second, the
+ // check will fail because `initialCreateTime` will be identical to the
+ // written timestamp for the files.
+ initialCreateTime := time.Now().Add(-1 * time.Second)
+
+ tmp := t.TempDir()
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV3,
+ Name: "ahab",
+ Version: "1.2.3",
+ },
+ Values: map[string]interface{}{
+ "imageName": "testimage",
+ "imageId": 42,
+ },
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ Schema: []byte("{\n \"title\": \"Values\"\n}"),
+ }
+
+ where, err := Save(c, tmp)
+ if err != nil {
+ t.Fatalf("Failed to save: %s", err)
+ }
+
+ allHeaders, err := retrieveAllHeadersFromTar(where)
+ if err != nil {
+ t.Fatalf("Failed to parse tar: %v", err)
+ }
+
+ for _, header := range allHeaders {
+ if header.ModTime.Before(initialCreateTime) {
+ t.Fatalf("File timestamp not preserved: %v", header.ModTime)
+ }
+ }
+}
+
+// We could refactor `load.go` to use this `retrieveAllHeadersFromTar` function
+// as well, so we are not duplicating components of the code which iterate
+// through the tar.
+func retrieveAllHeadersFromTar(path string) ([]*tar.Header, error) {
+ raw, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer raw.Close()
+
+ unzipped, err := gzip.NewReader(raw)
+ if err != nil {
+ return nil, err
+ }
+ defer unzipped.Close()
+
+ tr := tar.NewReader(unzipped)
+ headers := []*tar.Header{}
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ headers = append(headers, hd)
+ }
+
+ return headers, nil
+}
+
+func TestSaveDir(t *testing.T) {
+ tmp := t.TempDir()
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV3,
+ Name: "ahab",
+ Version: "1.2.3",
+ },
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ Templates: []*chart.File{
+ {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")},
+ },
+ }
+
+ if err := SaveDir(c, tmp); err != nil {
+ t.Fatalf("Failed to save: %s", err)
+ }
+
+ c2, err := loader.LoadDir(tmp + "/ahab")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if c2.Name() != c.Name() {
+ t.Fatalf("Expected chart archive to have %q, got %q", c.Name(), c2.Name())
+ }
+
+ if len(c2.Templates) != 1 || c2.Templates[0].Name != c.Templates[0].Name {
+ t.Fatal("Templates data did not match")
+ }
+
+ if len(c2.Files) != 1 || c2.Files[0].Name != c.Files[0].Name {
+ t.Fatal("Files data did not match")
+ }
+
+ tmp2 := t.TempDir()
+ c.Metadata.Name = "../ahab"
+ pth := filepath.Join(tmp2, "tmpcharts")
+ if err := os.MkdirAll(filepath.Join(pth), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := SaveDir(c, pth); err.Error() != "\"../ahab\" is not a valid chart name" {
+ t.Fatalf("Did not get expected error for chart named %q", c.Name())
+ }
+}
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml
new file mode 100644
index 000000000..4a4da7996
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml
@@ -0,0 +1,14 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: chart-with-dependency-aliased-twice
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: child
+ alias: foo
+ version: 1.0.0
+ - name: child
+ alias: bar
+ version: 1.0.0
+
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml
new file mode 100644
index 000000000..0f3afd8c6
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: child
+type: application
+version: 1.0.0
+
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml
new file mode 100644
index 000000000..3e0bf725b
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: grandchild
+type: application
+version: 1.0.0
+
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml
new file mode 100644
index 000000000..1830492ef
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}-{{ .Values.from }}
+data:
+ {{- toYaml .Values | nindent 2 }}
+
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml
new file mode 100644
index 000000000..b5d55af7c
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{- toYaml .Values | nindent 2 }}
+
diff --git a/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml
new file mode 100644
index 000000000..695521a4a
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-dependency-aliased-twice/values.yaml
@@ -0,0 +1,7 @@
+foo:
+ grandchild:
+ from: foo
+bar:
+ grandchild:
+ from: bar
+
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml
new file mode 100644
index 000000000..f2f0610b5
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml
@@ -0,0 +1,20 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: chart-with-dependency-aliased-twice
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: child
+ alias: foo
+ version: 1.0.0
+ import-values:
+ - parent: foo-defaults
+ child: defaults
+ - name: child
+ alias: bar
+ version: 1.0.0
+ import-values:
+ - parent: bar-defaults
+ child: defaults
+
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml
new file mode 100644
index 000000000..08ccac9e5
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml
@@ -0,0 +1,12 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: child
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: grandchild
+ version: 1.0.0
+ import-values:
+ - parent: defaults
+ child: defaults
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml
new file mode 100644
index 000000000..3e0bf725b
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v3
+appVersion: 1.0.0
+name: grandchild
+type: application
+version: 1.0.0
+
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml
new file mode 100644
index 000000000..f51c594f4
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml
@@ -0,0 +1,2 @@
+defaults:
+ defaultValue: "42"
\ No newline at end of file
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml
new file mode 100644
index 000000000..3140f53dd
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{ .Values.defaults | toYaml }}
+
diff --git a/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml
new file mode 100644
index 000000000..a2b62c95a
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{ toYaml .Values.defaults | indent 2 }}
+
diff --git a/internal/chart/v3/util/testdata/chartfiletest.yaml b/internal/chart/v3/util/testdata/chartfiletest.yaml
new file mode 100644
index 000000000..d222c8f8d
--- /dev/null
+++ b/internal/chart/v3/util/testdata/chartfiletest.yaml
@@ -0,0 +1,20 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
diff --git a/pkg/chartutil/testdata/coleridge.yaml b/internal/chart/v3/util/testdata/coleridge.yaml
similarity index 100%
rename from pkg/chartutil/testdata/coleridge.yaml
rename to internal/chart/v3/util/testdata/coleridge.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore b/internal/chart/v3/util/testdata/dependent-chart-alias/.helmignore
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/.helmignore
rename to internal/chart/v3/util/testdata/dependent-chart-alias/.helmignore
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock b/internal/chart/v3/util/testdata/dependent-chart-alias/Chart.lock
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.lock
rename to internal/chart/v3/util/testdata/dependent-chart-alias/Chart.lock
diff --git a/internal/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml
new file mode 100644
index 000000000..b8773d0d3
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-alias/Chart.yaml
@@ -0,0 +1,29 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+ alias: mariners2
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+ alias: mariners1
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt b/internal/chart/v3/util/testdata/dependent-chart-alias/INSTALL.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/INSTALL.txt
rename to internal/chart/v3/util/testdata/dependent-chart-alias/INSTALL.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE b/internal/chart/v3/util/testdata/dependent-chart-alias/LICENSE
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/LICENSE
rename to internal/chart/v3/util/testdata/dependent-chart-alias/LICENSE
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/README.md b/internal/chart/v3/util/testdata/dependent-chart-alias/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-alias/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/_ignore_me
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/_ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz b/internal/chart/v3/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md b/internal/chart/v3/util/testdata/dependent-chart-alias/docs/README.md
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/docs/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-alias/docs/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg b/internal/chart/v3/util/testdata/dependent-chart-alias/icon.svg
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/icon.svg
rename to internal/chart/v3/util/testdata/dependent-chart-alias/icon.svg
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt b/internal/chart/v3/util/testdata/dependent-chart-alias/ignore/me.txt
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/ignore/me.txt
rename to internal/chart/v3/util/testdata/dependent-chart-alias/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl b/internal/chart/v3/util/testdata/dependent-chart-alias/templates/template.tpl
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/templates/template.tpl
rename to internal/chart/v3/util/testdata/dependent-chart-alias/templates/template.tpl
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-alias/values.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-alias/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore b/internal/chart/v3/util/testdata/dependent-chart-helmignore/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/.helmignore
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/.helmignore
diff --git a/internal/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml
new file mode 100644
index 000000000..8b4ad8cdd
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-helmignore/Chart.yaml
@@ -0,0 +1,17 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/.ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/.ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/.ignore_me
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/_ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl b/internal/chart/v3/util/testdata/dependent-chart-helmignore/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/templates/template.tpl
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/templates/template.tpl
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-helmignore/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-helmignore/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/.helmignore b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/.helmignore
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/.helmignore
diff --git a/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml
new file mode 100644
index 000000000..8b4ad8cdd
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml
@@ -0,0 +1,17 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/INSTALL.txt
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/LICENSE b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/LICENSE
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/LICENSE
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/LICENSE
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/README.md b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/_ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/docs/README.md b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/docs/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/icon.svg b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/icon.svg
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/icon.svg
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/icon.svg
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/ignore/me.txt
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/templates/template.tpl
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-no-requirements-yaml/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/.helmignore
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml
new file mode 100644
index 000000000..06283093e
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml
@@ -0,0 +1,24 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/LICENSE
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz
old mode 100755
new mode 100644
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/docs/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/icon.svg
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml
new file mode 100644
index 000000000..6543799d0
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml
@@ -0,0 +1,21 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml b/internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml
rename to internal/chart/v3/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml
diff --git a/pkg/chartutil/testdata/frobnitz-1.2.3.tgz b/internal/chart/v3/util/testdata/frobnitz-1.2.3.tgz
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz-1.2.3.tgz
rename to internal/chart/v3/util/testdata/frobnitz-1.2.3.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore b/internal/chart/v3/util/testdata/frobnitz/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore
rename to internal/chart/v3/util/testdata/frobnitz/.helmignore
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/Chart.lock b/internal/chart/v3/util/testdata/frobnitz/Chart.lock
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/Chart.lock
rename to internal/chart/v3/util/testdata/frobnitz/Chart.lock
diff --git a/internal/chart/v3/util/testdata/frobnitz/Chart.yaml b/internal/chart/v3/util/testdata/frobnitz/Chart.yaml
new file mode 100644
index 000000000..1b63fc3e2
--- /dev/null
+++ b/internal/chart/v3/util/testdata/frobnitz/Chart.yaml
@@ -0,0 +1,27 @@
+apiVersion: v3
+name: frobnitz
+description: This is a frobnitz.
+version: "1.2.3"
+keywords:
+ - frobnitz
+ - sprocket
+ - dodad
+maintainers:
+ - name: The Helm Team
+ email: helm@example.com
+ - name: Someone Else
+ email: nobody@example.com
+sources:
+ - https://example.com/foo/bar
+home: http://example.com
+icon: https://example.com/64x64.png
+annotations:
+ extrakey: extravalue
+ anotherkey: anothervalue
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt b/internal/chart/v3/util/testdata/frobnitz/INSTALL.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt
rename to internal/chart/v3/util/testdata/frobnitz/INSTALL.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE b/internal/chart/v3/util/testdata/frobnitz/LICENSE
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE
rename to internal/chart/v3/util/testdata/frobnitz/LICENSE
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md b/internal/chart/v3/util/testdata/frobnitz/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/README.md
rename to internal/chart/v3/util/testdata/frobnitz/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me b/internal/chart/v3/util/testdata/frobnitz/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me
rename to internal/chart/v3/util/testdata/frobnitz/charts/_ignore_me
diff --git a/internal/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml
new file mode 100644
index 000000000..2a2c9c883
--- /dev/null
+++ b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: alpine
+description: Deploy a basic Alpine Linux pod
+version: 0.1.0
+home: https://helm.sh/helm
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md
rename to internal/chart/v3/util/testdata/frobnitz/charts/alpine/README.md
diff --git a/internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
new file mode 100644
index 000000000..aea109c75
--- /dev/null
+++ b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: mast1
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml
rename to internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
rename to internal/chart/v3/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
rename to internal/chart/v3/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml
rename to internal/chart/v3/util/testdata/frobnitz/charts/alpine/values.yaml
diff --git a/internal/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml
new file mode 100644
index 000000000..4d3eea730
--- /dev/null
+++ b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/Chart.yaml
@@ -0,0 +1,9 @@
+apiVersion: v3
+name: mariner
+description: A Helm chart for Kubernetes
+version: 4.3.2
+home: ""
+dependencies:
+ - name: albatross
+ repository: https://example.com/mariner/charts
+ version: "0.1.0"
diff --git a/internal/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml
new file mode 100644
index 000000000..da605991b
--- /dev/null
+++ b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: albatross
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml
rename to internal/chart/v3/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/mariner/templates/placeholder.tpl
rename to internal/chart/v3/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl
diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml b/internal/chart/v3/util/testdata/frobnitz/charts/mariner/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/mariner/values.yaml
rename to internal/chart/v3/util/testdata/frobnitz/charts/mariner/values.yaml
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md b/internal/chart/v3/util/testdata/frobnitz/docs/README.md
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md
rename to internal/chart/v3/util/testdata/frobnitz/docs/README.md
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg b/internal/chart/v3/util/testdata/frobnitz/icon.svg
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg
rename to internal/chart/v3/util/testdata/frobnitz/icon.svg
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt b/internal/chart/v3/util/testdata/frobnitz/ignore/me.txt
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt
rename to internal/chart/v3/util/testdata/frobnitz/ignore/me.txt
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl b/internal/chart/v3/util/testdata/frobnitz/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl
rename to internal/chart/v3/util/testdata/frobnitz/templates/template.tpl
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml b/internal/chart/v3/util/testdata/frobnitz/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml
rename to internal/chart/v3/util/testdata/frobnitz/values.yaml
diff --git a/pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz b/internal/chart/v3/util/testdata/frobnitz_backslash-1.2.3.tgz
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz_backslash-1.2.3.tgz
rename to internal/chart/v3/util/testdata/frobnitz_backslash-1.2.3.tgz
diff --git a/pkg/chart/loader/testdata/genfrob.sh b/internal/chart/v3/util/testdata/genfrob.sh
similarity index 100%
rename from pkg/chart/loader/testdata/genfrob.sh
rename to internal/chart/v3/util/testdata/genfrob.sh
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock
diff --git a/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml
new file mode 100644
index 000000000..0b3e9958b
--- /dev/null
+++ b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml
@@ -0,0 +1,22 @@
+apiVersion: v3
+name: parent-chart
+version: v0.1.0
+appVersion: v0.1.0
+dependencies:
+ - name: dev
+ repository: "file://envs/dev"
+ version: ">= 0.0.1"
+ condition: dev.enabled,global.dev.enabled
+ tags:
+ - dev
+ import-values:
+ - data
+
+ - name: prod
+ repository: "file://envs/prod"
+ version: ">= 0.0.1"
+ condition: prod.enabled,global.prod.enabled
+ tags:
+ - prod
+ import-values:
+ - data
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz
diff --git a/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml
new file mode 100644
index 000000000..72427c097
--- /dev/null
+++ b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+name: dev
+version: v0.1.0
+appVersion: v0.1.0
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml
diff --git a/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml
new file mode 100644
index 000000000..058ab3942
--- /dev/null
+++ b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+name: prod
+version: v0.1.0
+appVersion: v0.1.0
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml b/internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml
rename to internal/chart/v3/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml
diff --git a/internal/chart/v3/util/testdata/joonix/Chart.yaml b/internal/chart/v3/util/testdata/joonix/Chart.yaml
new file mode 100644
index 000000000..1860a3df1
--- /dev/null
+++ b/internal/chart/v3/util/testdata/joonix/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: joonix
+version: 1.2.3
diff --git a/pkg/chartutil/testdata/joonix/charts/.gitkeep b/internal/chart/v3/util/testdata/joonix/charts/.gitkeep
similarity index 100%
rename from pkg/chartutil/testdata/joonix/charts/.gitkeep
rename to internal/chart/v3/util/testdata/joonix/charts/.gitkeep
diff --git a/internal/chart/v3/util/testdata/subpop/Chart.yaml b/internal/chart/v3/util/testdata/subpop/Chart.yaml
new file mode 100644
index 000000000..53e9ec502
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/Chart.yaml
@@ -0,0 +1,41 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: parentchart
+version: 0.1.0
+dependencies:
+ - name: subchart1
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchart1.enabled
+ tags:
+ - front-end
+ - subchart1
+ import-values:
+ - child: SC1data
+ parent: imported-chart1
+ - child: SC1data
+ parent: overridden-chart1
+ - child: imported-chartA
+ parent: imported-chartA
+ - child: imported-chartA-B
+ parent: imported-chartA-B
+ - child: overridden-chartA-B
+ parent: overridden-chartA-B
+ - child: SCBexported1A
+ parent: .
+ - SCBexported2
+ - SC1exported1
+
+ - name: subchart2
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchart2.enabled
+ tags:
+ - back-end
+ - subchart2
+
+ - name: subchart2
+ alias: subchart2alias
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchart2alias.enabled
diff --git a/pkg/chartutil/testdata/subpop/README.md b/internal/chart/v3/util/testdata/subpop/README.md
similarity index 100%
rename from pkg/chartutil/testdata/subpop/README.md
rename to internal/chart/v3/util/testdata/subpop/README.md
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml
new file mode 100644
index 000000000..1539fb97d
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart1/Chart.yaml
@@ -0,0 +1,36 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subchart1
+version: 0.1.0
+dependencies:
+ - name: subcharta
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subcharta.enabled
+ tags:
+ - front-end
+ - subcharta
+ import-values:
+ - child: SCAdata
+ parent: imported-chartA
+ - child: SCAdata
+ parent: overridden-chartA
+ - child: SCAdata
+ parent: imported-chartA-B
+
+ - name: subchartb
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchartb.enabled
+ import-values:
+ - child: SCBdata
+ parent: imported-chartB
+ - child: SCBdata
+ parent: imported-chartA-B
+ - child: exports.SCBexported2
+ parent: exports.SCBexported2
+ - SCBexported1
+
+ tags:
+ - front-end
+ - subchartb
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml
new file mode 100644
index 000000000..2755a821b
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subcharta
+version: 0.1.0
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartA/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml
new file mode 100644
index 000000000..bf12fe8f3
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subchartb
+version: 0.1.0
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/crds/crdA.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/crds/crdA.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/crds/crdA.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt b/internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/NOTES.txt
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/role.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart1/values.yaml
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml
new file mode 100644
index 000000000..e77657040
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart2/Chart.yaml
@@ -0,0 +1,19 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subchart2
+version: 0.1.0
+dependencies:
+ - name: subchartb
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchartb.enabled
+ tags:
+ - back-end
+ - subchartb
+ - name: subchartc
+ repository: http://localhost:10191
+ version: 0.1.0
+ condition: subchartc.enabled
+ tags:
+ - back-end
+ - subchartc
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml
new file mode 100644
index 000000000..bf12fe8f3
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subchartb
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
similarity index 85%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
index 3f168bdbf..fb3dfc445 100644
--- a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
@@ -3,7 +3,7 @@ kind: Service
metadata:
name: subchart2-{{ .Chart.Name }}
labels:
- helm.sh/hart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
spec:
type: {{ .Values.service.type }}
ports:
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml
diff --git a/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml
new file mode 100644
index 000000000..e8c0ef5e5
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: subchartc
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml b/internal/chart/v3/util/testdata/subpop/charts/subchart2/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/values.yaml
rename to internal/chart/v3/util/testdata/subpop/charts/subchart2/values.yaml
diff --git a/internal/chart/v3/util/testdata/subpop/noreqs/Chart.yaml b/internal/chart/v3/util/testdata/subpop/noreqs/Chart.yaml
new file mode 100644
index 000000000..09eb05a96
--- /dev/null
+++ b/internal/chart/v3/util/testdata/subpop/noreqs/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v3
+description: A Helm chart for Kubernetes
+name: parentchart
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml b/internal/chart/v3/util/testdata/subpop/noreqs/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml
rename to internal/chart/v3/util/testdata/subpop/noreqs/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/noreqs/values.yaml b/internal/chart/v3/util/testdata/subpop/noreqs/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/noreqs/values.yaml
rename to internal/chart/v3/util/testdata/subpop/noreqs/values.yaml
diff --git a/pkg/chartutil/testdata/subpop/values.yaml b/internal/chart/v3/util/testdata/subpop/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/values.yaml
rename to internal/chart/v3/util/testdata/subpop/values.yaml
diff --git a/pkg/chartutil/testdata/test-values-invalid.schema.json b/internal/chart/v3/util/testdata/test-values-invalid.schema.json
similarity index 100%
rename from pkg/chartutil/testdata/test-values-invalid.schema.json
rename to internal/chart/v3/util/testdata/test-values-invalid.schema.json
diff --git a/pkg/chartutil/testdata/test-values-negative.yaml b/internal/chart/v3/util/testdata/test-values-negative.yaml
similarity index 100%
rename from pkg/chartutil/testdata/test-values-negative.yaml
rename to internal/chart/v3/util/testdata/test-values-negative.yaml
diff --git a/pkg/chartutil/testdata/test-values.schema.json b/internal/chart/v3/util/testdata/test-values.schema.json
similarity index 100%
rename from pkg/chartutil/testdata/test-values.schema.json
rename to internal/chart/v3/util/testdata/test-values.schema.json
diff --git a/pkg/chartutil/testdata/test-values.yaml b/internal/chart/v3/util/testdata/test-values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/test-values.yaml
rename to internal/chart/v3/util/testdata/test-values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/README.md b/internal/chart/v3/util/testdata/three-level-dependent-chart/README.md
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/README.md
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/README.md
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml
new file mode 100644
index 000000000..1026f8901
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml
@@ -0,0 +1,19 @@
+apiVersion: v3
+name: umbrella
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
+
+dependencies:
+- name: app1
+ version: 0.1.0
+ condition: app1.enabled
+- name: app2
+ version: 0.1.0
+ condition: app2.enabled
+- name: app3
+ version: 0.1.0
+ condition: app3.enabled
+- name: app4
+ version: 0.1.0
+ condition: app4.enabled
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml
new file mode 100644
index 000000000..5bdf21570
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml
@@ -0,0 +1,11 @@
+apiVersion: v3
+name: app1
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
+
+dependencies:
+- name: library
+ version: 0.1.0
+ import-values:
+ - defaults
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml
new file mode 100644
index 000000000..9bc306361
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: library
+description: A Helm chart for Kubernetes
+type: library
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml
new file mode 100644
index 000000000..1313ce4e9
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml
@@ -0,0 +1,11 @@
+apiVersion: v3
+name: app2
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
+
+dependencies:
+- name: library
+ version: 0.1.0
+ import-values:
+ - defaults
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml
new file mode 100644
index 000000000..9bc306361
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: library
+description: A Helm chart for Kubernetes
+type: library
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml
new file mode 100644
index 000000000..1a80533d0
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml
@@ -0,0 +1,11 @@
+apiVersion: v3
+name: app3
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
+
+dependencies:
+- name: library
+ version: 0.1.0
+ import-values:
+ - defaults
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml
new file mode 100644
index 000000000..9bc306361
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: library
+description: A Helm chart for Kubernetes
+type: library
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml
new file mode 100644
index 000000000..886b4b1e4
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml
@@ -0,0 +1,9 @@
+apiVersion: v3
+name: app4
+description: A Helm chart for Kubernetes
+type: application
+version: 0.1.0
+
+dependencies:
+- name: library
+ version: 0.1.0
diff --git a/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml
new file mode 100644
index 000000000..9bc306361
--- /dev/null
+++ b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v3
+name: library
+description: A Helm chart for Kubernetes
+type: library
+version: 0.1.0
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml b/internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/values.yaml
rename to internal/chart/v3/util/testdata/three-level-dependent-chart/umbrella/values.yaml
diff --git a/pkg/chartutil/validate_name.go b/internal/chart/v3/util/validate_name.go
similarity index 98%
rename from pkg/chartutil/validate_name.go
rename to internal/chart/v3/util/validate_name.go
index 05c090cb6..6595e085d 100644
--- a/pkg/chartutil/validate_name.go
+++ b/internal/chart/v3/util/validate_name.go
@@ -14,13 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
+ "errors"
"fmt"
"regexp"
-
- "github.com/pkg/errors"
)
// validName is a regular expression for resource names.
diff --git a/pkg/chartutil/validate_name_test.go b/internal/chart/v3/util/validate_name_test.go
similarity index 97%
rename from pkg/chartutil/validate_name_test.go
rename to internal/chart/v3/util/validate_name_test.go
index 5f0792f94..cfc62a0f7 100644
--- a/pkg/chartutil/validate_name_test.go
+++ b/internal/chart/v3/util/validate_name_test.go
@@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import "testing"
-// TestValidateName is a regression test for ValidateName
+// TestValidateReleaseName is a regression test for ValidateName
//
// Kubernetes has strict naming conventions for resource names. This test represents
// those conventions.
diff --git a/internal/chart/v3/util/values.go b/internal/chart/v3/util/values.go
new file mode 100644
index 000000000..8e1a14b45
--- /dev/null
+++ b/internal/chart/v3/util/values.go
@@ -0,0 +1,220 @@
+/*
+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 util
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+// GlobalKey is the name of the Values key that is used for storing global vars.
+const GlobalKey = "global"
+
+// Values represents a collection of chart values.
+type Values map[string]interface{}
+
+// YAML encodes the Values into a YAML string.
+func (v Values) YAML() (string, error) {
+ b, err := yaml.Marshal(v)
+ return string(b), err
+}
+
+// Table gets a table (YAML subsection) from a Values object.
+//
+// The table is returned as a Values.
+//
+// Compound table names may be specified with dots:
+//
+// foo.bar
+//
+// The above will be evaluated as "The table bar inside the table
+// foo".
+//
+// An ErrNoTable is returned if the table does not exist.
+func (v Values) Table(name string) (Values, error) {
+ table := v
+ var err error
+
+ for _, n := range parsePath(name) {
+ if table, err = tableLookup(table, n); err != nil {
+ break
+ }
+ }
+ return table, err
+}
+
+// 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{} {
+ if len(v) == 0 {
+ return map[string]interface{}{}
+ }
+ return v
+}
+
+// Encode writes serialized Values information to the given io.Writer.
+func (v Values) Encode(w io.Writer) error {
+ out, err := yaml.Marshal(v)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(out)
+ return err
+}
+
+func tableLookup(v Values, simple string) (Values, error) {
+ v2, ok := v[simple]
+ if !ok {
+ return v, ErrNoTable{simple}
+ }
+ if vv, ok := v2.(map[string]interface{}); ok {
+ return vv, nil
+ }
+
+ // This catches a case where a value is of type Values, but doesn't (for some
+ // reason) match the map[string]interface{}. This has been observed in the
+ // wild, and might be a result of a nil map of type Values.
+ if vv, ok := v2.(Values); ok {
+ return vv, nil
+ }
+
+ return Values{}, ErrNoTable{simple}
+}
+
+// ReadValues will parse YAML byte data into a Values.
+func ReadValues(data []byte) (vals Values, err error) {
+ err = yaml.Unmarshal(data, &vals)
+ if len(vals) == 0 {
+ vals = Values{}
+ }
+ return vals, err
+}
+
+// ReadValuesFile will parse a YAML file into a map of values.
+func ReadValuesFile(filename string) (Values, error) {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return map[string]interface{}{}, err
+ }
+ return ReadValues(data)
+}
+
+// ReleaseOptions represents the additional release options needed
+// for the composition of the final values struct
+type ReleaseOptions struct {
+ Name string
+ Namespace string
+ Revision int
+ IsUpgrade bool
+ IsInstall bool
+}
+
+// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files
+//
+// This takes both ReleaseOptions and Capabilities to merge into the render values.
+func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities) (Values, error) {
+ return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false)
+}
+
+// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files
+//
+// This takes both ReleaseOptions and Capabilities to merge into the render values.
+func ToRenderValuesWithSchemaValidation(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities, skipSchemaValidation bool) (Values, error) {
+ if caps == nil {
+ caps = DefaultCapabilities
+ }
+ top := map[string]interface{}{
+ "Chart": chrt.Metadata,
+ "Capabilities": caps,
+ "Release": map[string]interface{}{
+ "Name": options.Name,
+ "Namespace": options.Namespace,
+ "IsUpgrade": options.IsUpgrade,
+ "IsInstall": options.IsInstall,
+ "Revision": options.Revision,
+ "Service": "Helm",
+ },
+ }
+
+ vals, err := CoalesceValues(chrt, chrtVals)
+ if err != nil {
+ return top, err
+ }
+
+ if !skipSchemaValidation {
+ if err := ValidateAgainstSchema(chrt, vals); err != nil {
+ return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err)
+ }
+ }
+
+ top["Values"] = vals
+ return top, nil
+}
+
+// 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{})
+ return ok
+}
+
+// PathValue takes a path that traverses a YAML structure and returns the value at the end of that path.
+// The path starts at the root of the YAML structure and is comprised of YAML keys separated by periods.
+// Given the following YAML data the value at path "chapter.one.title" is "Loomings".
+//
+// chapter:
+// one:
+// title: "Loomings"
+func (v Values) PathValue(path string) (interface{}, 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) {
+ if len(path) == 1 {
+ // if exists must be root key not table
+ if _, ok := v[path[0]]; ok && !istable(v[path[0]]) {
+ return v[path[0]], nil
+ }
+ return nil, ErrNoValue{path[0]}
+ }
+
+ key, path := path[len(path)-1], path[:len(path)-1]
+ // get our table for table path
+ t, err := v.Table(joinPath(path...))
+ if err != nil {
+ return nil, ErrNoValue{key}
+ }
+ // check table for key and ensure value is not a table
+ if k, ok := t[key]; ok && !istable(k) {
+ return k, nil
+ }
+ return nil, ErrNoValue{key}
+}
+
+func parsePath(key string) []string { return strings.Split(key, ".") }
+
+func joinPath(path ...string) string { return strings.Join(path, ".") }
diff --git a/internal/chart/v3/util/values_test.go b/internal/chart/v3/util/values_test.go
new file mode 100644
index 000000000..34c664581
--- /dev/null
+++ b/internal/chart/v3/util/values_test.go
@@ -0,0 +1,293 @@
+/*
+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 util
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+ "text/template"
+
+ chart "helm.sh/helm/v4/internal/chart/v3"
+)
+
+func TestReadValues(t *testing.T) {
+ doc := `# Test YAML parse
+poet: "Coleridge"
+title: "Rime of the Ancient Mariner"
+stanza:
+ - "at"
+ - "length"
+ - "did"
+ - cross
+ - an
+ - Albatross
+
+mariner:
+ with: "crossbow"
+ shot: "ALBATROSS"
+
+water:
+ water:
+ where: "everywhere"
+ nor: "any drop to drink"
+`
+
+ data, err := ReadValues([]byte(doc))
+ if err != nil {
+ t.Fatalf("Error parsing bytes: %s", err)
+ }
+ matchValues(t, data)
+
+ tests := []string{`poet: "Coleridge"`, "# Just a comment", ""}
+
+ for _, tt := range tests {
+ data, err = ReadValues([]byte(tt))
+ if err != nil {
+ t.Fatalf("Error parsing bytes (%s): %s", tt, err)
+ }
+ if data == nil {
+ t.Errorf(`YAML string "%s" gave a nil map`, tt)
+ }
+ }
+}
+
+func TestToRenderValues(t *testing.T) {
+
+ chartValues := map[string]interface{}{
+ "name": "al Rashid",
+ "where": map[string]interface{}{
+ "city": "Basrah",
+ "title": "caliph",
+ },
+ }
+
+ overrideValues := map[string]interface{}{
+ "name": "Haroun",
+ "where": map[string]interface{}{
+ "city": "Baghdad",
+ "date": "809 CE",
+ },
+ }
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{Name: "test"},
+ Templates: []*chart.File{},
+ Values: chartValues,
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ }
+ c.AddDependency(&chart.Chart{
+ Metadata: &chart.Metadata{Name: "where"},
+ })
+
+ o := ReleaseOptions{
+ Name: "Seven Voyages",
+ Namespace: "default",
+ Revision: 1,
+ IsInstall: true,
+ }
+
+ res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Ensure that the top-level values are all set.
+ if name := res["Chart"].(*chart.Metadata).Name; name != "test" {
+ t.Errorf("Expected chart name 'test', got %q", name)
+ }
+ relmap := res["Release"].(map[string]interface{})
+ if name := relmap["Name"]; name.(string) != "Seven Voyages" {
+ t.Errorf("Expected release name 'Seven Voyages', got %q", name)
+ }
+ if namespace := relmap["Namespace"]; namespace.(string) != "default" {
+ t.Errorf("Expected namespace 'default', got %q", namespace)
+ }
+ if revision := relmap["Revision"]; revision.(int) != 1 {
+ t.Errorf("Expected revision '1', got %d", revision)
+ }
+ if relmap["IsUpgrade"].(bool) {
+ t.Error("Expected upgrade to be false.")
+ }
+ if !relmap["IsInstall"].(bool) {
+ t.Errorf("Expected install to be true.")
+ }
+ if !res["Capabilities"].(*Capabilities).APIVersions.Has("v1") {
+ t.Error("Expected Capabilities to have v1 as an API")
+ }
+ if res["Capabilities"].(*Capabilities).KubeVersion.Major != "1" {
+ t.Error("Expected Capabilities to have a Kube version")
+ }
+
+ vals := res["Values"].(Values)
+ if vals["name"] != "Haroun" {
+ t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals)
+ }
+ where := vals["where"].(map[string]interface{})
+ expects := map[string]string{
+ "city": "Baghdad",
+ "date": "809 CE",
+ "title": "caliph",
+ }
+ for field, expect := range expects {
+ if got := where[field]; got != expect {
+ t.Errorf("Expected %q, got %q (%v)", expect, got, where)
+ }
+ }
+}
+
+func TestReadValuesFile(t *testing.T) {
+ data, err := ReadValuesFile("./testdata/coleridge.yaml")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+ matchValues(t, data)
+}
+
+func ExampleValues() {
+ doc := `
+title: "Moby Dick"
+chapter:
+ one:
+ title: "Loomings"
+ two:
+ title: "The Carpet-Bag"
+ three:
+ title: "The Spouter Inn"
+`
+ d, err := ReadValues([]byte(doc))
+ if err != nil {
+ panic(err)
+ }
+ ch1, err := d.Table("chapter.one")
+ if err != nil {
+ panic("could not find chapter one")
+ }
+ fmt.Print(ch1["title"])
+ // Output:
+ // Loomings
+}
+
+func TestTable(t *testing.T) {
+ doc := `
+title: "Moby Dick"
+chapter:
+ one:
+ title: "Loomings"
+ two:
+ title: "The Carpet-Bag"
+ three:
+ title: "The Spouter Inn"
+`
+ d, err := ReadValues([]byte(doc))
+ if err != nil {
+ t.Fatalf("Failed to parse the White Whale: %s", err)
+ }
+
+ if _, err := d.Table("title"); err == nil {
+ t.Fatalf("Title is not a table.")
+ }
+
+ if _, err := d.Table("chapter"); err != nil {
+ t.Fatalf("Failed to get the chapter table: %s\n%v", err, d)
+ }
+
+ if v, err := d.Table("chapter.one"); err != nil {
+ t.Errorf("Failed to get chapter.one: %s", err)
+ } else if v["title"] != "Loomings" {
+ t.Errorf("Unexpected title: %s", v["title"])
+ }
+
+ if _, err := d.Table("chapter.three"); err != nil {
+ t.Errorf("Chapter three is missing: %s\n%v", err, d)
+ }
+
+ if _, err := d.Table("chapter.OneHundredThirtySix"); err == nil {
+ t.Errorf("I think you mean 'Epilogue'")
+ }
+}
+
+func matchValues(t *testing.T, data map[string]interface{}) {
+ t.Helper()
+ if data["poet"] != "Coleridge" {
+ t.Errorf("Unexpected poet: %s", data["poet"])
+ }
+
+ if o, err := ttpl("{{len .stanza}}", data); err != nil {
+ t.Errorf("len stanza: %s", err)
+ } else if o != "6" {
+ t.Errorf("Expected 6, got %s", o)
+ }
+
+ 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")
+ }
+
+ 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")
+ }
+}
+
+func ttpl(tpl string, v map[string]interface{}) (string, error) {
+ var b bytes.Buffer
+ tt := template.Must(template.New("t").Parse(tpl))
+ err := tt.Execute(&b, v)
+ return b.String(), err
+}
+
+func TestPathValue(t *testing.T) {
+ doc := `
+title: "Moby Dick"
+chapter:
+ one:
+ title: "Loomings"
+ two:
+ title: "The Carpet-Bag"
+ three:
+ title: "The Spouter Inn"
+`
+ d, err := ReadValues([]byte(doc))
+ if err != nil {
+ t.Fatalf("Failed to parse the White Whale: %s", err)
+ }
+
+ if v, err := d.PathValue("chapter.one.title"); err != nil {
+ t.Errorf("Got error instead of title: %s\n%v", err, d)
+ } else if v != "Loomings" {
+ t.Errorf("No error but got wrong value for title: %s\n%v", err, d)
+ }
+ if _, err := d.PathValue("chapter.one.doesnotexist"); err == nil {
+ t.Errorf("Non-existent key should return error: %s\n%v", err, d)
+ }
+ if _, err := d.PathValue("chapter.doesnotexist.one"); err == nil {
+ t.Errorf("Non-existent key in middle of path should return error: %s\n%v", err, d)
+ }
+ if _, err := d.PathValue(""); err == nil {
+ t.Error("Asking for the value from an empty path should yield an error")
+ }
+ if v, err := d.PathValue("title"); err == nil {
+ if v != "Moby Dick" {
+ t.Errorf("Failed to return values for root key title")
+ }
+ }
+}
diff --git a/internal/cli/output/color.go b/internal/cli/output/color.go
new file mode 100644
index 000000000..93bbbe56e
--- /dev/null
+++ b/internal/cli/output/color.go
@@ -0,0 +1,67 @@
+/*
+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 output
+
+import (
+ "github.com/fatih/color"
+
+ release "helm.sh/helm/v4/pkg/release/v1"
+)
+
+// ColorizeStatus returns a colorized version of the status string based on the status value
+func ColorizeStatus(status release.Status, noColor bool) string {
+ // Disable color if requested
+ if noColor {
+ return status.String()
+ }
+
+ switch status {
+ case release.StatusDeployed:
+ return color.GreenString(status.String())
+ case release.StatusFailed:
+ return color.RedString(status.String())
+ case release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback, release.StatusUninstalling:
+ return color.YellowString(status.String())
+ case release.StatusUnknown:
+ return color.RedString(status.String())
+ default:
+ // For uninstalled, superseded, and any other status
+ return status.String()
+ }
+}
+
+// ColorizeHeader returns a colorized version of a header string
+func ColorizeHeader(header string, noColor bool) string {
+ // Disable color if requested
+ if noColor {
+ return header
+ }
+
+ // Use bold for headers
+ return color.New(color.Bold).Sprint(header)
+}
+
+// ColorizeNamespace returns a colorized version of a namespace string
+func ColorizeNamespace(namespace string, noColor bool) string {
+ // Disable color if requested
+ if noColor {
+ return namespace
+ }
+
+ // Use cyan for namespaces
+ return color.CyanString(namespace)
+}
diff --git a/internal/cli/output/color_test.go b/internal/cli/output/color_test.go
new file mode 100644
index 000000000..c84e2c359
--- /dev/null
+++ b/internal/cli/output/color_test.go
@@ -0,0 +1,191 @@
+/*
+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 output
+
+import (
+ "strings"
+ "testing"
+
+ release "helm.sh/helm/v4/pkg/release/v1"
+)
+
+func TestColorizeStatus(t *testing.T) {
+
+ tests := []struct {
+ name string
+ status release.Status
+ noColor bool
+ envNoColor string
+ wantColor bool // whether we expect color codes in output
+ }{
+ {
+ name: "deployed status with color",
+ status: release.StatusDeployed,
+ noColor: false,
+ envNoColor: "",
+ wantColor: true,
+ },
+ {
+ name: "deployed status without color flag",
+ status: release.StatusDeployed,
+ noColor: true,
+ envNoColor: "",
+ wantColor: false,
+ },
+ {
+ name: "deployed status with NO_COLOR env",
+ status: release.StatusDeployed,
+ noColor: false,
+ envNoColor: "1",
+ wantColor: false,
+ },
+ {
+ name: "failed status with color",
+ status: release.StatusFailed,
+ noColor: false,
+ envNoColor: "",
+ wantColor: true,
+ },
+ {
+ name: "pending install status with color",
+ status: release.StatusPendingInstall,
+ noColor: false,
+ envNoColor: "",
+ wantColor: true,
+ },
+ {
+ name: "unknown status with color",
+ status: release.StatusUnknown,
+ noColor: false,
+ envNoColor: "",
+ wantColor: true,
+ },
+ {
+ name: "superseded status with color",
+ status: release.StatusSuperseded,
+ noColor: false,
+ envNoColor: "",
+ wantColor: false, // superseded doesn't get colored
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("NO_COLOR", tt.envNoColor)
+
+ result := ColorizeStatus(tt.status, tt.noColor)
+
+ // Check if result contains ANSI escape codes
+ hasColor := strings.Contains(result, "\033[")
+
+ // 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)
+ }
+
+ // 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())
+ }
+ })
+ }
+}
+
+func TestColorizeHeader(t *testing.T) {
+
+ tests := []struct {
+ name string
+ header string
+ noColor bool
+ envNoColor string
+ }{
+ {
+ name: "header with color",
+ header: "NAME",
+ noColor: false,
+ envNoColor: "",
+ },
+ {
+ name: "header without color flag",
+ header: "NAME",
+ noColor: true,
+ envNoColor: "",
+ },
+ {
+ name: "header with NO_COLOR env",
+ header: "NAME",
+ noColor: false,
+ envNoColor: "1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("NO_COLOR", tt.envNoColor)
+
+ 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)
+ }
+ })
+ }
+}
+
+func TestColorizeNamespace(t *testing.T) {
+
+ tests := []struct {
+ name string
+ namespace string
+ noColor bool
+ envNoColor string
+ }{
+ {
+ name: "namespace with color",
+ namespace: "default",
+ noColor: false,
+ envNoColor: "",
+ },
+ {
+ name: "namespace without color flag",
+ namespace: "default",
+ noColor: true,
+ envNoColor: "",
+ },
+ {
+ name: "namespace with NO_COLOR env",
+ namespace: "default",
+ noColor: false,
+ envNoColor: "1",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("NO_COLOR", tt.envNoColor)
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go
index 4ea09cca4..39e0e330f 100644
--- a/internal/fileutil/fileutil.go
+++ b/internal/fileutil/fileutil.go
@@ -21,7 +21,7 @@ import (
"os"
"path/filepath"
- "helm.sh/helm/v3/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
)
// AtomicWriteFile atomically (as atomic as os.Rename allows) writes a file to a
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
new file mode 100644
index 000000000..946a211ef
--- /dev/null
+++ b/internal/logging/logging.go
@@ -0,0 +1,87 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package logging
+
+import (
+ "context"
+ "log/slog"
+ "os"
+)
+
+// DebugEnabledFunc is a function type that determines if debug logging is enabled
+// We use a function because we want to check the setting at log time, not when the logger is created
+type DebugEnabledFunc func() bool
+
+// DebugCheckHandler checks settings.Debug at log time
+type DebugCheckHandler struct {
+ handler slog.Handler
+ debugEnabled DebugEnabledFunc
+}
+
+// Enabled implements slog.Handler.Enabled
+func (h *DebugCheckHandler) Enabled(_ context.Context, level slog.Level) bool {
+ if level == slog.LevelDebug {
+ return h.debugEnabled()
+ }
+ return true // Always log other levels
+}
+
+// Handle implements slog.Handler.Handle
+func (h *DebugCheckHandler) Handle(ctx context.Context, r slog.Record) error {
+ return h.handler.Handle(ctx, r)
+}
+
+// WithAttrs implements slog.Handler.WithAttrs
+func (h *DebugCheckHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
+ return &DebugCheckHandler{
+ handler: h.handler.WithAttrs(attrs),
+ debugEnabled: h.debugEnabled,
+ }
+}
+
+// WithGroup implements slog.Handler.WithGroup
+func (h *DebugCheckHandler) WithGroup(name string) slog.Handler {
+ return &DebugCheckHandler{
+ handler: h.handler.WithGroup(name),
+ debugEnabled: h.debugEnabled,
+ }
+}
+
+// NewLogger creates a new logger with dynamic debug checking
+func NewLogger(debugEnabled DebugEnabledFunc) *slog.Logger {
+ // Create base handler that removes timestamps
+ baseHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
+ // Always use LevelDebug here to allow all messages through
+ // Our custom handler will do the filtering
+ Level: slog.LevelDebug,
+ ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
+ // Remove the time attribute
+ if a.Key == slog.TimeKey {
+ return slog.Attr{}
+ }
+ return a
+ },
+ })
+
+ // Wrap with our dynamic debug-checking handler
+ dynamicHandler := &DebugCheckHandler{
+ handler: baseHandler,
+ debugEnabled: debugEnabled,
+ }
+
+ return slog.New(dynamicHandler)
+}
diff --git a/internal/monocular/client.go b/internal/monocular/client.go
index 88a2564b9..f4ef5d647 100644
--- a/internal/monocular/client.go
+++ b/internal/monocular/client.go
@@ -29,9 +29,6 @@ type Client struct {
// The base URL for requests
BaseURL string
-
- // The internal logger to use
- Log func(string, ...interface{})
}
// New creates a new client
@@ -44,12 +41,9 @@ func New(u string) (*Client, error) {
return &Client{
BaseURL: u,
- Log: nopLogger,
}, nil
}
-var nopLogger = func(_ string, _ ...interface{}) {}
-
// Validate if the base URL for monocular is valid.
func validate(u string) error {
diff --git a/internal/monocular/search.go b/internal/monocular/search.go
index 4e7e8c002..fcf04b7a4 100644
--- a/internal/monocular/search.go
+++ b/internal/monocular/search.go
@@ -24,8 +24,8 @@ import (
"path"
"time"
- "helm.sh/helm/v3/internal/version"
- "helm.sh/helm/v3/pkg/chart"
+ "helm.sh/helm/v4/internal/version"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// SearchPath is the url path to the search API in monocular.
@@ -129,7 +129,7 @@ func (c *Client) Search(term string) ([]SearchResult, error) {
}
defer res.Body.Close()
- if res.StatusCode != 200 {
+ if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch %s : %s", p.String(), res.Status)
}
diff --git a/internal/monocular/search_test.go b/internal/monocular/search_test.go
index 9f6954af7..fc82ef4b4 100644
--- a/internal/monocular/search_test.go
+++ b/internal/monocular/search_test.go
@@ -28,7 +28,7 @@ var searchResult = `{"data":[{"id":"stable/phpmyadmin","type":"chart","attribute
func TestSearch(t *testing.T) {
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, searchResult)
}))
defer ts.Close()
diff --git a/pkg/plugin/cache/cache.go b/internal/plugin/cache/cache.go
similarity index 96%
rename from pkg/plugin/cache/cache.go
rename to internal/plugin/cache/cache.go
index 5f3345b63..f3b737477 100644
--- a/pkg/plugin/cache/cache.go
+++ b/internal/plugin/cache/cache.go
@@ -14,7 +14,7 @@ limitations under the License.
*/
// Package cache provides a key generator for vcs urls.
-package cache // import "helm.sh/helm/v3/pkg/plugin/cache"
+package cache // import "helm.sh/helm/v4/internal/plugin/cache"
import (
"net/url"
diff --git a/internal/plugin/config.go b/internal/plugin/config.go
new file mode 100644
index 000000000..812dba7f6
--- /dev/null
+++ b/internal/plugin/config.go
@@ -0,0 +1,82 @@
+/*
+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 (
+ "fmt"
+
+ "go.yaml.in/yaml/v3"
+)
+
+// Config interface defines the methods that all plugin type configurations must implement
+type Config interface {
+ GetType() string
+ Validate() error
+}
+
+// ConfigCLI represents the configuration for CLI plugins
+type ConfigCLI struct {
+ // Usage is the single-line usage text shown in help
+ // For recommended syntax, see [spf13/cobra.command.Command] Use field comment:
+ // https://pkg.go.dev/github.com/spf13/cobra#Command
+ Usage string `yaml:"usage"`
+ // ShortHelp is the short description shown in the 'helm help' output
+ ShortHelp string `yaml:"shortHelp"`
+ // LongHelp is the long message shown in the 'helm help ' output
+ LongHelp string `yaml:"longHelp"`
+ // IgnoreFlags ignores any flags passed in from Helm
+ IgnoreFlags bool `yaml:"ignoreFlags"`
+}
+
+// ConfigGetter represents the configuration for download plugins
+type ConfigGetter struct {
+ // Protocols are the list of URL schemes supported by this downloader
+ Protocols []string `yaml:"protocols"`
+}
+
+func (c *ConfigCLI) GetType() string { return "cli/v1" }
+func (c *ConfigGetter) GetType() string { return "getter/v1" }
+
+func (c *ConfigCLI) Validate() error {
+ // Config validation for CLI plugins
+ return nil
+}
+
+func (c *ConfigGetter) Validate() error {
+ if len(c.Protocols) == 0 {
+ return fmt.Errorf("getter has no protocols")
+ }
+ for i, protocol := range c.Protocols {
+ if protocol == "" {
+ return fmt.Errorf("getter has empty protocol at index %d", i)
+ }
+ }
+ return nil
+}
+
+func remarshalConfig[T Config](configData map[string]any) (Config, error) {
+ data, err := yaml.Marshal(configData)
+ if err != nil {
+ return nil, err
+ }
+
+ var config T
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return nil, err
+ }
+
+ return config, nil
+}
diff --git a/internal/plugin/descriptor.go b/internal/plugin/descriptor.go
new file mode 100644
index 000000000..ba92b3c55
--- /dev/null
+++ b/internal/plugin/descriptor.go
@@ -0,0 +1,24 @@
+/*
+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
+
+// Descriptor describes a plugin to find
+type Descriptor struct {
+ // Name is the name of the plugin
+ Name string
+ // Type is the type of the plugin (cli, getter, postrenderer)
+ Type string
+}
diff --git a/internal/plugin/doc.go b/internal/plugin/doc.go
new file mode 100644
index 000000000..39ba6300b
--- /dev/null
+++ b/internal/plugin/doc.go
@@ -0,0 +1,89 @@
+/*
+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.
+*/
+
+/*
+---
+TODO: move this section to public plugin package
+
+Package plugin provides the implementation of the Helm plugin system.
+
+Conceptually, "plugins" enable extending Helm's functionality external to Helm's core codebase. The plugin system allows
+code to fetch plugins by type, then invoke the plugin with an input as required by that plugin type. The plugin
+returning an output for the caller to consume.
+
+An example of a plugin invocation:
+```
+d := plugin.Descriptor{
+ Type: "example/v1", //
+}
+plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
+
+for _, plg := range plgs {
+ input := &plugin.Input{
+ Message: schema.InputMessageExampleV1{ // The type of the input message is defined by the plugin's "type" (example/v1 here)
+ ...
+ },
+ }
+ output, err := plg.Invoke(context.Background(), input)
+ if err != nil {
+ ...
+ }
+
+ // consume the output, using type assertion to convert to the expected output type (as defined by the plugin's "type")
+ outputMessage, ok := output.Message.(schema.OutputMessageExampleV1)
+}
+
+---
+
+Package `plugin` provides the implementation of the Helm plugin system.
+
+Helm plugins are exposed to uses as the "Plugin" type, the basic interface that primarily support the "Invoke" method.
+
+# Plugin Runtimes
+Internally, plugins must be implemented by a "runtime" that is responsible for creating the plugin instance, and dispatching the plugin's invocation to the plugin's implementation.
+For example:
+- forming environment variables and command line args for subprocess execution
+- converting input to JSON and invoking a function in a Wasm runtime
+
+Internally, the code structure is:
+Runtime.CreatePlugin()
+ |
+ | (creates)
+ |
+ \---> PluginRuntime
+ |
+ | (implements)
+ v
+ Plugin.Invoke()
+
+# Plugin Types
+Each plugin implements a specific functionality, denoted by the plugin's "type" e.g. "getter/v1". The "type" includes a version, in order to allow a given types messaging schema and invocation options to evolve.
+
+Specifically, the plugin's "type" specifies the contract for the input and output messages that are expected to be passed to the plugin, and returned from the plugin. The plugin's "type" also defines the options that can be passed to the plugin when invoking it.
+
+# Metadata
+Each plugin must have a `plugin.yaml`, that defines the plugin's metadata. The metadata includes the plugin's name, version, and other information.
+
+For legacy plugins, the type is inferred by which fields are set on the plugin: a downloader plugin is inferred when metadata contains a "downloaders" yaml node, otherwise it is assumed to define a Helm CLI subcommand.
+
+For v1 plugins, the metadata includes explicit apiVersion and type fields. It will also contain type-specific Config, and RuntimeConfig fields.
+
+# Runtime and type cardinality
+From a cardinality perspective, this means there a "few" runtimes, and "many" plugins types. It is also expected that the subprocess runtime will not be extended to support extra plugin types, and deprecated in a future version of Helm.
+
+Future ideas that are intended to be implemented include extending the plugin system to support future Wasm standards. Or allowing Helm SDK user's to inject "plugins" that are actually implemented as native go modules. Or even moving Helm's internal functionality e.g. yaml rendering engine to be used as an "in-built" plugin, along side other plugins that may implement other (non-go template) rendering engines.
+*/
+
+package plugin
diff --git a/internal/plugin/error.go b/internal/plugin/error.go
new file mode 100644
index 000000000..5ace680cb
--- /dev/null
+++ b/internal/plugin/error.go
@@ -0,0 +1,29 @@
+/*
+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
+
+// InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code
+// - subprocess plugin: child process exit code
+// - extism plugin: wasm function return code
+type InvokeExecError struct {
+ Err error // Underlying error
+ Code int // Exeit code from plugin code execution
+}
+
+// Error implements the error interface
+func (e *InvokeExecError) Error() string {
+ return e.Err.Error()
+}
diff --git a/pkg/plugin/installer/base.go b/internal/plugin/installer/base.go
similarity index 91%
rename from pkg/plugin/installer/base.go
rename to internal/plugin/installer/base.go
index ba6a55d55..c21a245a8 100644
--- a/pkg/plugin/installer/base.go
+++ b/internal/plugin/installer/base.go
@@ -13,12 +13,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"path/filepath"
- "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v4/pkg/cli"
)
type base struct {
diff --git a/pkg/plugin/installer/base_test.go b/internal/plugin/installer/base_test.go
similarity index 88%
rename from pkg/plugin/installer/base_test.go
rename to internal/plugin/installer/base_test.go
index 38ef28c3e..62b77bde5 100644
--- a/pkg/plugin/installer/base_test.go
+++ b/internal/plugin/installer/base_test.go
@@ -11,10 +11,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
- "os"
"testing"
)
@@ -37,12 +36,11 @@ func TestPath(t *testing.T) {
for _, tt := range tests {
- os.Setenv("HELM_PLUGINS", tt.helmPluginsDir)
+ t.Setenv("HELM_PLUGINS", tt.helmPluginsDir)
baseIns := newBase(tt.source)
baseInsPath := baseIns.Path()
if baseInsPath != tt.expectPath {
t.Errorf("expected name %s, got %s", tt.expectPath, baseInsPath)
}
- os.Unsetenv("HELM_PLUGINS")
}
}
diff --git a/pkg/plugin/installer/doc.go b/internal/plugin/installer/doc.go
similarity index 89%
rename from pkg/plugin/installer/doc.go
rename to internal/plugin/installer/doc.go
index 3e3b2ebeb..a4cf384bf 100644
--- a/pkg/plugin/installer/doc.go
+++ b/internal/plugin/installer/doc.go
@@ -14,4 +14,4 @@ limitations under the License.
*/
// Package installer provides an interface for installing Helm plugins.
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
diff --git a/pkg/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go
similarity index 81%
rename from pkg/plugin/installer/http_installer.go
rename to internal/plugin/installer/http_installer.go
index 49274f83c..b68fc059a 100644
--- a/pkg/plugin/installer/http_installer.go
+++ b/internal/plugin/installer/http_installer.go
@@ -13,27 +13,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
"bytes"
"compress/gzip"
+ "errors"
+ "fmt"
"io"
+ "log/slog"
"os"
"path"
"path/filepath"
"regexp"
+ "slices"
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
- "github.com/pkg/errors"
- "helm.sh/helm/v3/internal/third_party/dep/fs"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/plugin/cache"
+ "helm.sh/helm/v4/internal/plugin/cache"
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
)
// HTTPInstaller installs plugins from an archive served by a web server.
@@ -66,6 +69,9 @@ func mediaTypeToExtension(mt string) (string, bool) {
switch strings.ToLower(mt) {
case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar":
return ".tgz", true
+ case "application/octet-stream":
+ // Generic binary type - we'll need to check the URL suffix
+ return "", false
default:
return "", false
}
@@ -78,7 +84,7 @@ func NewExtractor(source string) (Extractor, error) {
return extractor, nil
}
}
- return nil, errors.Errorf("no extractor implemented yet for %s", source)
+ return nil, fmt.Errorf("no extractor implemented yet for %s", source)
}
// NewHTTPInstaller creates a new HttpInstaller.
@@ -132,31 +138,38 @@ func (i *HTTPInstaller) Install() error {
}
if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil {
- return errors.Wrap(err, "extracting files from archive")
+ return fmt.Errorf("extracting files from archive: %w", err)
}
- if !isPlugin(i.CacheDir) {
- return ErrMissingMetadata
+ // Detect where the plugin.yaml actually is
+ pluginRoot, err := detectPluginRoot(i.CacheDir)
+ if err != nil {
+ return err
}
- src, err := filepath.Abs(i.CacheDir)
+ // Validate plugin structure if needed
+ if err := validatePluginName(pluginRoot, i.PluginName); err != nil {
+ return err
+ }
+
+ src, err := filepath.Abs(pluginRoot)
if err != nil {
return err
}
- debug("copying %s to %s", src, i.Path())
+ slog.Debug("copying", "source", src, "path", i.Path())
return fs.CopyDir(src, i.Path())
}
// Update updates a local repository
// Not implemented for now since tarball most likely will be packaged by version
func (i *HTTPInstaller) Update() error {
- return errors.Errorf("method Update() not implemented for HttpInstaller")
+ return fmt.Errorf("method Update() not implemented for HttpInstaller")
}
// Path is overridden because we want to join on the plugin name not the file name
func (i HTTPInstaller) Path() string {
- if i.base.Source == "" {
+ if i.Source == "" {
return ""
}
return helmpath.DataPath("plugins", i.PluginName)
@@ -194,10 +207,8 @@ func cleanJoin(root, dest string) (string, error) {
// We want to alert the user that something bad was attempted. Cleaning it
// is not a good practice.
- for _, part := range strings.Split(dest, "/") {
- if part == ".." {
- return "", errors.New("path contains '..', which is illegal")
- }
+ if slices.Contains(strings.Split(dest, "/"), "..") {
+ return "", errors.New("path contains '..', which is illegal")
}
// If a path is absolute, the creator of the TAR is doing something shady.
@@ -206,6 +217,9 @@ func cleanJoin(root, dest string) (string, error) {
}
// SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks.
+ // 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.
+ root = filepath.Clean(root)
newpath, err := securejoin.SecureJoin(root, dest)
if err != nil {
return "", err
@@ -244,24 +258,27 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
switch header.Typeflag {
case tar.TypeDir:
- if err := os.Mkdir(path, 0755); err != nil {
+ if err := os.MkdirAll(path, 0755); err != nil {
return err
}
case tar.TypeReg:
+ // Ensure parent directory exists
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err
}
+ defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
- outFile.Close()
return err
}
- outFile.Close()
// We don't want to process these extension header files.
case tar.TypeXGlobalHeader, tar.TypeXHeader:
continue
default:
- return errors.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
+ return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
}
}
return nil
diff --git a/pkg/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go
similarity index 54%
rename from pkg/plugin/installer/http_installer_test.go
rename to internal/plugin/installer/http_installer_test.go
index 177227c5b..453021b76 100644
--- a/pkg/plugin/installer/http_installer_test.go
+++ b/internal/plugin/installer/http_installer_test.go
@@ -13,14 +13,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/base64"
+ "errors"
"fmt"
+ "io/fs"
"net/http"
"net/http/httptest"
"os"
@@ -29,11 +31,9 @@ import (
"syscall"
"testing"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
)
var _ Installer = new(HTTPInstaller)
@@ -44,7 +44,7 @@ type TestHTTPGetter struct {
MockError error
}
-func (t *TestHTTPGetter) Get(href string, _ ...getter.Option) (*bytes.Buffer, error) {
+func (t *TestHTTPGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) {
return t.MockResponse, t.MockError
}
@@ -150,7 +150,7 @@ func TestHTTPInstallerNonExistentVersion(t *testing.T) {
// inject fake http client responding with error
httpInstaller.getter = &TestHTTPGetter{
- MockError: errors.Errorf("failed to download plugin for some reason"),
+ MockError: fmt.Errorf("failed to download plugin for some reason"),
}
// attempt to install the plugin
@@ -210,11 +210,9 @@ func TestExtract(t *testing.T) {
tempDir := t.TempDir()
- // Set the umask to default open permissions so we can actually test
- oldmask := syscall.Umask(0000)
- defer func() {
- syscall.Umask(oldmask)
- }()
+ // Get current umask to predict expected permissions
+ currentUmask := syscall.Umask(0)
+ syscall.Umask(currentUmask)
// Write a tarball to a buffer for us to extract
var tarbuf bytes.Buffer
@@ -274,24 +272,30 @@ func TestExtract(t *testing.T) {
t.Fatalf("Did not expect error but got error: %v", err)
}
+ // Calculate expected permissions after umask is applied
+ expectedPluginYAMLPerm := os.FileMode(0600 &^ currentUmask)
+ expectedReadmePerm := os.FileMode(0777 &^ currentUmask)
+
pluginYAMLFullPath := filepath.Join(tempDir, "plugin.yaml")
if info, err := os.Stat(pluginYAMLFullPath); err != nil {
- if os.IsNotExist(err) {
+ if errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Expected %s to exist but doesn't", pluginYAMLFullPath)
}
t.Fatal(err)
- } else if info.Mode().Perm() != 0600 {
- t.Fatalf("Expected %s to have 0600 mode it but has %o", pluginYAMLFullPath, info.Mode().Perm())
+ } else if info.Mode().Perm() != expectedPluginYAMLPerm {
+ t.Fatalf("Expected %s to have %o mode but has %o (umask: %o)",
+ pluginYAMLFullPath, expectedPluginYAMLPerm, info.Mode().Perm(), currentUmask)
}
readmeFullPath := filepath.Join(tempDir, "README.md")
if info, err := os.Stat(readmeFullPath); err != nil {
- if os.IsNotExist(err) {
+ if errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Expected %s to exist but doesn't", readmeFullPath)
}
t.Fatal(err)
- } else if info.Mode().Perm() != 0777 {
- t.Fatalf("Expected %s to have 0777 mode it but has %o", readmeFullPath, info.Mode().Perm())
+ } else if info.Mode().Perm() != expectedReadmePerm {
+ t.Fatalf("Expected %s to have %o mode but has %o (umask: %o)",
+ readmeFullPath, expectedReadmePerm, info.Mode().Perm(), currentUmask)
}
}
@@ -348,3 +352,249 @@ func TestMediaTypeToExtension(t *testing.T) {
}
}
}
+
+func TestExtractWithNestedDirectories(t *testing.T) {
+ source := "https://repo.localdomain/plugins/nested-plugin-0.0.1.tar.gz"
+ tempDir := t.TempDir()
+
+ // Write a tarball with nested directory structure
+ var tarbuf bytes.Buffer
+ tw := tar.NewWriter(&tarbuf)
+ var files = []struct {
+ Name string
+ Body string
+ Mode int64
+ TypeFlag byte
+ }{
+ {"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
+ {"bin/", "", 0755, tar.TypeDir},
+ {"bin/plugin", "#!/bin/bash\necho plugin", 0755, tar.TypeReg},
+ {"docs/", "", 0755, tar.TypeDir},
+ {"docs/README.md", "readme content", 0644, tar.TypeReg},
+ {"docs/examples/", "", 0755, tar.TypeDir},
+ {"docs/examples/example1.yaml", "example content", 0644, tar.TypeReg},
+ }
+
+ for _, file := range files {
+ hdr := &tar.Header{
+ Name: file.Name,
+ Typeflag: file.TypeFlag,
+ Mode: file.Mode,
+ Size: int64(len(file.Body)),
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if file.TypeFlag == tar.TypeReg {
+ if _, err := tw.Write([]byte(file.Body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ if _, err := gz.Write(tarbuf.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ gz.Close()
+
+ extractor, err := NewExtractor(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // First extraction
+ if err = extractor.Extract(&buf, tempDir); err != nil {
+ t.Fatalf("First extraction failed: %v", err)
+ }
+
+ // Verify nested structure was created
+ nestedFile := filepath.Join(tempDir, "docs", "examples", "example1.yaml")
+ if _, err := os.Stat(nestedFile); err != nil {
+ t.Fatalf("Expected nested file %s to exist but got error: %v", nestedFile, err)
+ }
+
+ // Reset buffer for second extraction
+ buf.Reset()
+ gz = gzip.NewWriter(&buf)
+ if _, err := gz.Write(tarbuf.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ gz.Close()
+
+ // Second extraction to same directory (should not fail)
+ if err = extractor.Extract(&buf, tempDir); err != nil {
+ t.Fatalf("Second extraction to existing directory failed: %v", err)
+ }
+}
+
+func TestExtractWithExistingDirectory(t *testing.T) {
+ source := "https://repo.localdomain/plugins/test-plugin-0.0.1.tar.gz"
+ tempDir := t.TempDir()
+
+ // Pre-create the cache directory structure
+ cacheDir := filepath.Join(tempDir, "cache")
+ if err := os.MkdirAll(filepath.Join(cacheDir, "existing", "dir"), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a file in the existing directory
+ existingFile := filepath.Join(cacheDir, "existing", "file.txt")
+ if err := os.WriteFile(existingFile, []byte("existing content"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Write a tarball
+ var tarbuf bytes.Buffer
+ tw := tar.NewWriter(&tarbuf)
+ files := []struct {
+ Name string
+ Body string
+ Mode int64
+ TypeFlag byte
+ }{
+ {"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
+ {"existing/", "", 0755, tar.TypeDir},
+ {"existing/dir/", "", 0755, tar.TypeDir},
+ {"existing/dir/newfile.txt", "new content", 0644, tar.TypeReg},
+ }
+
+ for _, file := range files {
+ hdr := &tar.Header{
+ Name: file.Name,
+ Typeflag: file.TypeFlag,
+ Mode: file.Mode,
+ Size: int64(len(file.Body)),
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if file.TypeFlag == tar.TypeReg {
+ if _, err := tw.Write([]byte(file.Body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ if _, err := gz.Write(tarbuf.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ gz.Close()
+
+ extractor, err := NewExtractor(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Extract to directory with existing content
+ if err = extractor.Extract(&buf, cacheDir); err != nil {
+ t.Fatalf("Extraction to directory with existing content failed: %v", err)
+ }
+
+ // Verify new file was created
+ newFile := filepath.Join(cacheDir, "existing", "dir", "newfile.txt")
+ if _, err := os.Stat(newFile); err != nil {
+ t.Fatalf("Expected new file %s to exist but got error: %v", newFile, err)
+ }
+
+ // Verify existing file is still there
+ if _, err := os.Stat(existingFile); err != nil {
+ t.Fatalf("Expected existing file %s to still exist but got error: %v", existingFile, err)
+ }
+}
+
+func TestExtractPluginInSubdirectory(t *testing.T) {
+ source := "https://repo.localdomain/plugins/subdir-plugin-1.0.0.tar.gz"
+ tempDir := t.TempDir()
+
+ // Create a tarball where plugin files are in a subdirectory
+ var tarbuf bytes.Buffer
+ tw := tar.NewWriter(&tarbuf)
+ files := []struct {
+ Name string
+ Body string
+ Mode int64
+ TypeFlag byte
+ }{
+ {"my-plugin/", "", 0755, tar.TypeDir},
+ {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test plugin\ncommand: $HELM_PLUGIN_DIR/bin/my-plugin", 0644, tar.TypeReg},
+ {"my-plugin/bin/", "", 0755, tar.TypeDir},
+ {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, tar.TypeReg},
+ }
+
+ for _, file := range files {
+ hdr := &tar.Header{
+ Name: file.Name,
+ Typeflag: file.TypeFlag,
+ Mode: file.Mode,
+ Size: int64(len(file.Body)),
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if file.TypeFlag == tar.TypeReg {
+ if _, err := tw.Write([]byte(file.Body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ var buf bytes.Buffer
+ gz := gzip.NewWriter(&buf)
+ if _, err := gz.Write(tarbuf.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ gz.Close()
+
+ // Test the installer
+ installer := &HTTPInstaller{
+ CacheDir: tempDir,
+ PluginName: "subdir-plugin",
+ base: newBase(source),
+ extractor: &TarGzExtractor{},
+ }
+
+ // Create a mock getter
+ installer.getter = &TestHTTPGetter{
+ MockResponse: &buf,
+ }
+
+ // Ensure the destination directory doesn't exist
+ // (In a real scenario, this is handled by installer.Install() wrapper)
+ destPath := installer.Path()
+ if err := os.RemoveAll(destPath); err != nil {
+ t.Fatalf("Failed to clean destination path: %v", err)
+ }
+
+ // Install should handle the subdirectory correctly
+ if err := installer.Install(); err != nil {
+ t.Fatalf("Failed to install plugin with subdirectory: %v", err)
+ }
+
+ // The plugin should be installed from the subdirectory
+ // Check that detectPluginRoot found the correct location
+ pluginRoot, err := detectPluginRoot(tempDir)
+ if err != nil {
+ t.Fatalf("Failed to detect plugin root: %v", err)
+ }
+
+ expectedRoot := filepath.Join(tempDir, "my-plugin")
+ if pluginRoot != expectedRoot {
+ t.Errorf("Expected plugin root to be %s but got %s", expectedRoot, pluginRoot)
+ }
+}
diff --git a/pkg/plugin/installer/installer.go b/internal/plugin/installer/installer.go
similarity index 91%
rename from pkg/plugin/installer/installer.go
rename to internal/plugin/installer/installer.go
index 6f01494e5..7900f6745 100644
--- a/pkg/plugin/installer/installer.go
+++ b/internal/plugin/installer/installer.go
@@ -16,16 +16,13 @@ limitations under the License.
package installer
import (
- "fmt"
- "log"
+ "errors"
"net/http"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/plugin"
+ "helm.sh/helm/v4/internal/plugin"
)
// ErrMissingMetadata indicates that plugin.yaml is missing.
@@ -95,6 +92,15 @@ func isLocalReference(source string) bool {
// HEAD operation to see if the remote resource is a file that we understand.
func isRemoteHTTPArchive(source string) bool {
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
+ // First, check if the URL ends with a known archive suffix
+ // This is more reliable than content-type detection
+ for suffix := range Extractors {
+ if strings.HasSuffix(source, suffix) {
+ return true
+ }
+ }
+
+ // If no suffix match, try HEAD request to check content type
res, err := http.Head(source)
if err != nil {
// If we get an error at the network layer, we can't install it. So
@@ -125,11 +131,3 @@ func isPlugin(dirname string) bool {
_, err := os.Stat(filepath.Join(dirname, plugin.PluginFileName))
return err == nil
}
-
-var logger = log.New(os.Stderr, "[debug] ", log.Lshortfile)
-
-func debug(format string, args ...interface{}) {
- if Debug {
- logger.Output(2, fmt.Sprintf(format, args...))
- }
-}
diff --git a/pkg/plugin/installer/installer_test.go b/internal/plugin/installer/installer_test.go
similarity index 71%
rename from pkg/plugin/installer/installer_test.go
rename to internal/plugin/installer/installer_test.go
index a11464924..dcd76fe9c 100644
--- a/pkg/plugin/installer/installer_test.go
+++ b/internal/plugin/installer/installer_test.go
@@ -26,8 +26,15 @@ func TestIsRemoteHTTPArchive(t *testing.T) {
t.Errorf("Expected non-URL to return false")
}
- if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") {
- t.Errorf("Bad URL should not have succeeded.")
+ // 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")
+ }
+
+ // 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")
}
if !isRemoteHTTPArchive(source) {
diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go
new file mode 100644
index 000000000..87b9eaf97
--- /dev/null
+++ b/internal/plugin/installer/local_installer.go
@@ -0,0 +1,148 @@
+/*
+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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
+)
+
+// ErrPluginNotAFolder indicates that the plugin path is not a folder.
+var ErrPluginNotAFolder = errors.New("expected plugin to be a folder")
+
+// LocalInstaller installs plugins from the filesystem.
+type LocalInstaller struct {
+ base
+ isArchive bool
+ extractor Extractor
+}
+
+// NewLocalInstaller creates a new LocalInstaller.
+func NewLocalInstaller(source string) (*LocalInstaller, error) {
+ src, err := filepath.Abs(source)
+ if err != nil {
+ return nil, fmt.Errorf("unable to get absolute path to plugin: %w", err)
+ }
+ i := &LocalInstaller{
+ base: newBase(src),
+ }
+
+ // Check if source is an archive
+ if isLocalArchive(src) {
+ i.isArchive = true
+ extractor, err := NewExtractor(src)
+ if err != nil {
+ return nil, fmt.Errorf("unsupported archive format: %w", err)
+ }
+ i.extractor = extractor
+ }
+
+ return i, nil
+}
+
+// isLocalArchive checks if the file is a supported archive format
+func isLocalArchive(path string) bool {
+ for suffix := range Extractors {
+ if strings.HasSuffix(path, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+// Install creates a symlink to the plugin directory.
+//
+// Implements Installer.
+func (i *LocalInstaller) Install() error {
+ if i.isArchive {
+ return i.installFromArchive()
+ }
+ return i.installFromDirectory()
+}
+
+// installFromDirectory creates a symlink to the plugin directory
+func (i *LocalInstaller) installFromDirectory() error {
+ stat, err := os.Stat(i.Source)
+ if err != nil {
+ return err
+ }
+ if !stat.IsDir() {
+ return ErrPluginNotAFolder
+ }
+
+ if !isPlugin(i.Source) {
+ return ErrMissingMetadata
+ }
+ slog.Debug("symlinking", "source", i.Source, "path", i.Path())
+ return os.Symlink(i.Source, i.Path())
+}
+
+// installFromArchive extracts and installs a plugin from a tarball
+func (i *LocalInstaller) installFromArchive() error {
+ // Read the archive file
+ data, err := os.ReadFile(i.Source)
+ if err != nil {
+ return fmt.Errorf("failed to read archive: %w", err)
+ }
+
+ // Create a temporary directory for extraction
+ tempDir, err := os.MkdirTemp("", "helm-plugin-extract-")
+ if err != nil {
+ return fmt.Errorf("failed to create temp directory: %w", err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ // Extract the archive
+ buffer := bytes.NewBuffer(data)
+ if err := i.extractor.Extract(buffer, tempDir); err != nil {
+ return fmt.Errorf("failed to extract archive: %w", err)
+ }
+
+ // Detect where the plugin.yaml actually is
+ pluginRoot, err := detectPluginRoot(tempDir)
+ if err != nil {
+ return err
+ }
+
+ // Copy to the final destination
+ slog.Debug("copying", "source", pluginRoot, "path", i.Path())
+ return fs.CopyDir(pluginRoot, i.Path())
+}
+
+// Path returns the path where the plugin will be installed.
+// For archive sources, strips the version from the filename.
+func (i *LocalInstaller) Path() string {
+ if i.Source == "" {
+ return ""
+ }
+ if i.isArchive {
+ return filepath.Join(i.PluginsDirectory, stripPluginName(filepath.Base(i.Source)))
+ }
+ return filepath.Join(i.PluginsDirectory, filepath.Base(i.Source))
+}
+
+// Update updates a local repository
+func (i *LocalInstaller) Update() error {
+ slog.Debug("local repository is auto-updated")
+ return nil
+}
diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go
new file mode 100644
index 000000000..05118e183
--- /dev/null
+++ b/internal/plugin/installer/local_installer_test.go
@@ -0,0 +1,227 @@
+/*
+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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/helmpath"
+)
+
+var _ Installer = new(LocalInstaller)
+
+func TestLocalInstaller(t *testing.T) {
+ ensure.HelmHome(t)
+ // Make a temp dir
+ tdir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ source := "../testdata/plugdir/good/echo-v1"
+ i, err := NewForSource(source, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ if err := Install(i); err != nil {
+ t.Fatal(err)
+ }
+
+ if i.Path() != helmpath.DataPath("plugins", "echo-v1") {
+ t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path())
+ }
+ defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm
+}
+
+func TestLocalInstallerNotAFolder(t *testing.T) {
+ source := "../testdata/plugdir/good/echo-v1/plugin.yaml"
+ i, err := NewForSource(source, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ err = Install(i)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if err != ErrPluginNotAFolder {
+ t.Fatalf("expected error to equal: %q", err)
+ }
+}
+
+func TestLocalInstallerTarball(t *testing.T) {
+ ensure.HelmHome(t)
+
+ // Create a test tarball
+ tempDir := t.TempDir()
+ tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tar.gz")
+
+ // Create tarball content
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+
+ files := []struct {
+ Name string
+ Body string
+ Mode int64
+ }{
+ {"plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644},
+ {"bin/test-plugin", "#!/bin/bash\necho test", 0755},
+ }
+
+ for _, file := range files {
+ hdr := &tar.Header{
+ Name: file.Name,
+ Mode: file.Mode,
+ Size: int64(len(file.Body)),
+ }
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tw.Write([]byte(file.Body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Write tarball to file
+ if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test installation
+ i, err := NewForSource(tarballPath, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ // Verify it's detected as LocalInstaller
+ localInstaller, ok := i.(*LocalInstaller)
+ if !ok {
+ t.Fatal("expected LocalInstaller")
+ }
+
+ if !localInstaller.isArchive {
+ t.Fatal("expected isArchive to be true")
+ }
+
+ if err := Install(i); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedPath := helmpath.DataPath("plugins", "test-plugin")
+ if i.Path() != expectedPath {
+ t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
+ }
+
+ // Verify plugin was installed
+ if _, err := os.Stat(i.Path()); err != nil {
+ t.Fatalf("plugin not found at %s: %v", i.Path(), err)
+ }
+}
+
+func TestLocalInstallerTarballWithSubdirectory(t *testing.T) {
+ ensure.HelmHome(t)
+
+ // Create a test tarball with subdirectory
+ tempDir := t.TempDir()
+ tarballPath := filepath.Join(tempDir, "subdir-plugin-1.0.0.tar.gz")
+
+ // Create tarball content
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+
+ files := []struct {
+ Name string
+ Body string
+ Mode int64
+ IsDir bool
+ }{
+ {"my-plugin/", "", 0755, true},
+ {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644, false},
+ {"my-plugin/bin/", "", 0755, true},
+ {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, false},
+ }
+
+ for _, file := range files {
+ hdr := &tar.Header{
+ Name: file.Name,
+ Mode: file.Mode,
+ }
+ if file.IsDir {
+ hdr.Typeflag = tar.TypeDir
+ } else {
+ hdr.Size = int64(len(file.Body))
+ }
+
+ if err := tw.WriteHeader(hdr); err != nil {
+ t.Fatal(err)
+ }
+ if !file.IsDir {
+ if _, err := tw.Write([]byte(file.Body)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := gw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Write tarball to file
+ if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test installation
+ i, err := NewForSource(tarballPath, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+
+ if err := Install(i); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedPath := helmpath.DataPath("plugins", "subdir-plugin")
+ if i.Path() != expectedPath {
+ t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
+ }
+
+ // Verify plugin was installed from subdirectory
+ pluginYaml := filepath.Join(i.Path(), "plugin.yaml")
+ if _, err := os.Stat(pluginYaml); err != nil {
+ t.Fatalf("plugin.yaml not found at %s: %v", pluginYaml, err)
+ }
+}
diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go
new file mode 100644
index 000000000..a96a94ee1
--- /dev/null
+++ b/internal/plugin/installer/oci_installer.go
@@ -0,0 +1,216 @@
+/*
+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 installer
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "helm.sh/helm/v4/internal/plugin/cache"
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/registry"
+)
+
+// OCIInstaller installs plugins from OCI registries
+type OCIInstaller struct {
+ CacheDir string
+ PluginName string
+ base
+ settings *cli.EnvSettings
+ getter getter.Getter
+}
+
+// NewOCIInstaller creates a new OCIInstaller with optional getter options
+func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, error) {
+ // Extract plugin name from OCI reference using robust registry parsing
+ pluginName, err := registry.GetPluginName(source)
+ if err != nil {
+ return nil, err
+ }
+
+ key, err := cache.Key(source)
+ if err != nil {
+ return nil, err
+ }
+
+ settings := cli.New()
+
+ // Always add plugin artifact type and any provided options
+ pluginOptions := append([]getter.Option{getter.WithArtifactType("plugin")}, options...)
+ getterProvider, err := getter.NewOCIGetter(pluginOptions...)
+ if err != nil {
+ return nil, err
+ }
+
+ i := &OCIInstaller{
+ CacheDir: helmpath.CachePath("plugins", key),
+ PluginName: pluginName,
+ base: newBase(source),
+ settings: settings,
+ getter: getterProvider,
+ }
+ return i, nil
+}
+
+// Install downloads and installs a plugin from OCI registry
+// Implements Installer.
+func (i *OCIInstaller) Install() error {
+ slog.Debug("pulling OCI plugin", "source", i.Source)
+
+ // Use getter to download the plugin
+ pluginData, err := i.getter.Get(i.Source)
+ if err != nil {
+ return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
+ }
+
+ // Create cache directory
+ if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
+ return fmt.Errorf("failed to create cache directory: %w", err)
+ }
+
+ // Check if this is a gzip compressed file
+ pluginBytes := pluginData.Bytes()
+ if len(pluginBytes) < 2 || pluginBytes[0] != 0x1f || pluginBytes[1] != 0x8b {
+ return fmt.Errorf("plugin data is not a gzip compressed archive")
+ }
+
+ // Extract as gzipped tar
+ if err := extractTarGz(bytes.NewReader(pluginBytes), i.CacheDir); err != nil {
+ return fmt.Errorf("failed to extract plugin: %w", err)
+ }
+
+ // Verify plugin.yaml exists - check root and subdirectories
+ pluginDir := i.CacheDir
+ if !isPlugin(pluginDir) {
+ // Check if plugin.yaml is in a subdirectory
+ entries, err := os.ReadDir(i.CacheDir)
+ if err != nil {
+ return err
+ }
+
+ foundPluginDir := ""
+ for _, entry := range entries {
+ if entry.IsDir() {
+ subDir := filepath.Join(i.CacheDir, entry.Name())
+ if isPlugin(subDir) {
+ foundPluginDir = subDir
+ break
+ }
+ }
+ }
+
+ if foundPluginDir == "" {
+ return ErrMissingMetadata
+ }
+
+ // Use the subdirectory as the plugin directory
+ pluginDir = foundPluginDir
+ }
+
+ // Copy from cache to final destination
+ src, err := filepath.Abs(pluginDir)
+ if err != nil {
+ return err
+ }
+
+ slog.Debug("copying", "source", src, "path", i.Path())
+ return fs.CopyDir(src, i.Path())
+}
+
+// Update updates a plugin by reinstalling it
+func (i *OCIInstaller) Update() error {
+ // For OCI, update means removing the old version and installing the new one
+ if err := os.RemoveAll(i.Path()); err != nil {
+ return err
+ }
+ return i.Install()
+}
+
+// Path is where the plugin will be installed
+func (i OCIInstaller) Path() string {
+ if i.Source == "" {
+ return ""
+ }
+ return filepath.Join(i.settings.PluginsDirectory, i.PluginName)
+}
+
+// extractTarGz extracts a gzipped tar archive to a directory
+func extractTarGz(r io.Reader, targetDir string) error {
+ gzr, err := gzip.NewReader(r)
+ if err != nil {
+ return err
+ }
+ defer gzr.Close()
+
+ return extractTar(gzr, targetDir)
+}
+
+// extractTar extracts a tar archive to a directory
+func extractTar(r io.Reader, targetDir string) error {
+ tarReader := tar.NewReader(r)
+
+ for {
+ header, err := tarReader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+
+ path, err := cleanJoin(targetDir, header.Name)
+ if err != nil {
+ return err
+ }
+
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(path, 0755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+
+ outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
+ if err != nil {
+ return err
+ }
+ defer outFile.Close()
+ if _, err := io.Copy(outFile, tarReader); err != nil {
+ return err
+ }
+ case tar.TypeXGlobalHeader, tar.TypeXHeader:
+ // Skip these
+ continue
+ default:
+ return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go
new file mode 100644
index 000000000..1ed10ff8e
--- /dev/null
+++ b/internal/plugin/installer/oci_installer_test.go
@@ -0,0 +1,814 @@
+/*
+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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/opencontainers/go-digest"
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
+)
+
+var _ Installer = new(OCIInstaller)
+
+// createTestPluginTarGz creates a test plugin tar.gz with plugin.yaml
+func createTestPluginTarGz(t *testing.T, pluginName string) []byte {
+ t.Helper()
+
+ var buf bytes.Buffer
+ gzWriter := gzip.NewWriter(&buf)
+ tarWriter := tar.NewWriter(gzWriter)
+
+ // Add plugin.yaml
+ pluginYAML := fmt.Sprintf(`name: %s
+version: "1.0.0"
+description: "Test plugin for OCI installer"
+command: "$HELM_PLUGIN_DIR/bin/%s"
+`, pluginName, pluginName)
+ header := &tar.Header{
+ Name: "plugin.yaml",
+ Mode: 0644,
+ Size: int64(len(pluginYAML)),
+ Typeflag: tar.TypeReg,
+ }
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tarWriter.Write([]byte(pluginYAML)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Add bin directory
+ dirHeader := &tar.Header{
+ Name: "bin/",
+ Mode: 0755,
+ Typeflag: tar.TypeDir,
+ }
+ if err := tarWriter.WriteHeader(dirHeader); err != nil {
+ t.Fatal(err)
+ }
+
+ // Add executable
+ execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName)
+ execHeader := &tar.Header{
+ Name: fmt.Sprintf("bin/%s", pluginName),
+ Mode: 0755,
+ Size: int64(len(execContent)),
+ Typeflag: tar.TypeReg,
+ }
+ if err := tarWriter.WriteHeader(execHeader); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tarWriter.Write([]byte(execContent)); err != nil {
+ t.Fatal(err)
+ }
+
+ tarWriter.Close()
+ gzWriter.Close()
+
+ return buf.Bytes()
+}
+
+// mockOCIRegistryWithArtifactType creates a mock OCI registry server using the new artifact type approach
+func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest.Server, string) {
+ t.Helper()
+
+ pluginData := createTestPluginTarGz(t, pluginName)
+ layerDigest := fmt.Sprintf("sha256:%x", sha256Sum(pluginData))
+
+ // Create empty config data (as per OCI v1.1+ spec)
+ configData := []byte("{}")
+ configDigest := fmt.Sprintf("sha256:%x", sha256Sum(configData))
+
+ // Create manifest with artifact type
+ manifest := ocispec.Manifest{
+ MediaType: ocispec.MediaTypeImageManifest,
+ ArtifactType: "application/vnd.helm.plugin.v1+json", // Using artifact type
+ Config: ocispec.Descriptor{
+ MediaType: "application/vnd.oci.empty.v1+json", // Empty config
+ Digest: digest.Digest(configDigest),
+ Size: int64(len(configData)),
+ },
+ Layers: []ocispec.Descriptor{
+ {
+ MediaType: "application/vnd.oci.image.layer.v1.tar",
+ Digest: digest.Digest(layerDigest),
+ Size: int64(len(pluginData)),
+ Annotations: map[string]string{
+ ocispec.AnnotationTitle: pluginName + ".tgz", // Layer named properly
+ },
+ },
+ },
+ }
+
+ manifestData, err := json.Marshal(manifest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ manifestDigest := fmt.Sprintf("sha256:%x", sha256Sum(manifestData))
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/") && !strings.Contains(r.URL.Path, "/manifests/") && !strings.Contains(r.URL.Path, "/blobs/"):
+ // API version check
+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("{}"))
+
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/manifests/") && strings.Contains(r.URL.Path, pluginName):
+ // Return manifest
+ w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
+ w.Header().Set("Docker-Content-Digest", manifestDigest)
+ w.WriteHeader(http.StatusOK)
+ w.Write(manifestData)
+
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+layerDigest):
+ // Return layer data
+ w.Header().Set("Content-Type", "application/vnd.oci.image.layer.v1.tar")
+ w.WriteHeader(http.StatusOK)
+ w.Write(pluginData)
+
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+configDigest):
+ // Return config data
+ w.Header().Set("Content-Type", "application/vnd.oci.empty.v1+json")
+ w.WriteHeader(http.StatusOK)
+ w.Write(configData)
+
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+
+ // Parse server URL to get host:port format for OCI reference
+ serverURL, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ registryHost := serverURL.Host
+
+ return server, registryHost
+}
+
+// sha256Sum calculates SHA256 sum of data
+func sha256Sum(data []byte) []byte {
+ h := sha256.New()
+ h.Write(data)
+ return h.Sum(nil)
+}
+
+func TestNewOCIInstaller(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ expectName string
+ expectError bool
+ }{
+ {
+ name: "valid OCI reference with tag",
+ source: "oci://ghcr.io/user/plugin-name:v1.0.0",
+ expectName: "plugin-name",
+ expectError: false,
+ },
+ {
+ name: "valid OCI reference with digest",
+ source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
+ expectName: "plugin-name",
+ expectError: false,
+ },
+ {
+ name: "valid OCI reference without tag",
+ source: "oci://ghcr.io/user/plugin-name",
+ expectName: "plugin-name",
+ expectError: false,
+ },
+ {
+ name: "valid OCI reference with multiple path segments",
+ source: "oci://registry.example.com/org/team/plugin-name:latest",
+ expectName: "plugin-name",
+ expectError: false,
+ },
+ {
+ name: "invalid OCI reference - no path",
+ source: "oci://registry.example.com",
+ expectName: "",
+ expectError: true,
+ },
+ {
+ name: "valid OCI reference - single path segment",
+ source: "oci://registry.example.com/plugin",
+ expectName: "plugin",
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ installer, err := NewOCIInstaller(tt.source)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ return
+ }
+
+ // Check all fields thoroughly
+ if installer.PluginName != tt.expectName {
+ t.Errorf("expected plugin name %s, got %s", tt.expectName, installer.PluginName)
+ }
+
+ if installer.Source != tt.source {
+ t.Errorf("expected source %s, got %s", tt.source, installer.Source)
+ }
+
+ if installer.CacheDir == "" {
+ t.Error("expected non-empty cache directory")
+ }
+
+ if !strings.Contains(installer.CacheDir, "plugins") {
+ t.Errorf("expected cache directory to contain 'plugins', got %s", installer.CacheDir)
+ }
+
+ if installer.settings == nil {
+ t.Error("expected settings to be initialized")
+ }
+
+ // Check that Path() method works
+ expectedPath := helmpath.DataPath("plugins", tt.expectName)
+ if installer.Path() != expectedPath {
+ t.Errorf("expected path %s, got %s", expectedPath, installer.Path())
+ }
+ })
+ }
+}
+
+func TestOCIInstaller_Path(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ pluginName string
+ expectPath string
+ }{
+ {
+ name: "valid plugin name",
+ source: "oci://ghcr.io/user/plugin-name:v1.0.0",
+ pluginName: "plugin-name",
+ expectPath: helmpath.DataPath("plugins", "plugin-name"),
+ },
+ {
+ name: "empty source",
+ source: "",
+ pluginName: "",
+ expectPath: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ installer := &OCIInstaller{
+ PluginName: tt.pluginName,
+ base: newBase(tt.source),
+ settings: cli.New(),
+ }
+
+ path := installer.Path()
+ if path != tt.expectPath {
+ t.Errorf("expected path %s, got %s", tt.expectPath, path)
+ }
+ })
+ }
+}
+
+func TestOCIInstaller_Install(t *testing.T) {
+ // Set up isolated test environment FIRST
+ testPluginsDir := t.TempDir()
+ t.Setenv("HELM_PLUGINS", testPluginsDir)
+
+ pluginName := "test-plugin-basic"
+ server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
+ defer server.Close()
+
+ // Test OCI reference
+ source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
+
+ // Test with plain HTTP (since test server uses HTTP)
+ installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ // The OCI installer uses helmpath.DataPath, which now points to our test directory
+ actualPath := installer.Path()
+ t.Logf("Installer will use path: %s", actualPath)
+
+ // Verify the path is actually in our test directory
+ if !strings.HasPrefix(actualPath, testPluginsDir) {
+ t.Fatalf("Expected path %s to be under test directory %s", actualPath, testPluginsDir)
+ }
+
+ // Install the plugin
+ if err := Install(installer); err != nil {
+ t.Fatalf("Expected installation to succeed, got error: %v", err)
+ }
+
+ // Verify plugin was installed to the correct location
+ if !isPlugin(actualPath) {
+ t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
+ }
+
+ // Debug: list what was actually created
+ if entries, err := os.ReadDir(actualPath); err != nil {
+ t.Fatalf("Could not read plugin directory %s: %v", actualPath, err)
+ } else {
+ t.Logf("Plugin directory %s contains:", actualPath)
+ for _, entry := range entries {
+ t.Logf(" - %s", entry.Name())
+ }
+ }
+
+ // Verify the plugin.yaml file exists and is valid
+ pluginFile := filepath.Join(actualPath, "plugin.yaml")
+ if _, err := os.Stat(pluginFile); err != nil {
+ t.Errorf("Expected plugin.yaml to exist, got error: %v", err)
+ }
+}
+
+func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) {
+ testCases := []struct {
+ name string
+ pluginName string
+ options []getter.Option
+ wantErr bool
+ }{
+ {
+ name: "plain HTTP",
+ pluginName: "example-cli-plain-http",
+ options: []getter.Option{getter.WithPlainHTTP(true)},
+ wantErr: false,
+ },
+ {
+ name: "insecure skip TLS verify",
+ pluginName: "example-cli-insecure",
+ options: []getter.Option{getter.WithPlainHTTP(true), getter.WithInsecureSkipVerifyTLS(true)},
+ wantErr: false,
+ },
+ {
+ name: "with timeout",
+ pluginName: "example-cli-timeout",
+ options: []getter.Option{getter.WithPlainHTTP(true), getter.WithTimeout(30 * time.Second)},
+ wantErr: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Set up isolated test environment for each subtest
+ testPluginsDir := t.TempDir()
+ t.Setenv("HELM_PLUGINS", testPluginsDir)
+
+ server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName)
+ defer server.Close()
+
+ source := fmt.Sprintf("oci://%s/%s:latest", registryHost, tc.pluginName)
+
+ installer, err := NewOCIInstaller(source, tc.options...)
+ if err != nil {
+ if !tc.wantErr {
+ t.Fatalf("Expected no error creating installer, got %v", err)
+ }
+ return
+ }
+
+ // The installer now uses our isolated test directory
+ actualPath := installer.Path()
+
+ // Install the plugin
+ err = Install(installer)
+ if tc.wantErr {
+ if err == nil {
+ t.Errorf("Expected installation to fail, but it succeeded")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Expected installation to succeed, got error: %v", err)
+ } else {
+ // Verify plugin was installed to the actual path
+ if !isPlugin(actualPath) {
+ t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestOCIInstaller_Install_AlreadyExists(t *testing.T) {
+ // Set up isolated test environment
+ testPluginsDir := t.TempDir()
+ t.Setenv("HELM_PLUGINS", testPluginsDir)
+
+ pluginName := "test-plugin-exists"
+ server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
+ defer server.Close()
+
+ source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
+ installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ // First install should succeed
+ if err := Install(installer); err != nil {
+ t.Fatalf("Expected first installation to succeed, got error: %v", err)
+ }
+
+ // Verify plugin was installed
+ if !isPlugin(installer.Path()) {
+ t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
+ }
+
+ // Second install should fail with "plugin already exists"
+ err = Install(installer)
+ if err == nil {
+ t.Error("Expected error when installing plugin that already exists")
+ } else if !strings.Contains(err.Error(), "plugin already exists") {
+ t.Errorf("Expected 'plugin already exists' error, got: %v", err)
+ }
+}
+
+func TestOCIInstaller_Update(t *testing.T) {
+ // Set up isolated test environment
+ testPluginsDir := t.TempDir()
+ t.Setenv("HELM_PLUGINS", testPluginsDir)
+
+ pluginName := "test-plugin-update"
+ server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
+ defer server.Close()
+
+ source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
+ installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
+ if err != nil {
+ t.Fatalf("Expected no error, got %v", err)
+ }
+
+ // Test update when plugin does not exist - should fail
+ err = Update(installer)
+ if err == nil {
+ t.Error("Expected error when updating plugin that does not exist")
+ } else if !strings.Contains(err.Error(), "plugin does not exist") {
+ t.Errorf("Expected 'plugin does not exist' error, got: %v", err)
+ }
+
+ // Install plugin first
+ if err := Install(installer); err != nil {
+ t.Fatalf("Expected installation to succeed, got error: %v", err)
+ }
+
+ // Verify plugin was installed
+ if !isPlugin(installer.Path()) {
+ t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
+ }
+
+ // Test update when plugin exists - should succeed
+ // For OCI, Update() removes old version and reinstalls
+ if err := Update(installer); err != nil {
+ t.Errorf("Expected update to succeed, got error: %v", err)
+ }
+
+ // Verify plugin is still installed after update
+ if !isPlugin(installer.Path()) {
+ t.Errorf("Expected plugin directory %s to contain plugin.yaml after update", installer.Path())
+ }
+}
+
+func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) {
+ // Test that we can extract a plugin archive properly
+ // This tests the extraction logic that Install() uses
+ tempDir := t.TempDir()
+ pluginName := "test-plugin-extract"
+
+ pluginData := createTestPluginTarGz(t, pluginName)
+
+ // Test extraction
+ err := extractTarGz(bytes.NewReader(pluginData), tempDir)
+ if err != nil {
+ t.Fatalf("Failed to extract plugin: %v", err)
+ }
+
+ // 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")
+ }
+
+ // 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")
+ }
+
+ // Verify executable exists and has correct permissions
+ execPath := filepath.Join(tempDir, "bin", pluginName)
+ 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")
+ }
+
+ // Verify this would be recognized as a plugin
+ if !isPlugin(tempDir) {
+ t.Errorf("extracted directory is not a valid plugin")
+ }
+}
+
+func TestExtractTarGz(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create a test tar.gz file
+ var buf bytes.Buffer
+ gzWriter := gzip.NewWriter(&buf)
+ tarWriter := tar.NewWriter(gzWriter)
+
+ // Add a test file to the archive
+ testContent := "test content"
+ header := &tar.Header{
+ Name: "test-file.txt",
+ Mode: 0644,
+ Size: int64(len(testContent)),
+ Typeflag: tar.TypeReg,
+ }
+
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := tarWriter.Write([]byte(testContent)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Add a test directory
+ dirHeader := &tar.Header{
+ Name: "test-dir/",
+ Mode: 0755,
+ Typeflag: tar.TypeDir,
+ }
+
+ if err := tarWriter.WriteHeader(dirHeader); err != nil {
+ t.Fatal(err)
+ }
+
+ tarWriter.Close()
+ gzWriter.Close()
+
+ // Test extraction
+ err := extractTarGz(bytes.NewReader(buf.Bytes()), tempDir)
+ if err != nil {
+ t.Errorf("extractTarGz failed: %v", err)
+ }
+
+ // Verify extracted file
+ extractedFile := filepath.Join(tempDir, "test-file.txt")
+ content, err := os.ReadFile(extractedFile)
+ if err != nil {
+ t.Errorf("failed to read extracted file: %v", err)
+ }
+
+ if string(content) != testContent {
+ t.Errorf("expected content %s, got %s", testContent, string(content))
+ }
+
+ // Verify extracted directory
+ extractedDir := filepath.Join(tempDir, "test-dir")
+ if _, err := os.Stat(extractedDir); os.IsNotExist(err) {
+ t.Errorf("extracted directory does not exist: %s", extractedDir)
+ }
+}
+
+func TestExtractTarGz_InvalidGzip(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Test with invalid gzip data
+ invalidGzipData := []byte("not gzip data")
+ err := extractTarGz(bytes.NewReader(invalidGzipData), tempDir)
+ if err == nil {
+ t.Error("expected error for invalid gzip data")
+ }
+}
+
+func TestExtractTar_UnknownFileType(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Create a test tar file
+ var buf bytes.Buffer
+ tarWriter := tar.NewWriter(&buf)
+
+ // Add a test file
+ testContent := "test content"
+ header := &tar.Header{
+ Name: "test-file.txt",
+ Mode: 0644,
+ Size: int64(len(testContent)),
+ Typeflag: tar.TypeReg,
+ }
+
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := tarWriter.Write([]byte(testContent)); err != nil {
+ t.Fatal(err)
+ }
+
+ // Test unknown file type
+ unknownHeader := &tar.Header{
+ Name: "unknown-type",
+ Mode: 0644,
+ Typeflag: tar.TypeSymlink, // Use a type that's not handled
+ }
+
+ if err := tarWriter.WriteHeader(unknownHeader); err != nil {
+ t.Fatal(err)
+ }
+
+ tarWriter.Close()
+
+ // Test extraction - should fail due to unknown type
+ err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
+ if err == nil {
+ t.Error("expected error for unknown tar file type")
+ }
+
+ if !strings.Contains(err.Error(), "unknown type") {
+ t.Errorf("expected 'unknown type' error, got: %v", err)
+ }
+}
+
+func TestExtractTar_SuccessfulExtraction(t *testing.T) {
+ tempDir := t.TempDir()
+
+ // Since we can't easily create extended headers with Go's tar package,
+ // we'll test the logic that skips them by creating a simple tar with regular files
+ // and then testing that the extraction works correctly.
+
+ // Create a test tar file
+ var buf bytes.Buffer
+ tarWriter := tar.NewWriter(&buf)
+
+ // Add a regular file
+ testContent := "test content"
+ header := &tar.Header{
+ Name: "test-file.txt",
+ Mode: 0644,
+ Size: int64(len(testContent)),
+ Typeflag: tar.TypeReg,
+ }
+
+ if err := tarWriter.WriteHeader(header); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := tarWriter.Write([]byte(testContent)); err != nil {
+ t.Fatal(err)
+ }
+
+ tarWriter.Close()
+
+ // Test extraction
+ err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
+ if err != nil {
+ t.Errorf("extractTar failed: %v", err)
+ }
+
+ // Verify the regular file was extracted
+ extractedFile := filepath.Join(tempDir, "test-file.txt")
+ content, err := os.ReadFile(extractedFile)
+ if err != nil {
+ t.Errorf("failed to read extracted file: %v", err)
+ }
+
+ if string(content) != testContent {
+ t.Errorf("expected content %s, got %s", testContent, string(content))
+ }
+}
+
+func TestOCIInstaller_Install_PlainHTTPOption(t *testing.T) {
+ // Test that PlainHTTP option is properly passed to getter
+ source := "oci://example.com/test-plugin:v1.0.0"
+
+ // Test with PlainHTTP=false (default)
+ installer1, err := NewOCIInstaller(source)
+ if err != nil {
+ t.Fatalf("failed to create installer: %v", err)
+ }
+ if installer1.getter == nil {
+ t.Error("getter should be initialized")
+ }
+
+ // Test with PlainHTTP=true
+ installer2, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
+ if err != nil {
+ t.Fatalf("failed to create installer with PlainHTTP=true: %v", err)
+ }
+ if installer2.getter == nil {
+ t.Error("getter should be initialized with PlainHTTP=true")
+ }
+
+ // Both installers should have the same basic properties
+ if installer1.PluginName != installer2.PluginName {
+ t.Error("plugin names should match")
+ }
+ if installer1.Source != installer2.Source {
+ t.Error("sources should match")
+ }
+
+ // Test with multiple options
+ installer3, err := NewOCIInstaller(source,
+ getter.WithPlainHTTP(true),
+ getter.WithBasicAuth("user", "pass"),
+ )
+ if err != nil {
+ t.Fatalf("failed to create installer with multiple options: %v", err)
+ }
+ if installer3.getter == nil {
+ t.Error("getter should be initialized with multiple options")
+ }
+}
+
+func TestOCIInstaller_Install_ValidationErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ layerData []byte
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "non-gzip layer",
+ layerData: []byte("not gzip data"),
+ expectError: true,
+ errorMsg: "is not a gzip compressed archive",
+ },
+ {
+ name: "empty layer",
+ layerData: []byte{},
+ expectError: true,
+ errorMsg: "is not a gzip compressed archive",
+ },
+ {
+ name: "single byte layer",
+ layerData: []byte{0x1f},
+ expectError: true,
+ errorMsg: "is not a gzip compressed archive",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Test the gzip validation logic that's used in the Install method
+ if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b {
+ // This matches the validation in the Install method
+ if !tt.expectError {
+ 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'")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/plugin/installer/plugin_structure.go b/internal/plugin/installer/plugin_structure.go
new file mode 100644
index 000000000..10647141e
--- /dev/null
+++ b/internal/plugin/installer/plugin_structure.go
@@ -0,0 +1,80 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package installer
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "helm.sh/helm/v4/internal/plugin"
+)
+
+// detectPluginRoot searches for plugin.yaml in the extracted directory
+// and returns the path to the directory containing it.
+// This handles cases where the tarball contains the plugin in a subdirectory.
+func detectPluginRoot(extractDir string) (string, error) {
+ // First check if plugin.yaml is at the root
+ if _, err := os.Stat(filepath.Join(extractDir, plugin.PluginFileName)); err == nil {
+ return extractDir, nil
+ }
+
+ // Otherwise, look for plugin.yaml in subdirectories (only one level deep)
+ entries, err := os.ReadDir(extractDir)
+ if err != nil {
+ return "", err
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ subdir := filepath.Join(extractDir, entry.Name())
+ if _, err := os.Stat(filepath.Join(subdir, plugin.PluginFileName)); err == nil {
+ return subdir, nil
+ }
+ }
+ }
+
+ return "", fmt.Errorf("plugin.yaml not found in %s or its immediate subdirectories", extractDir)
+}
+
+// validatePluginName checks if the plugin directory name matches the plugin name
+// from plugin.yaml when the plugin is in a subdirectory.
+func validatePluginName(pluginRoot string, expectedName string) error {
+ // Only validate if plugin is in a subdirectory
+ dirName := filepath.Base(pluginRoot)
+ if dirName == expectedName {
+ return nil
+ }
+
+ // Load plugin.yaml to get the actual name
+ p, err := plugin.LoadDir(pluginRoot)
+ if err != nil {
+ return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err)
+ }
+
+ m := p.Metadata()
+ actualName := m.Name
+
+ // For now, just log a warning if names don't match
+ // In the future, we might want to enforce this more strictly
+ if actualName != dirName && actualName != strings.TrimSuffix(expectedName, filepath.Ext(expectedName)) {
+ // This is just informational - not an error
+ return nil
+ }
+
+ return nil
+}
diff --git a/internal/plugin/installer/plugin_structure_test.go b/internal/plugin/installer/plugin_structure_test.go
new file mode 100644
index 000000000..c8766ce59
--- /dev/null
+++ b/internal/plugin/installer/plugin_structure_test.go
@@ -0,0 +1,165 @@
+/*
+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 installer
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDetectPluginRoot(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(dir string) error
+ expectRoot string
+ expectError bool
+ }{
+ {
+ name: "plugin.yaml at root",
+ setup: func(dir string) error {
+ return os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: test"), 0644)
+ },
+ expectRoot: ".",
+ expectError: false,
+ },
+ {
+ name: "plugin.yaml in subdirectory",
+ setup: func(dir string) error {
+ subdir := filepath.Join(dir, "my-plugin")
+ if err := os.MkdirAll(subdir, 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
+ },
+ expectRoot: "my-plugin",
+ expectError: false,
+ },
+ {
+ name: "no plugin.yaml",
+ setup: func(dir string) error {
+ return os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0644)
+ },
+ expectRoot: "",
+ expectError: true,
+ },
+ {
+ name: "plugin.yaml in nested subdirectory (should not find)",
+ setup: func(dir string) error {
+ subdir := filepath.Join(dir, "outer", "inner")
+ if err := os.MkdirAll(subdir, 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
+ },
+ expectRoot: "",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ if err := tt.setup(dir); err != nil {
+ t.Fatalf("Setup failed: %v", err)
+ }
+
+ root, err := detectPluginRoot(dir)
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ expectedPath := dir
+ if tt.expectRoot != "." {
+ expectedPath = filepath.Join(dir, tt.expectRoot)
+ }
+ if root != expectedPath {
+ t.Errorf("Expected root %s but got %s", expectedPath, root)
+ }
+ }
+ })
+ }
+}
+
+func TestValidatePluginName(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(dir string) error
+ pluginRoot string
+ expectedName string
+ expectError bool
+ }{
+ {
+ name: "matching directory and plugin name",
+ setup: func(dir string) error {
+ subdir := filepath.Join(dir, "my-plugin")
+ if err := os.MkdirAll(subdir, 0755); err != nil {
+ return err
+ }
+ yaml := `name: my-plugin
+version: 1.0.0
+usage: test
+description: test`
+ return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
+ },
+ pluginRoot: "my-plugin",
+ expectedName: "my-plugin",
+ expectError: false,
+ },
+ {
+ name: "different directory and plugin name",
+ setup: func(dir string) error {
+ subdir := filepath.Join(dir, "wrong-name")
+ if err := os.MkdirAll(subdir, 0755); err != nil {
+ return err
+ }
+ yaml := `name: my-plugin
+version: 1.0.0
+usage: test
+description: test`
+ return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
+ },
+ pluginRoot: "wrong-name",
+ expectedName: "wrong-name",
+ expectError: false, // Currently we don't error on mismatch
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ if err := tt.setup(dir); err != nil {
+ t.Fatalf("Setup failed: %v", err)
+ }
+
+ pluginRoot := filepath.Join(dir, tt.pluginRoot)
+ err := validatePluginName(pluginRoot, tt.expectedName)
+ if tt.expectError {
+ if err == nil {
+ t.Error("Expected error but got none")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/plugin/installer/vcs_installer.go b/internal/plugin/installer/vcs_installer.go
similarity index 80%
rename from pkg/plugin/installer/vcs_installer.go
rename to internal/plugin/installer/vcs_installer.go
index f7df5b322..3601ec7a8 100644
--- a/pkg/plugin/installer/vcs_installer.go
+++ b/internal/plugin/installer/vcs_installer.go
@@ -13,19 +13,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
+ "errors"
+ "fmt"
+ stdfs "io/fs"
+ "log/slog"
"os"
"sort"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/vcs"
- "github.com/pkg/errors"
- "helm.sh/helm/v3/internal/third_party/dep/fs"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/plugin/cache"
+ "helm.sh/helm/v4/internal/plugin/cache"
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/pkg/helmpath"
)
// VCSInstaller installs plugins from remote a repository.
@@ -63,7 +66,7 @@ func NewVCSInstaller(source, version string) (*VCSInstaller, error) {
Version: version,
base: newBase(source),
}
- return i, err
+ return i, nil
}
// Install clones a remote repository and installs into the plugin directory.
@@ -88,13 +91,13 @@ func (i *VCSInstaller) Install() error {
return ErrMissingMetadata
}
- debug("copying %s to %s", i.Repo.LocalPath(), i.Path())
+ slog.Debug("copying files", "source", i.Repo.LocalPath(), "destination", i.Path())
return fs.CopyDir(i.Repo.LocalPath(), i.Path())
}
// Update updates a remote repository
func (i *VCSInstaller) Update() error {
- debug("updating %s", i.Repo.Remote())
+ slog.Debug("updating", "source", i.Repo.Remote())
if i.Repo.IsDirty() {
return errors.New("plugin repo was modified")
}
@@ -128,7 +131,7 @@ func (i *VCSInstaller) solveVersion(repo vcs.Repo) (string, error) {
if err != nil {
return "", err
}
- debug("found refs: %s", refs)
+ slog.Debug("found refs", "refs", refs)
// Convert and filter the list to semver.Version instances
semvers := getSemVers(refs)
@@ -139,27 +142,27 @@ func (i *VCSInstaller) solveVersion(repo vcs.Repo) (string, error) {
if constraint.Check(v) {
// If the constraint passes get the original reference
ver := v.Original()
- debug("setting to %s", ver)
+ slog.Debug("setting to version", "version", ver)
return ver, nil
}
}
- return "", errors.Errorf("requested version %q does not exist for plugin %q", i.Version, i.Repo.Remote())
+ return "", fmt.Errorf("requested version %q does not exist for plugin %q", i.Version, i.Repo.Remote())
}
// setVersion attempts to checkout the version
func (i *VCSInstaller) setVersion(repo vcs.Repo, ref string) error {
- debug("setting version to %q", i.Version)
+ slog.Debug("setting version", "version", i.Version)
return repo.UpdateVersion(ref)
}
// sync will clone or update a remote repo.
func (i *VCSInstaller) sync(repo vcs.Repo) error {
- if _, err := os.Stat(repo.LocalPath()); os.IsNotExist(err) {
- debug("cloning %s to %s", repo.Remote(), repo.LocalPath())
+ if _, err := os.Stat(repo.LocalPath()); errors.Is(err, stdfs.ErrNotExist) {
+ slog.Debug("cloning", "source", repo.Remote(), "destination", repo.LocalPath())
return repo.Get()
}
- debug("updating %s", repo.Remote())
+ slog.Debug("updating", "source", repo.Remote(), "destination", repo.LocalPath())
return repo.Update()
}
diff --git a/pkg/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go
similarity index 90%
rename from pkg/plugin/installer/vcs_installer_test.go
rename to internal/plugin/installer/vcs_installer_test.go
index 0bb0b6780..f024b4b40 100644
--- a/pkg/plugin/installer/vcs_installer_test.go
+++ b/internal/plugin/installer/vcs_installer_test.go
@@ -13,18 +13,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
+package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"fmt"
"os"
"path/filepath"
+ "strings"
"testing"
"github.com/Masterminds/vcs"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/helmpath"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/helmpath"
)
var _ Installer = new(VCSInstaller)
@@ -56,7 +57,7 @@ func TestVCSInstaller(t *testing.T) {
}
source := "https://github.com/adamreese/helm-env"
- testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo")
+ testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo-v1")
repo := &testRepo{
local: testRepoPath,
tags: []string{"0.1.0", "0.1.1"},
@@ -119,6 +120,8 @@ func TestVCSInstallerNonExistentVersion(t *testing.T) {
if err := Install(i); err == nil {
t.Fatalf("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) {
t.Fatalf("expected error for version does not exists, got (%v)", err)
}
@@ -146,7 +149,11 @@ func TestVCSInstallerUpdate(t *testing.T) {
// Install plugin before update
if err := Install(i); err != nil {
- t.Fatal(err)
+ if strings.Contains(err.Error(), "Could not resolve host: github.com") {
+ t.Skip("Unable to run test without Internet access")
+ } else {
+ t.Fatal(err)
+ }
}
// Test FindSource method for positive result
diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go
new file mode 100644
index 000000000..eb05cb722
--- /dev/null
+++ b/internal/plugin/loader.go
@@ -0,0 +1,249 @@
+/*
+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 (
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "go.yaml.in/yaml/v3"
+)
+
+func peekAPIVersion(r io.Reader) (string, error) {
+ type apiVersion struct {
+ APIVersion string `yaml:"apiVersion"`
+ }
+
+ var v apiVersion
+ d := yaml.NewDecoder(r)
+ if err := d.Decode(&v); err != nil {
+ return "", err
+ }
+
+ return v.APIVersion, nil
+}
+
+func loadMetadataLegacy(metadataData []byte) (*Metadata, error) {
+
+ var ml MetadataLegacy
+ d := yaml.NewDecoder(bytes.NewReader(metadataData))
+ if err := d.Decode(&ml); err != nil {
+ return nil, err
+ }
+
+ if err := ml.Validate(); err != nil {
+ return nil, err
+ }
+
+ m := fromMetadataLegacy(ml)
+ if err := m.Validate(); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func loadMetadataV1(metadataData []byte) (*Metadata, error) {
+
+ var mv1 MetadataV1
+ d := yaml.NewDecoder(bytes.NewReader(metadataData))
+ if err := d.Decode(&mv1); err != nil {
+ return nil, err
+ }
+
+ if err := mv1.Validate(); err != nil {
+ return nil, err
+ }
+
+ m, err := fromMetadataV1(mv1)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert MetadataV1 to Metadata: %w", err)
+ }
+
+ if err := m.Validate(); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func loadMetadata(metadataData []byte) (*Metadata, error) {
+ apiVersion, err := peekAPIVersion(bytes.NewReader(metadataData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to peek %s API version: %w", PluginFileName, err)
+ }
+
+ switch apiVersion {
+ case "": // legacy
+ return loadMetadataLegacy(metadataData)
+ case "v1":
+ return loadMetadataV1(metadataData)
+ }
+
+ return nil, fmt.Errorf("invalid plugin apiVersion: %q", apiVersion)
+}
+
+type prototypePluginManager struct {
+ runtimes map[string]Runtime
+}
+
+func newPrototypePluginManager() *prototypePluginManager {
+ return &prototypePluginManager{
+ runtimes: map[string]Runtime{
+ "subprocess": &RuntimeSubprocess{},
+ },
+ }
+}
+
+func (pm *prototypePluginManager) RegisterRuntime(runtimeName string, runtime Runtime) {
+ pm.runtimes[runtimeName] = runtime
+}
+
+func (pm *prototypePluginManager) CreatePlugin(pluginPath string, metadata *Metadata) (Plugin, error) {
+ rt, ok := pm.runtimes[metadata.Runtime]
+ if !ok {
+ return nil, fmt.Errorf("unsupported plugin runtime type: %q", metadata.Runtime)
+ }
+
+ return rt.CreatePlugin(pluginPath, metadata)
+}
+
+// LoadDir loads a plugin from the given directory.
+func LoadDir(dirname string) (Plugin, error) {
+ pluginfile := filepath.Join(dirname, PluginFileName)
+ metadataData, err := os.ReadFile(pluginfile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err)
+ }
+
+ m, err := loadMetadata(metadataData)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err)
+ }
+
+ pm := newPrototypePluginManager()
+ return pm.CreatePlugin(dirname, m)
+}
+
+// LoadAll loads all plugins found beneath the base directory.
+//
+// This scans only one directory level.
+func LoadAll(basedir string) ([]Plugin, error) {
+ var plugins []Plugin
+ // We want basedir/*/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)
+ }
+
+ // empty dir should load
+ if len(matches) == 0 {
+ return plugins, nil
+ }
+
+ for _, yamlFile := range matches {
+ dir := filepath.Dir(yamlFile)
+ p, err := LoadDir(dir)
+ if err != nil {
+ return plugins, err
+ }
+ plugins = append(plugins, p)
+ }
+ return plugins, detectDuplicates(plugins)
+}
+
+// findFunc is a function that finds plugins in a directory
+type findFunc func(pluginsDir string) ([]Plugin, error)
+
+// filterFunc is a function that filters plugins
+type filterFunc func(Plugin) bool
+
+// FindPlugins returns a list of plugins that match the descriptor
+func FindPlugins(pluginsDirs []string, descriptor Descriptor) ([]Plugin, error) {
+ return findPlugins(pluginsDirs, LoadAll, makeDescriptorFilter(descriptor))
+}
+
+// findPlugins is the internal implementation that uses the find and filter functions
+func findPlugins(pluginsDirs []string, findFn findFunc, filterFn filterFunc) ([]Plugin, error) {
+ var found []Plugin
+ for _, pluginsDir := range pluginsDirs {
+ ps, err := findFn(pluginsDir)
+
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range ps {
+ if filterFn(p) {
+ found = append(found, p)
+ }
+ }
+
+ }
+
+ return found, nil
+}
+
+// makeDescriptorFilter creates a filter function from a descriptor
+// Additional plugin filter criteria we wish to support can be added here
+func makeDescriptorFilter(descriptor Descriptor) filterFunc {
+ return func(p Plugin) bool {
+ // 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 {
+ return false
+ }
+ return true
+ }
+}
+
+// FindPlugin returns a single plugin that matches the descriptor
+func FindPlugin(dirs []string, descriptor Descriptor) (Plugin, error) {
+ plugins, err := FindPlugins(dirs, descriptor)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(plugins) > 0 {
+ return plugins[0], nil
+ }
+
+ return nil, fmt.Errorf("plugin: %+v not found", descriptor)
+}
+
+func detectDuplicates(plugs []Plugin) error {
+ names := map[string]string{}
+
+ for _, plug := range plugs {
+ if oldpath, ok := names[plug.Metadata().Name]; ok {
+ return fmt.Errorf(
+ "two plugins claim the name %q at %q and %q",
+ plug.Metadata().Name,
+ oldpath,
+ plug.Dir(),
+ )
+ }
+ names[plug.Metadata().Name] = plug.Dir()
+ }
+
+ return nil
+}
diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go
new file mode 100644
index 000000000..81ef26e02
--- /dev/null
+++ b/internal/plugin/loader_test.go
@@ -0,0 +1,241 @@
+/*
+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 (
+ "bytes"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPeekAPIVersion(t *testing.T) {
+ testCases := map[string]struct {
+ data []byte
+ expected string
+ }{
+ "v1": {
+ data: []byte(`---
+apiVersion: v1
+name: "test-plugin"
+`),
+ expected: "v1",
+ },
+ "legacy": { // No apiVersion field
+ data: []byte(`---
+name: "test-plugin"
+`),
+ expected: "",
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ version, err := peekAPIVersion(bytes.NewReader(tc.data))
+ require.NoError(t, err)
+ assert.Equal(t, tc.expected, version)
+ })
+ }
+
+ // invalid yaml
+ {
+ data := []byte(`bad yaml`)
+ _, err := peekAPIVersion(bytes.NewReader(data))
+ assert.Error(t, err)
+ }
+}
+
+func TestLoadDir(t *testing.T) {
+
+ makeMetadata := func(apiVersion string) Metadata {
+ usage := "hello [params]..."
+ if apiVersion == "legacy" {
+ usage = "" // Legacy plugins don't have Usage field for command syntax
+ }
+ return Metadata{
+ APIVersion: apiVersion,
+ Name: fmt.Sprintf("hello-%s", apiVersion),
+ Version: "0.1.0",
+ Type: "cli/v1",
+ Runtime: "subprocess",
+ Config: &ConfigCLI{
+ Usage: usage,
+ ShortHelp: "echo hello message",
+ LongHelp: "description",
+ IgnoreFlags: true,
+ },
+ RuntimeConfig: &RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.sh"}},
+ {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "${HELM_PLUGIN_DIR}/hello.ps1"}},
+ },
+ PlatformHooks: map[string][]PlatformCommand{
+ Install: {
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+ {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+ },
+ },
+ },
+ }
+ }
+
+ testCases := map[string]struct {
+ dirname string
+ apiVersion string
+ expect Metadata
+ }{
+ "legacy": {
+ dirname: "testdata/plugdir/good/hello-legacy",
+ apiVersion: "legacy",
+ expect: makeMetadata("legacy"),
+ },
+ "v1": {
+ dirname: "testdata/plugdir/good/hello-v1",
+ apiVersion: "v1",
+ expect: makeMetadata("v1"),
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ plug, err := LoadDir(tc.dirname)
+ require.NoError(t, err, "error loading plugin from %s", tc.dirname)
+
+ assert.Equal(t, tc.dirname, plug.Dir())
+ assert.EqualValues(t, tc.expect, plug.Metadata())
+ })
+ }
+}
+
+func TestLoadDirDuplicateEntries(t *testing.T) {
+ testCases := map[string]string{
+ "legacy": "testdata/plugdir/bad/duplicate-entries-legacy",
+ "v1": "testdata/plugdir/bad/duplicate-entries-v1",
+ }
+ for name, dirname := range testCases {
+ t.Run(name, func(t *testing.T) {
+ _, err := LoadDir(dirname)
+ assert.Error(t, err)
+ })
+ }
+}
+
+func TestLoadDirGetter(t *testing.T) {
+ dirname := "testdata/plugdir/good/getter"
+
+ expect := Metadata{
+ Name: "getter",
+ Version: "1.2.3",
+ Type: "getter/v1",
+ APIVersion: "v1",
+ Runtime: "subprocess",
+ Config: &ConfigGetter{
+ Protocols: []string{"myprotocol", "myprotocols"},
+ },
+ RuntimeConfig: &RuntimeConfigSubprocess{
+ ProtocolCommands: []SubprocessProtocolCommand{
+ {
+ Protocols: []string{"myprotocol", "myprotocols"},
+ Command: "echo getter",
+ },
+ },
+ },
+ }
+
+ plug, err := LoadDir(dirname)
+ require.NoError(t, err)
+ assert.Equal(t, dirname, plug.Dir())
+ assert.Equal(t, expect, plug.Metadata())
+}
+
+func TestDetectDuplicates(t *testing.T) {
+ plugs := []Plugin{
+ mockSubprocessCLIPlugin(t, "foo"),
+ mockSubprocessCLIPlugin(t, "bar"),
+ }
+ if err := detectDuplicates(plugs); err != nil {
+ t.Error("no duplicates in the first set")
+ }
+ plugs = append(plugs, mockSubprocessCLIPlugin(t, "foo"))
+ if err := detectDuplicates(plugs); err == nil {
+ t.Error("duplicates in the second set")
+ }
+}
+
+func TestLoadAll(t *testing.T) {
+ // Verify that empty dir loads:
+ {
+ plugs, err := LoadAll("testdata")
+ require.NoError(t, err)
+ assert.Len(t, plugs, 0)
+ }
+
+ basedir := "testdata/plugdir/good"
+ plugs, err := LoadAll(basedir)
+ require.NoError(t, err)
+ require.NotEmpty(t, plugs, "expected plugins to be loaded from %s", basedir)
+
+ plugsMap := map[string]Plugin{}
+ for _, p := range plugs {
+ plugsMap[p.Metadata().Name] = p
+ }
+
+ assert.Len(t, plugsMap, 6)
+ assert.Contains(t, plugsMap, "downloader")
+ assert.Contains(t, plugsMap, "echo-legacy")
+ assert.Contains(t, plugsMap, "echo-v1")
+ assert.Contains(t, plugsMap, "getter")
+ assert.Contains(t, plugsMap, "hello-legacy")
+ assert.Contains(t, plugsMap, "hello-v1")
+}
+
+func TestFindPlugins(t *testing.T) {
+ cases := []struct {
+ name string
+ plugdirs string
+ expected int
+ }{
+ {
+ name: "plugdirs is empty",
+ plugdirs: "",
+ expected: 0,
+ },
+ {
+ 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: 6,
+ },
+ }
+ for _, c := range cases {
+ t.Run(t.Name(), func(t *testing.T) {
+ plugin, err := LoadAll(c.plugdirs)
+ require.NoError(t, err)
+ assert.Len(t, plugin, c.expected, "expected %d plugins, got %d", c.expected, len(plugin))
+ })
+ }
+}
diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go
new file mode 100644
index 000000000..48741474e
--- /dev/null
+++ b/internal/plugin/metadata.go
@@ -0,0 +1,216 @@
+/*
+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 (
+ "errors"
+ "fmt"
+)
+
+// 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 {
+ // APIVersion specifies the plugin API version
+ APIVersion string
+
+ // Name is the name of the plugin
+ Name string
+
+ // Type of plugin (eg, cli/v1, getter/v1)
+ Type string
+
+ // Runtime specifies the runtime type (subprocess, wasm)
+ Runtime string
+
+ // Version is the SemVer 2 version of the plugin.
+ Version string
+
+ // SourceURL is the URL where this plugin can be found
+ SourceURL string
+
+ // Config contains the type-specific configuration for this plugin
+ Config Config
+
+ // RuntimeConfig contains the runtime-specific configuration
+ RuntimeConfig RuntimeConfig
+}
+
+func (m Metadata) Validate() error {
+ var errs []error
+
+ if !validPluginName.MatchString(m.Name) {
+ errs = append(errs, fmt.Errorf("invalid name"))
+ }
+
+ if m.APIVersion == "" {
+ errs = append(errs, fmt.Errorf("empty APIVersion"))
+ }
+
+ if m.Type == "" {
+ errs = append(errs, fmt.Errorf("empty type field"))
+ }
+
+ if m.Runtime == "" {
+ errs = append(errs, fmt.Errorf("empty runtime field"))
+ }
+
+ if m.Config == nil {
+ errs = append(errs, fmt.Errorf("missing config field"))
+ }
+
+ if m.RuntimeConfig == nil {
+ errs = append(errs, fmt.Errorf("missing runtimeConfig field"))
+ }
+
+ // Validate the config itself
+ if m.Config != nil {
+ if err := m.Config.Validate(); err != nil {
+ errs = append(errs, fmt.Errorf("config validation failed: %w", err))
+ }
+ }
+
+ // Validate the runtime config itself
+ if m.RuntimeConfig != nil {
+ if err := m.RuntimeConfig.Validate(); err != nil {
+ errs = append(errs, fmt.Errorf("runtime config validation failed: %w", err))
+ }
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+
+ return nil
+}
+
+func fromMetadataLegacy(m MetadataLegacy) *Metadata {
+ pluginType := "cli/v1"
+
+ if len(m.Downloaders) > 0 {
+ pluginType = "getter/v1"
+ }
+
+ return &Metadata{
+ APIVersion: "legacy",
+ Name: m.Name,
+ Version: m.Version,
+ Type: pluginType,
+ Runtime: "subprocess",
+ Config: buildLegacyConfig(m, pluginType),
+ RuntimeConfig: buildLegacyRuntimeConfig(m),
+ }
+}
+
+func buildLegacyConfig(m MetadataLegacy, pluginType string) Config {
+ switch pluginType {
+ case "getter/v1":
+ var protocols []string
+ for _, d := range m.Downloaders {
+ protocols = append(protocols, d.Protocols...)
+ }
+ return &ConfigGetter{
+ Protocols: protocols,
+ }
+ case "cli/v1":
+ return &ConfigCLI{
+ Usage: "", // Legacy plugins don't have Usage field for command syntax
+ ShortHelp: m.Usage, // Map legacy usage to shortHelp
+ LongHelp: m.Description, // Map legacy description to longHelp
+ IgnoreFlags: m.IgnoreFlags,
+ }
+ default:
+ return nil
+ }
+}
+
+func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig {
+ var protocolCommands []SubprocessProtocolCommand
+ if len(m.Downloaders) > 0 {
+ protocolCommands =
+ make([]SubprocessProtocolCommand, 0, len(m.Downloaders))
+ for _, d := range m.Downloaders {
+ protocolCommands = append(protocolCommands, SubprocessProtocolCommand(d))
+ }
+ }
+ return &RuntimeConfigSubprocess{
+ PlatformCommands: m.PlatformCommands,
+ Command: m.Command,
+ PlatformHooks: m.PlatformHooks,
+ Hooks: m.Hooks,
+ ProtocolCommands: protocolCommands,
+ }
+}
+
+func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) {
+
+ config, err := convertMetadataConfig(mv1.Type, mv1.Config)
+ if err != nil {
+ return nil, err
+ }
+
+ runtimeConfig, err := convertMetdataRuntimeConfig(mv1.Runtime, mv1.RuntimeConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Metadata{
+ APIVersion: mv1.APIVersion,
+ Name: mv1.Name,
+ Type: mv1.Type,
+ Runtime: mv1.Runtime,
+ Version: mv1.Version,
+ SourceURL: mv1.SourceURL,
+ Config: config,
+ RuntimeConfig: runtimeConfig,
+ }, nil
+}
+
+func convertMetadataConfig(pluginType string, configRaw map[string]any) (Config, error) {
+ var err error
+ var config Config
+
+ switch pluginType {
+ case "cli/v1":
+ config, err = remarshalConfig[*ConfigCLI](configRaw)
+ case "getter/v1":
+ config, err = remarshalConfig[*ConfigGetter](configRaw)
+ default:
+ return nil, fmt.Errorf("unsupported plugin type: %s", pluginType)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal config for %s plugin type: %w", pluginType, err)
+ }
+
+ return config, nil
+}
+
+func convertMetdataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string]any) (RuntimeConfig, error) {
+ var runtimeConfig RuntimeConfig
+ var err error
+
+ switch runtimeType {
+ case "subprocess":
+ runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigSubprocess](runtimeConfigRaw)
+ default:
+ return nil, fmt.Errorf("unsupported plugin runtime type: %q", runtimeType)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal runtimeConfig for %s runtime: %w", runtimeType, err)
+ }
+ return runtimeConfig, nil
+}
diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go
new file mode 100644
index 000000000..ce9c2f580
--- /dev/null
+++ b/internal/plugin/metadata_legacy.go
@@ -0,0 +1,113 @@
+/*
+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 (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+// Downloaders represents the plugins capability if it can retrieve
+// charts from special sources
+type Downloaders struct {
+ // Protocols are the list of schemes from the charts URL.
+ Protocols []string `yaml:"protocols"`
+ // Command is the executable path with which the plugin performs
+ // the actual download for the corresponding Protocols
+ Command string `yaml:"command"`
+}
+
+// MetadataLegacy is the legacy plugin.yaml format
+type MetadataLegacy struct {
+ // Name is the name of the plugin
+ Name string `yaml:"name"`
+
+ // Version is a SemVer 2 version of the plugin.
+ Version string `yaml:"version"`
+
+ // Usage is the single-line usage text shown in help
+ Usage string `yaml:"usage"`
+
+ // Description is a long description shown in places like `helm help`
+ Description string `yaml:"description"`
+
+ // PlatformCommands is the plugin command, with a platform selector and support for args.
+ PlatformCommands []PlatformCommand `yaml:"platformCommand"`
+
+ // Command is the plugin command, as a single string.
+ // DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins.
+ Command string `yaml:"command"`
+
+ // IgnoreFlags ignores any flags passed in from Helm
+ IgnoreFlags bool `yaml:"ignoreFlags"`
+
+ // PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
+ 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.
+ Hooks Hooks `yaml:"hooks"`
+
+ // Downloaders field is used if the plugin supply downloader mechanism
+ // for special protocols.
+ Downloaders []Downloaders `yaml:"downloaders"`
+}
+
+func (m *MetadataLegacy) Validate() error {
+ if !validPluginName.MatchString(m.Name) {
+ return fmt.Errorf("invalid plugin name")
+ }
+ m.Usage = sanitizeString(m.Usage)
+
+ if len(m.PlatformCommands) > 0 && len(m.Command) > 0 {
+ return fmt.Errorf("both platformCommand and command are set")
+ }
+
+ if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 {
+ return fmt.Errorf("both platformHooks and hooks are set")
+ }
+
+ // Validate downloader plugins
+ for i, downloader := range m.Downloaders {
+ if downloader.Command == "" {
+ return fmt.Errorf("downloader %d has empty command", i)
+ }
+ if len(downloader.Protocols) == 0 {
+ return fmt.Errorf("downloader %d has no protocols", i)
+ }
+ for j, protocol := range downloader.Protocols {
+ if protocol == "" {
+ return fmt.Errorf("downloader %d has empty protocol at index %d", i, j)
+ }
+ }
+ }
+
+ return nil
+}
+
+// sanitizeString normalize spaces and removes non-printable characters.
+func sanitizeString(str string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsSpace(r) {
+ return ' '
+ }
+ if unicode.IsPrint(r) {
+ return r
+ }
+ return -1
+ }, str)
+}
diff --git a/internal/plugin/metadata_test.go b/internal/plugin/metadata_test.go
new file mode 100644
index 000000000..810020a67
--- /dev/null
+++ b/internal/plugin/metadata_test.go
@@ -0,0 +1,141 @@
+/*
+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 (
+ "strings"
+ "testing"
+)
+
+func TestValidatePluginData(t *testing.T) {
+
+ // A mock plugin with no commands
+ mockNoCommand := mockSubprocessCLIPlugin(t, "foo")
+ mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{},
+ PlatformHooks: map[string][]PlatformCommand{},
+ }
+
+ // A mock plugin with legacy commands
+ mockLegacyCommand := mockSubprocessCLIPlugin(t, "foo")
+ mockLegacyCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{},
+ Command: "echo \"mock plugin\"",
+ PlatformHooks: map[string][]PlatformCommand{},
+ Hooks: map[string]string{
+ Install: "echo installing...",
+ },
+ }
+
+ // A mock plugin with a command also set
+ mockWithCommand := mockSubprocessCLIPlugin(t, "foo")
+ mockWithCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
+ },
+ Command: "echo \"mock plugin\"",
+ }
+
+ // A mock plugin with a hooks also set
+ mockWithHooks := mockSubprocessCLIPlugin(t, "foo")
+ mockWithHooks.metadata.RuntimeConfig = &RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
+ },
+ PlatformHooks: map[string][]PlatformCommand{
+ Install: {
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+ },
+ },
+ Hooks: map[string]string{
+ Install: "echo installing...",
+ },
+ }
+
+ for i, item := range []struct {
+ pass bool
+ plug Plugin
+ errString string
+ }{
+ {true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""},
+ {true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""},
+ {false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"},
+ {false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars
+ {false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars
+ {false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline
+ {true, mockNoCommand, ""}, // Test no command metadata works
+ {true, mockLegacyCommand, ""}, // Test legacy command metadata works
+ {false, mockWithCommand, "runtime config validation failed: both platformCommand and command are set"}, // Test platformCommand and command both set fails
+ {false, mockWithHooks, "runtime config validation failed: both platformHooks and hooks are set"}, // Test platformHooks and hooks both set fails
+ } {
+ err := item.plug.Metadata().Validate()
+ if item.pass && err != nil {
+ t.Errorf("failed to validate case %d: %s", i, err)
+ } else if !item.pass && err == nil {
+ t.Errorf("expected case %d to fail", i)
+ }
+ if !item.pass && err.Error() != item.errString {
+ t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error())
+ }
+ }
+}
+
+func TestMetadataValidateMultipleErrors(t *testing.T) {
+ // Create metadata with multiple validation issues
+ metadata := Metadata{
+ Name: "invalid name with spaces", // Invalid name
+ APIVersion: "", // Empty API version
+ Type: "", // Empty type
+ Runtime: "", // Empty runtime
+ Config: nil, // Missing config
+ RuntimeConfig: nil, // Missing runtime config
+ }
+
+ err := metadata.Validate()
+ if err == nil {
+ t.Fatal("expected validation to fail with multiple errors")
+ }
+
+ errStr := err.Error()
+
+ // Check that all expected errors are present in the joined error
+ expectedErrors := []string{
+ "invalid name",
+ "empty APIVersion",
+ "empty type field",
+ "empty runtime field",
+ "missing config field",
+ "missing runtimeConfig field",
+ }
+
+ for _, expectedErr := range expectedErrors {
+ if !strings.Contains(errStr, expectedErr) {
+ t.Errorf("expected error to contain %q, but got: %v", expectedErr, errStr)
+ }
+ }
+
+ // Verify that the error contains the correct number of error messages
+ errorCount := 0
+ for _, expectedErr := range expectedErrors {
+ if strings.Contains(errStr, expectedErr) {
+ errorCount++
+ }
+ }
+
+ if errorCount < len(expectedErrors) {
+ t.Errorf("expected %d errors, but only found %d in: %v", len(expectedErrors), errorCount, errStr)
+ }
+}
diff --git a/internal/plugin/metadata_v1.go b/internal/plugin/metadata_v1.go
new file mode 100644
index 000000000..654aa8900
--- /dev/null
+++ b/internal/plugin/metadata_v1.go
@@ -0,0 +1,67 @@
+/*
+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 (
+ "fmt"
+)
+
+// MetadataV1 is the APIVersion V1 plugin.yaml format
+type MetadataV1 struct {
+ // APIVersion specifies the plugin API version
+ APIVersion string `yaml:"apiVersion"`
+
+ // Name is the name of the plugin
+ Name string `yaml:"name"`
+
+ // Type of plugin (eg, cli/v1, getter/v1)
+ Type string `yaml:"type"`
+
+ // Runtime specifies the runtime type (subprocess, wasm)
+ Runtime string `yaml:"runtime"`
+
+ // Version is a SemVer 2 version of the plugin.
+ Version string `yaml:"version"`
+
+ // SourceURL is the URL where this plugin can be found
+ SourceURL string `yaml:"sourceURL,omitempty"`
+
+ // Config contains the type-specific configuration for this plugin
+ Config map[string]any `yaml:"config"`
+
+ // RuntimeConfig contains the runtime-specific configuration
+ RuntimeConfig map[string]any `yaml:"runtimeConfig"`
+}
+
+func (m *MetadataV1) Validate() error {
+ if !validPluginName.MatchString(m.Name) {
+ return fmt.Errorf("invalid plugin `name`")
+ }
+
+ if m.APIVersion != "v1" {
+ return fmt.Errorf("invalid `apiVersion`: %q", m.APIVersion)
+ }
+
+ if m.Type == "" {
+ return fmt.Errorf("`type` missing")
+ }
+
+ if m.Runtime == "" {
+ return fmt.Errorf("`runtime` missing")
+ }
+
+ return nil
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
new file mode 100644
index 000000000..132b1739e
--- /dev/null
+++ b/internal/plugin/plugin.go
@@ -0,0 +1,81 @@
+/*
+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 "helm.sh/helm/v4/internal/plugin"
+
+import (
+ "context"
+ "io"
+ "regexp"
+)
+
+const PluginFileName = "plugin.yaml"
+
+// Plugin defines a plugin instance. The client (Helm codebase) facing type that can be used to introspect and invoke a plugin
+type Plugin interface {
+ // Dir return the plugin directory (as an absolute path) on the filesystem
+ Dir() string
+
+ // Metadata describes the plugin's type, version, etc.
+ // (This metadata type is the converted and plugin version independented in-memory representation of the plugin.yaml file)
+ Metadata() Metadata
+
+ // Invoke takes the given input, and dispatches the contents to plugin instance
+ // The input is expected to be a JSON-serializable object, which the plugin will interpret according to its type
+ // The plugin is expected to return a JSON-serializable object, which the invoker
+ // will interpret according to the plugin's type
+ //
+ // Invoke can be thought of as a request/response mechanism. Similar to e.g. http.RoundTripper
+ //
+ // If plugin's execution fails with a non-zero "return code" (this is plugin runtime implementation specific)
+ // an InvokeExecError is returned
+ Invoke(ctx context.Context, input *Input) (*Output, error)
+}
+
+// PluginHook allows plugins to implement hooks that are invoked on plugin management events (install, upgrade, etc)
+type PluginHook interface { //nolint:revive
+ InvokeHook(event string) error
+}
+
+// Input defines the input message and parameters to be passed to the plugin
+type Input struct {
+ // Message represents the type-elided value to be passed to the plugin.
+ // The plugin is expected to interpret the message according to its type
+ // The message object must be JSON-serializable
+ Message any
+
+ // Optional: Reader to be consumed plugin's "stdin"
+ Stdin io.Reader
+
+ // Optional: Writers to consume the plugin's "stdout" and "stderr"
+ Stdout, Stderr io.Writer
+
+ // Optional: Env represents the environment as a list of "key=value" strings
+ // see os.Environ
+ Env []string
+}
+
+// Output defines the output message and parameters the passed from the plugin
+type Output struct {
+ // Message represents the type-elided value returned from the plugin
+ // The invoker is expected to interpret the message according to the plugin's type
+ // The message object must be JSON-serializable
+ Message any
+}
+
+// validPluginName is a regular expression that validates plugin names.
+//
+// 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
new file mode 100644
index 000000000..fbebecac4
--- /dev/null
+++ b/internal/plugin/plugin_test.go
@@ -0,0 +1,58 @@
+/*
+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"
+)
+
+func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime {
+ t.Helper()
+
+ rc := RuntimeConfigSubprocess{
+ PlatformCommands: []PlatformCommand{
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}},
+ {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}},
+ },
+ PlatformHooks: map[string][]PlatformCommand{
+ Install: {
+ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}},
+ {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}},
+ },
+ },
+ }
+
+ pluginDir := t.TempDir()
+
+ return &SubprocessPluginRuntime{
+ metadata: Metadata{
+ Name: pluginName,
+ Version: "v0.1.2",
+ Type: "cli/v1",
+ APIVersion: "v1",
+ Runtime: "subprocess",
+ Config: &ConfigCLI{
+ Usage: "Mock plugin",
+ ShortHelp: "Mock plugin",
+ LongHelp: "Mock plugin for testing",
+ IgnoreFlags: false,
+ },
+ RuntimeConfig: &rc,
+ },
+ pluginDir: pluginDir, // NOTE: dir is empty (ie. plugin.yaml is not present)
+ RuntimeConfig: rc,
+ }
+}
diff --git a/internal/plugin/runtime.go b/internal/plugin/runtime.go
new file mode 100644
index 000000000..8add92dea
--- /dev/null
+++ b/internal/plugin/runtime.go
@@ -0,0 +1,49 @@
+/*
+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 "go.yaml.in/yaml/v3"
+
+// Runtime represents a plugin runtime (subprocess, extism, etc) ie. how a plugin should be executed
+// Runtime is responsible for instantiating plugins that implement the runtime
+// TODO: could call this something more like "PluginRuntimeCreator"?
+type Runtime interface {
+ // CreatePlugin creates a plugin instance from the given metadata
+ CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error)
+
+ // TODO: move config unmarshalling to the runtime?
+ // UnmarshalConfig(runtimeConfigRaw map[string]any) (RuntimeConfig, error)
+}
+
+// RuntimeConfig represents the assertable type for a plugin's runtime configuration.
+// It is expected to type assert (cast) the a RuntimeConfig to its expected type
+type RuntimeConfig interface {
+ Validate() error
+}
+
+func remarshalRuntimeConfig[T RuntimeConfig](runtimeData map[string]any) (RuntimeConfig, error) {
+ data, err := yaml.Marshal(runtimeData)
+ if err != nil {
+ return nil, err
+ }
+
+ var config T
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ return nil, err
+ }
+
+ return config, nil
+}
diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go
new file mode 100644
index 000000000..286c1abeb
--- /dev/null
+++ b/internal/plugin/runtime_subprocess.go
@@ -0,0 +1,229 @@
+/*
+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 (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "syscall"
+
+ "helm.sh/helm/v4/internal/plugin/schema"
+ "helm.sh/helm/v4/pkg/cli"
+)
+
+// SubprocessProtocolCommand maps a given protocol to the getter command used to retrieve artifacts for that protcol
+type SubprocessProtocolCommand struct {
+ // Protocols are the list of schemes from the charts URL.
+ Protocols []string `yaml:"protocols"`
+ // Command is the executable path with which the plugin performs
+ // the actual download for the corresponding Protocols
+ Command string `yaml:"command"`
+}
+
+// RuntimeConfigSubprocess represents configuration for subprocess runtime
+type RuntimeConfigSubprocess struct {
+ // PlatformCommand is a list containing a plugin command, with a platform selector and support for args.
+ PlatformCommands []PlatformCommand `yaml:"platformCommand"`
+ // Command is the plugin command, as a single string.
+ // DEPRECATED: Use PlatformCommand instead. Remove in Helm 4.
+ Command string `yaml:"command"`
+ // PlatformHooks are commands that will run on plugin events, with a platform selector and support for args.
+ PlatformHooks PlatformHooks `yaml:"platformHooks"`
+ // Hooks are commands that will run on plugin events, as a single string.
+ // DEPRECATED: Use PlatformHooks instead. Remove in Helm 4.
+ Hooks Hooks `yaml:"hooks"`
+ // ProtocolCommands field is used if the plugin supply downloader mechanism
+ // for special protocols.
+ // (This is a compatibility hangover from the old plugin downloader mechanism, which was extended to support multiple
+ // protocols in a given plugin)
+ ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"`
+}
+
+var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil)
+
+func (r *RuntimeConfigSubprocess) GetType() string { return "subprocess" }
+
+func (r *RuntimeConfigSubprocess) Validate() error {
+ if len(r.PlatformCommands) > 0 && len(r.Command) > 0 {
+ return fmt.Errorf("both platformCommand and command are set")
+ }
+ if len(r.PlatformHooks) > 0 && len(r.Hooks) > 0 {
+ return fmt.Errorf("both platformHooks and hooks are set")
+ }
+ return nil
+}
+
+type RuntimeSubprocess struct{}
+
+var _ Runtime = (*RuntimeSubprocess)(nil)
+
+// CreateRuntime implementation for RuntimeConfig
+func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
+ return &SubprocessPluginRuntime{
+ metadata: *metadata,
+ pluginDir: pluginDir,
+ RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
+ }, nil
+}
+
+// RuntimeSubprocess implements the Runtime interface for subprocess execution
+type SubprocessPluginRuntime struct {
+ metadata Metadata
+ pluginDir string
+ RuntimeConfig RuntimeConfigSubprocess
+}
+
+var _ Plugin = (*SubprocessPluginRuntime)(nil)
+
+func (r *SubprocessPluginRuntime) Dir() string {
+ return r.pluginDir
+}
+
+func (r *SubprocessPluginRuntime) Metadata() Metadata {
+ return r.metadata
+}
+
+func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Output, error) {
+ switch input.Message.(type) {
+ case schema.InputMessageCLIV1:
+ return r.runCLI(input)
+ case schema.InputMessageGetterV1:
+ return r.runGetter(input)
+ default:
+ return nil, fmt.Errorf("unsupported subprocess plugin type %q", r.metadata.Type)
+ }
+}
+
+// InvokeWithEnv executes a plugin command with custom environment and I/O streams
+// 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)
+ prog := exec.Command(mainCmdExp, argv...)
+ prog.Env = env
+ prog.Stdin = stdin
+ prog.Stdout = stdout
+ prog.Stderr = stderr
+
+ if err := prog.Run(); err != nil {
+ if eerr, ok := err.(*exec.ExitError); ok {
+ os.Stderr.Write(eerr.Stderr)
+ status := eerr.Sys().(syscall.WaitStatus)
+ return &InvokeExecError{
+ Err: fmt.Errorf("plugin %q exited with error", r.metadata.Name),
+ Code: status.ExitStatus(),
+ }
+ }
+ }
+ return nil
+}
+
+func (r *SubprocessPluginRuntime) InvokeHook(event string) error {
+ // Get hook commands for the event
+ var cmds []PlatformCommand
+ expandArgs := true
+
+ cmds = r.RuntimeConfig.PlatformHooks[event]
+ if len(cmds) == 0 && len(r.RuntimeConfig.Hooks) > 0 {
+ cmd := r.RuntimeConfig.Hooks[event]
+ if len(cmd) > 0 {
+ cmds = []PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}}
+ expandArgs = false
+ }
+ }
+
+ // If no hook commands are defined, just return successfully
+ if len(cmds) == 0 {
+ return nil
+ }
+
+ main, argv, err := PrepareCommands(cmds, expandArgs, []string{})
+ if err != nil {
+ return err
+ }
+
+ prog := exec.Command(main, argv...)
+ prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
+
+ if err := prog.Run(); err != nil {
+ if eerr, ok := err.(*exec.ExitError); ok {
+ os.Stderr.Write(eerr.Stderr)
+ return fmt.Errorf("plugin %s hook for %q exited with error", event, r.metadata.Name)
+ }
+ return err
+ }
+ return nil
+}
+
+// TODO decide the best way to handle this code
+// right now we implement status and error return in 3 slightly different ways in this file
+// then replace the other three with a call to this func
+func executeCmd(prog *exec.Cmd, pluginName string) error {
+ if err := prog.Run(); err != nil {
+ if eerr, ok := err.(*exec.ExitError); ok {
+ os.Stderr.Write(eerr.Stderr)
+ return &InvokeExecError{
+ Err: fmt.Errorf("plugin %q exited with error", pluginName),
+ Code: eerr.ExitCode(),
+ }
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (r *SubprocessPluginRuntime) runCLI(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)
+ }
+
+ extraArgs := input.Message.(schema.InputMessageCLIV1).ExtraArgs
+
+ cmds := r.RuntimeConfig.PlatformCommands
+ if len(cmds) == 0 && len(r.RuntimeConfig.Command) > 0 {
+ cmds = []PlatformCommand{{Command: r.RuntimeConfig.Command}}
+ }
+
+ command, args, err := PrepareCommands(cmds, true, extraArgs)
+ if err != nil {
+ return nil, fmt.Errorf("failed to prepare plugin command: %w", err)
+ }
+
+ err2 := r.InvokeWithEnv(command, args, input.Env, input.Stdin, input.Stdout, input.Stderr)
+ if err2 != nil {
+ return nil, err2
+ }
+
+ return &Output{
+ Message: &schema.OutputMessageCLIV1{},
+ }, nil
+}
+
+// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
+// the plugin subsystem itself needs access to the environment variables
+// created here.
+func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { // TODO: remove
+ env := settings.EnvVars()
+ env["HELM_PLUGIN_NAME"] = name
+ env["HELM_PLUGIN_DIR"] = base
+ for key, val := range env {
+ os.Setenv(key, val)
+ }
+}
diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go
new file mode 100644
index 000000000..6f9bfea91
--- /dev/null
+++ b/internal/plugin/runtime_subprocess_getter.go
@@ -0,0 +1,92 @@
+/*
+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 (
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "helm.sh/helm/v4/internal/plugin/schema"
+)
+
+func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) *SubprocessProtocolCommand {
+ for _, c := range commands {
+ if slices.Contains(c.Protocols, protocol) {
+ return &c
+ }
+ }
+
+ return nil
+}
+
+// TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv?
+func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) {
+ msg, ok := (input.Message).(schema.InputMessageGetterV1)
+ if !ok {
+ return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input)
+ }
+
+ tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", r.metadata.Name))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create temporary directory: %w", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ d := getProtocolCommand(r.RuntimeConfig.ProtocolCommands, msg.Protocol)
+ if d == nil {
+ return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol)
+ }
+
+ commands := strings.Split(d.Command, " ")
+ args := append(
+ commands[1:],
+ msg.Options.CertFile,
+ msg.Options.KeyFile,
+ msg.Options.CAFile,
+ msg.Href)
+
+ // TODO should we append to input.Env too?
+ env := append(
+ os.Environ(),
+ fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username),
+ fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password),
+ fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll))
+
+ // TODO should we pass along input.Stdout?
+ buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout
+
+ pluginCommand := filepath.Join(r.pluginDir, commands[0])
+ prog := exec.Command(
+ pluginCommand,
+ args...)
+ prog.Env = env
+ prog.Stdout = &buf
+ prog.Stderr = os.Stderr
+ if err := executeCmd(prog, r.metadata.Name); err != nil {
+ return nil, err
+ }
+
+ return &Output{
+ Message: &schema.OutputMessageGetterV1{
+ Data: buf.Bytes(),
+ },
+ }, nil
+}
diff --git a/pkg/plugin/hooks.go b/internal/plugin/runtime_subprocess_hooks.go
similarity index 80%
rename from pkg/plugin/hooks.go
rename to internal/plugin/runtime_subprocess_hooks.go
index e3481515f..7b4ff5a38 100644
--- a/pkg/plugin/hooks.go
+++ b/internal/plugin/runtime_subprocess_hooks.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package plugin // import "helm.sh/helm/v3/pkg/plugin"
+package plugin // import "helm.sh/helm/v4/internal/plugin"
// Types of hooks
const (
@@ -25,5 +25,8 @@ const (
Update = "update"
)
+// PlatformHooks is a map of events to a command for a particular operating system and architecture.
+type PlatformHooks map[string][]PlatformCommand
+
// Hooks is a map of events to commands.
type Hooks map[string]string
diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go
new file mode 100644
index 000000000..9d932816d
--- /dev/null
+++ b/internal/plugin/runtime_subprocess_test.go
@@ -0,0 +1,64 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package plugin
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "helm.sh/helm/v4/pkg/cli"
+)
+
+func TestSetupEnv(t *testing.T) {
+ name := "pequod"
+ base := filepath.Join("testdata/helmhome/helm/plugins", name)
+
+ s := cli.New()
+ s.PluginsDirectory = "testdata/helmhome/helm/plugins"
+
+ SetupPluginEnv(s, name, base)
+ for _, tt := range []struct {
+ name, expect string
+ }{
+ {"HELM_PLUGIN_NAME", name},
+ {"HELM_PLUGIN_DIR", base},
+ } {
+ if got := os.Getenv(tt.name); got != tt.expect {
+ t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
+ }
+ }
+}
+
+func TestSetupEnvWithSpace(t *testing.T) {
+ name := "sureshdsk"
+ base := filepath.Join("testdata/helm home/helm/plugins", name)
+
+ s := cli.New()
+ s.PluginsDirectory = "testdata/helm home/helm/plugins"
+
+ SetupPluginEnv(s, name, base)
+ for _, tt := range []struct {
+ name, expect string
+ }{
+ {"HELM_PLUGIN_NAME", name},
+ {"HELM_PLUGIN_DIR", base},
+ } {
+ if got := os.Getenv(tt.name); got != tt.expect {
+ t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
+ }
+ }
+}
diff --git a/internal/plugin/schema/cli.go b/internal/plugin/schema/cli.go
new file mode 100644
index 000000000..3976d3737
--- /dev/null
+++ b/internal/plugin/schema/cli.go
@@ -0,0 +1,29 @@
+/*
+ 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 schema
+
+import (
+ "bytes"
+
+ "helm.sh/helm/v4/pkg/cli"
+)
+
+type InputMessageCLIV1 struct {
+ ExtraArgs []string `json:"extraArgs"`
+ Settings *cli.EnvSettings `json:"settings"`
+}
+
+type OutputMessageCLIV1 struct {
+ Data *bytes.Buffer `json:"data"`
+}
diff --git a/internal/plugin/schema/getter.go b/internal/plugin/schema/getter.go
new file mode 100644
index 000000000..f9840008e
--- /dev/null
+++ b/internal/plugin/schema/getter.go
@@ -0,0 +1,47 @@
+/*
+ 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 schema
+
+import (
+ "time"
+)
+
+// TODO: can we generate these plugin input/outputs?
+
+type GetterOptionsV1 struct {
+ URL string
+ CertFile string
+ KeyFile string
+ CAFile string
+ UNTar bool
+ InsecureSkipVerifyTLS bool
+ PlainHTTP bool
+ AcceptHeader string
+ Username string
+ Password string
+ PassCredentialsAll bool
+ UserAgent string
+ Version string
+ Timeout time.Duration
+}
+
+type InputMessageGetterV1 struct {
+ Href string `json:"href"`
+ Protocol string `json:"protocol"`
+ Options GetterOptionsV1 `json:"options"`
+}
+
+type OutputMessageGetterV1 struct {
+ Data []byte `json:"data"`
+}
diff --git a/internal/plugin/subprocess_commands.go b/internal/plugin/subprocess_commands.go
new file mode 100644
index 000000000..d979f98e3
--- /dev/null
+++ b/internal/plugin/subprocess_commands.go
@@ -0,0 +1,111 @@
+/*
+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 (
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+)
+
+// PlatformCommand represents a command for a particular operating system and architecture
+type PlatformCommand struct {
+ OperatingSystem string `yaml:"os"`
+ Architecture string `yaml:"arch"`
+ Command string `yaml:"command"`
+ Args []string `yaml:"args"`
+}
+
+// Returns command and args strings based on the following rules in priority order:
+// - From the PlatformCommand where OS and Arch match the current platform
+// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified
+// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform
+// - From the PlatformCommand where OS and Arch are both empty/unspecified
+// - Return nil, nil
+func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) {
+ var command, args []string
+ found := false
+ foundOs := false
+
+ eq := strings.EqualFold
+ for _, c := range cmds {
+ if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
+ // Return early for an exact match
+ return strings.Split(c.Command, " "), c.Args
+ }
+
+ if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 {
+ // Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match
+ continue
+ }
+
+ if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) {
+ // First OS match with empty arch, can only be overridden by a direct match
+ command = strings.Split(c.Command, " ")
+ args = c.Args
+ found = true
+ foundOs = true
+ } else if !found {
+ // First empty match, can be overridden by a direct match or an OS match
+ command = strings.Split(c.Command, " ")
+ args = c.Args
+ found = true
+ }
+ }
+
+ return command, args
+}
+
+// PrepareCommands takes a []Plugin.PlatformCommand
+// and prepares the command and arguments for execution.
+//
+// It merges extraArgs into any arguments supplied in the plugin. It
+// returns the main command and an args array.
+//
+// The result is suitable to pass to exec.Command.
+func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string) (string, []string, error) {
+ cmdParts, args := getPlatformCommand(cmds)
+ if len(cmdParts) == 0 || cmdParts[0] == "" {
+ return "", nil, fmt.Errorf("no plugin command is applicable")
+ }
+
+ main := os.ExpandEnv(cmdParts[0])
+ baseArgs := []string{}
+ if len(cmdParts) > 1 {
+ for _, cmdPart := range cmdParts[1:] {
+ if expandArgs {
+ baseArgs = append(baseArgs, os.ExpandEnv(cmdPart))
+ } else {
+ baseArgs = append(baseArgs, cmdPart)
+ }
+ }
+ }
+
+ for _, arg := range args {
+ if expandArgs {
+ baseArgs = append(baseArgs, os.ExpandEnv(arg))
+ } else {
+ baseArgs = append(baseArgs, arg)
+ }
+ }
+
+ if len(extraArgs) > 0 {
+ baseArgs = append(baseArgs, extraArgs...)
+ }
+
+ return main, baseArgs, nil
+}
diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go
new file mode 100644
index 000000000..3cb9325ab
--- /dev/null
+++ b/internal/plugin/subprocess_commands_test.go
@@ -0,0 +1,257 @@
+/*
+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 (
+ "reflect"
+ "runtime"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPrepareCommand(t *testing.T) {
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+
+ platformCommands := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+ }
+
+ cmd, args, err := PrepareCommands(platformCommands, true, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, cmdArgs) {
+ t.Fatalf("Expected %v, got %v", cmdArgs, args)
+ }
+}
+
+func TestPrepareCommandExtraArgs(t *testing.T) {
+
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+ platformCommands := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ }
+
+ extraArgs := []string{"--debug", "--foo", "bar"}
+
+ type testCaseExpected struct {
+ cmdMain string
+ args []string
+ }
+
+ testCases := map[string]struct {
+ ignoreFlags bool
+ expected testCaseExpected
+ }{
+ "ignoreFlags false": {
+ ignoreFlags: false,
+ expected: testCaseExpected{
+ cmdMain: cmdMain,
+ args: []string{"-c", "echo \"test\"", "--debug", "--foo", "bar"},
+ },
+ },
+ "ignoreFlags true": {
+ ignoreFlags: true,
+ expected: testCaseExpected{
+ cmdMain: cmdMain,
+ args: []string{"-c", "echo \"test\""},
+ },
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ // extra args are expected when ignoreFlags is unset or false
+ testExtraArgs := extraArgs
+ if tc.ignoreFlags {
+ testExtraArgs = []string{}
+ }
+ cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assert.Equal(t, tc.expected.cmdMain, cmd, "Expected command to match")
+ assert.Equal(t, tc.expected.args, args, "Expected args to match")
+ })
+ }
+}
+
+func TestPrepareCommands(t *testing.T) {
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+
+ cmds := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ }
+
+ cmd, args, err := PrepareCommands(cmds, true, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, cmdArgs) {
+ t.Fatalf("Expected %v, got %v", cmdArgs, args)
+ }
+}
+
+func TestPrepareCommandsExtraArgs(t *testing.T) {
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+ extraArgs := []string{"--debug", "--foo", "bar"}
+
+ cmds := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ }
+
+ expectedArgs := append(cmdArgs, extraArgs...)
+
+ cmd, args, err := PrepareCommands(cmds, true, extraArgs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Fatalf("Expected %v, got %v", expectedArgs, args)
+ }
+}
+
+func TestPrepareCommandsNoArch(t *testing.T) {
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+
+ cmds := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ }
+
+ cmd, args, err := PrepareCommands(cmds, true, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, cmdArgs) {
+ t.Fatalf("Expected %v, got %v", cmdArgs, args)
+ }
+}
+
+func TestPrepareCommandsNoOsNoArch(t *testing.T) {
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"test\""}
+
+ cmds := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ {OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}},
+ }
+
+ cmd, args, err := PrepareCommands(cmds, true, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, cmdArgs) {
+ t.Fatalf("Expected %v, got %v", cmdArgs, args)
+ }
+}
+
+func TestPrepareCommandsNoMatch(t *testing.T) {
+ cmds := []PlatformCommand{
+ {OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ {OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}},
+ }
+
+ if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
+ t.Fatalf("Expected error to be returned")
+ }
+}
+
+func TestPrepareCommandsNoCommands(t *testing.T) {
+ cmds := []PlatformCommand{}
+
+ if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil {
+ t.Fatalf("Expected error to be returned")
+ }
+}
+
+func TestPrepareCommandsExpand(t *testing.T) {
+ t.Setenv("TEST", "test")
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"${TEST}\""}
+ cmds := []PlatformCommand{
+ {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+ }
+
+ expectedArgs := []string{"-c", "echo \"test\""}
+
+ cmd, args, err := PrepareCommands(cmds, true, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, expectedArgs) {
+ t.Fatalf("Expected %v, got %v", expectedArgs, args)
+ }
+}
+
+func TestPrepareCommandsNoExpand(t *testing.T) {
+ t.Setenv("TEST", "test")
+ cmdMain := "sh"
+ cmdArgs := []string{"-c", "echo \"${TEST}\""}
+ cmds := []PlatformCommand{
+ {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs},
+ }
+
+ cmd, args, err := PrepareCommands(cmds, false, []string{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmd != cmdMain {
+ t.Fatalf("Expected %q, got %q", cmdMain, cmd)
+ }
+ if !reflect.DeepEqual(args, cmdArgs) {
+ t.Fatalf("Expected %v, got %v", cmdArgs, args)
+ }
+}
diff --git a/pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml b/internal/plugin/testdata/plugdir/bad/duplicate-entries-legacy/plugin.yaml
similarity index 100%
rename from pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml
rename to internal/plugin/testdata/plugdir/bad/duplicate-entries-legacy/plugin.yaml
diff --git a/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml
new file mode 100644
index 000000000..030ae6aca
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml
@@ -0,0 +1,16 @@
+name: "duplicate-entries"
+version: "0.1.0"
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "test duplicate entries"
+ longHelp: |-
+ description
+ ignoreFlags: true
+runtimeConfig:
+ command: "echo hello"
+ hooks:
+ install: "echo installing..."
+ hooks:
+ install: "echo installing something different"
diff --git a/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml b/internal/plugin/testdata/plugdir/good/downloader/plugin.yaml
similarity index 98%
rename from pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml
rename to internal/plugin/testdata/plugdir/good/downloader/plugin.yaml
index c0b90379b..4e85f1f79 100644
--- a/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml
+++ b/internal/plugin/testdata/plugdir/good/downloader/plugin.yaml
@@ -1,3 +1,4 @@
+---
name: "downloader"
version: "1.2.3"
usage: "usage"
diff --git a/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml b/internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml
similarity index 85%
rename from pkg/plugin/testdata/plugdir/good/echo/plugin.yaml
rename to internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml
index 8baa35b6d..ef84a4d8f 100644
--- a/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml
+++ b/internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml
@@ -1,4 +1,5 @@
-name: "echo"
+---
+name: "echo-legacy"
version: "1.2.3"
usage: "echo something"
description: |-
diff --git a/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml
new file mode 100644
index 000000000..8bbef9c0f
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml
@@ -0,0 +1,15 @@
+---
+name: "echo-v1"
+version: "1.2.3"
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "echo something"
+ longHelp: |-
+ This is a testing fixture.
+ ignoreFlags: false
+runtimeConfig:
+ command: "echo Hello"
+ hooks:
+ install: "echo Installing"
diff --git a/internal/plugin/testdata/plugdir/good/getter/plugin.yaml b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml
new file mode 100644
index 000000000..cfe80fbdc
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml
@@ -0,0 +1,16 @@
+---
+name: "getter"
+version: "1.2.3"
+type: getter/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ protocols:
+ - "myprotocol"
+ - "myprotocols"
+runtimeConfig:
+ protocolCommands:
+ - command: "echo getter"
+ protocols:
+ - "myprotocol"
+ - "myprotocols"
diff --git a/internal/plugin/testdata/plugdir/good/hello-legacy/hello.ps1 b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.ps1
new file mode 100644
index 000000000..bee61f27d
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.ps1
@@ -0,0 +1,3 @@
+#!/usr/bin/env pwsh
+
+Write-Host "Hello, world!"
diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.sh b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh
similarity index 100%
rename from pkg/plugin/testdata/plugdir/good/hello/hello.sh
rename to internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh
diff --git a/internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml b/internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml
new file mode 100644
index 000000000..bf37e0626
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml
@@ -0,0 +1,22 @@
+---
+name: "hello-legacy"
+version: "0.1.0"
+usage: "echo hello message"
+description: |-
+ description
+platformCommand:
+ - os: linux
+ command: "sh"
+ args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
+ - os: windows
+ command: "pwsh"
+ args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
+ignoreFlags: true
+platformHooks:
+ install:
+ - os: linux
+ command: "sh"
+ args: ["-c", 'echo "installing..."']
+ - os: windows
+ command: "pwsh"
+ args: ["-c", 'echo "installing..."']
diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1 b/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1
new file mode 100644
index 000000000..bee61f27d
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1
@@ -0,0 +1,3 @@
+#!/usr/bin/env pwsh
+
+Write-Host "Hello, world!"
diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh
new file mode 100755
index 000000000..dcfd58876
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+echo "Hello from a Helm plugin"
+
+echo "PARAMS"
+echo $*
+
+$HELM_BIN ls --all
+
diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml
new file mode 100644
index 000000000..044a3476d
--- /dev/null
+++ b/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml
@@ -0,0 +1,32 @@
+---
+name: "hello-v1"
+version: "0.1.0"
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ usage: hello [params]...
+ shortHelp: "echo hello message"
+ longHelp: |-
+ description
+ ignoreFlags: true
+runtimeConfig:
+ platformCommand:
+ - os: linux
+ arch:
+ command: "sh"
+ args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"]
+ - os: windows
+ arch:
+ command: "pwsh"
+ args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"]
+ platformHooks:
+ install:
+ - os: linux
+ arch: ""
+ command: "sh"
+ args: ["-c", 'echo "installing..."']
+ - os: windows
+ arch: ""
+ command: "pwsh"
+ args: ["-c", 'echo "installing..."']
diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go
index 5e8921f96..13dcd2ce9 100644
--- a/internal/resolver/resolver.go
+++ b/internal/resolver/resolver.go
@@ -18,21 +18,22 @@ package resolver
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/Masterminds/semver/v3"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/provenance"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/provenance"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
)
// Resolver resolves dependencies from semantic version ranges to a particular version.
@@ -60,7 +61,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
for i, d := range reqs {
constraint, err := semver.NewConstraint(d.Version)
if err != nil {
- return nil, errors.Wrapf(err, "dependency %q has an invalid version/constraint format", d.Name)
+ return nil, fmt.Errorf("dependency %q has an invalid version/constraint format: %w", d.Name, err)
}
if d.Repository == "" {
@@ -77,7 +78,6 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
continue
}
if strings.HasPrefix(d.Repository, "file://") {
-
chartpath, err := GetLocalPath(d.Repository, r.chartpath)
if err != nil {
return nil, err
@@ -95,7 +95,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
}
if !constraint.Check(v) {
- missing = append(missing, d.Name)
+ missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version))
continue
}
@@ -125,12 +125,12 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
if !registry.IsOCI(d.Repository) {
repoIndex, err := repo.LoadIndexFile(filepath.Join(r.cachepath, helmpath.CacheIndexFile(repoName)))
if err != nil {
- return nil, errors.Wrapf(err, "no cached repository for %s found. (try 'helm repo update')", repoName)
+ return nil, fmt.Errorf("no cached repository for %s found. (try 'helm repo update'): %w", repoName, err)
}
vs, ok = repoIndex.Entries[d.Name]
if !ok {
- return nil, errors.Errorf("%s chart not found in repo %s", d.Name, d.Repository)
+ return nil, fmt.Errorf("%s chart not found in repo %s", d.Name, d.Repository)
}
found = false
} else {
@@ -152,7 +152,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
ref := fmt.Sprintf("%s/%s", strings.TrimPrefix(d.Repository, fmt.Sprintf("%s://", registry.OCIScheme)), d.Name)
tags, err := r.registryClient.Tags(ref)
if err != nil {
- return nil, errors.Wrapf(err, "could not retrieve list of tags for repository %s", d.Repository)
+ return nil, fmt.Errorf("could not retrieve list of tags for repository %s: %w", d.Repository, err)
}
vs = make(repo.ChartVersions, len(tags))
@@ -173,7 +173,7 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
Repository: d.Repository,
Version: version,
}
- // The version are already sorted and hence the first one to satisfy the constraint is used
+ // The versions are already sorted and hence the first one to satisfy the constraint is used
for _, ver := range vs {
v, err := semver.NewVersion(ver.Version)
// OCI does not need URLs
@@ -189,11 +189,11 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string
}
if !found {
- missing = append(missing, d.Name)
+ missing = append(missing, fmt.Sprintf("%q (repository %q, version %q)", d.Name, d.Repository, d.Version))
}
}
if len(missing) > 0 {
- return nil, errors.Errorf("can't get a valid version for repositories %s. Try changing the version constraint in Chart.yaml", strings.Join(missing, ", "))
+ return nil, fmt.Errorf("can't get a valid version for %d subchart(s): %s. Make sure a matching chart version exists in the repo, or change the version constraint in Chart.yaml", len(missing), strings.Join(missing, ", "))
}
digest, err := HashReq(reqs, locked)
@@ -253,8 +253,8 @@ func GetLocalPath(repo, chartpath string) (string, error) {
depPath = filepath.Join(chartpath, p)
}
- if _, err = os.Stat(depPath); os.IsNotExist(err) {
- return "", errors.Errorf("directory %s not found", depPath)
+ if _, err = os.Stat(depPath); errors.Is(err, fs.ErrNotExist) {
+ return "", fmt.Errorf("directory %s not found", depPath)
} else if err != nil {
return "", err
}
diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go
index a79852175..1e33837a9 100644
--- a/internal/resolver/resolver_test.go
+++ b/internal/resolver/resolver_test.go
@@ -19,8 +19,8 @@ import (
"runtime"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/registry"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/registry"
)
func TestResolve(t *testing.T) {
diff --git a/internal/statusreaders/job_status_reader.go b/internal/statusreaders/job_status_reader.go
new file mode 100644
index 000000000..3cd9ac7ac
--- /dev/null
+++ b/internal/statusreaders/job_status_reader.go
@@ -0,0 +1,121 @@
+/*
+Copyright The Helm Authors.
+This file was initially copied and modified from
+ https://github.com/fluxcd/kustomize-controller/blob/main/internal/statusreaders/job.go
+Copyright 2022 The Flux 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 statusreaders
+
+import (
+ "context"
+ "fmt"
+
+ batchv1 "k8s.io/api/batch/v1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/object"
+)
+
+type customJobStatusReader struct {
+ genericStatusReader engine.StatusReader
+}
+
+func NewCustomJobStatusReader(mapper meta.RESTMapper) engine.StatusReader {
+ genericStatusReader := statusreaders.NewGenericStatusReader(mapper, jobConditions)
+ return &customJobStatusReader{
+ genericStatusReader: genericStatusReader,
+ }
+}
+
+func (j *customJobStatusReader) Supports(gk schema.GroupKind) bool {
+ return gk == batchv1.SchemeGroupVersion.WithKind("Job").GroupKind()
+}
+
+func (j *customJobStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
+ return j.genericStatusReader.ReadStatus(ctx, reader, resource)
+}
+
+func (j *customJobStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
+ return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource)
+}
+
+// Ref: https://github.com/kubernetes-sigs/cli-utils/blob/v0.29.4/pkg/kstatus/status/core.go
+// Modified to return Current status only when the Job has completed as opposed to when it's in progress.
+func jobConditions(u *unstructured.Unstructured) (*status.Result, error) {
+ obj := u.UnstructuredContent()
+
+ parallelism := status.GetIntField(obj, ".spec.parallelism", 1)
+ completions := status.GetIntField(obj, ".spec.completions", parallelism)
+ succeeded := status.GetIntField(obj, ".status.succeeded", 0)
+ failed := status.GetIntField(obj, ".status.failed", 0)
+
+ // Conditions
+ // https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/job/utils.go#L24
+ objc, err := status.GetObjectWithConditions(obj)
+ if err != nil {
+ return nil, err
+ }
+ for _, c := range objc.Status.Conditions {
+ switch c.Type {
+ case "Complete":
+ if c.Status == corev1.ConditionTrue {
+ message := fmt.Sprintf("Job Completed. succeeded: %d/%d", succeeded, completions)
+ return &status.Result{
+ Status: status.CurrentStatus,
+ Message: message,
+ Conditions: []status.Condition{},
+ }, nil
+ }
+ case "Failed":
+ message := fmt.Sprintf("Job Failed. failed: %d/%d", failed, completions)
+ if c.Status == corev1.ConditionTrue {
+ return &status.Result{
+ Status: status.FailedStatus,
+ Message: message,
+ Conditions: []status.Condition{
+ {
+ Type: status.ConditionStalled,
+ Status: corev1.ConditionTrue,
+ Reason: "JobFailed",
+ Message: message,
+ },
+ },
+ }, nil
+ }
+ }
+ }
+
+ message := "Job in progress"
+ return &status.Result{
+ Status: status.InProgressStatus,
+ Message: message,
+ Conditions: []status.Condition{
+ {
+ Type: status.ConditionReconciling,
+ Status: corev1.ConditionTrue,
+ Reason: "JobInProgress",
+ Message: message,
+ },
+ },
+ }, nil
+}
diff --git a/internal/statusreaders/job_status_reader_test.go b/internal/statusreaders/job_status_reader_test.go
new file mode 100644
index 000000000..6e9ed5a79
--- /dev/null
+++ b/internal/statusreaders/job_status_reader_test.go
@@ -0,0 +1,116 @@
+/*
+Copyright The Helm Authors.
+This file was initially copied and modified from
+ https://github.com/fluxcd/kustomize-controller/blob/main/internal/statusreaders/job_test.go
+Copyright 2022 The Flux 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 statusreaders
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ batchv1 "k8s.io/api/batch/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+)
+
+func toUnstructured(t *testing.T, obj runtime.Object) (*unstructured.Unstructured, error) {
+ t.Helper()
+ // If the incoming object is already unstructured, perform a deep copy first
+ // otherwise DefaultUnstructuredConverter ends up returning the inner map without
+ // making a copy.
+ if _, ok := obj.(runtime.Unstructured); ok {
+ obj = obj.DeepCopyObject()
+ }
+ rawMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
+ if err != nil {
+ return nil, err
+ }
+ return &unstructured.Unstructured{Object: rawMap}, nil
+}
+
+func TestJobConditions(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ job *batchv1.Job
+ expectedStatus status.Status
+ }{
+ {
+ name: "job without Complete condition returns InProgress status",
+ job: &batchv1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job-no-condition",
+ },
+ Spec: batchv1.JobSpec{},
+ Status: batchv1.JobStatus{},
+ },
+ expectedStatus: status.InProgressStatus,
+ },
+ {
+ name: "job with Complete condition as True returns Current status",
+ job: &batchv1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job-complete",
+ },
+ Spec: batchv1.JobSpec{},
+ Status: batchv1.JobStatus{
+ Conditions: []batchv1.JobCondition{
+ {
+ Type: batchv1.JobComplete,
+ Status: corev1.ConditionTrue,
+ },
+ },
+ },
+ },
+ expectedStatus: status.CurrentStatus,
+ },
+ {
+ name: "job with Failed condition as True returns Failed status",
+ job: &batchv1.Job{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "job-failed",
+ },
+ Spec: batchv1.JobSpec{},
+ Status: batchv1.JobStatus{
+ Conditions: []batchv1.JobCondition{
+ {
+ Type: batchv1.JobFailed,
+ Status: corev1.ConditionTrue,
+ },
+ },
+ },
+ },
+ expectedStatus: status.FailedStatus,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ us, err := toUnstructured(t, tc.job)
+ assert.NoError(t, err)
+ result, err := jobConditions(us)
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expectedStatus, result.Status)
+ })
+ }
+}
diff --git a/internal/statusreaders/pod_status_reader.go b/internal/statusreaders/pod_status_reader.go
new file mode 100644
index 000000000..c074c3487
--- /dev/null
+++ b/internal/statusreaders/pod_status_reader.go
@@ -0,0 +1,104 @@
+/*
+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 statusreaders
+
+import (
+ "context"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/object"
+)
+
+type customPodStatusReader struct {
+ genericStatusReader engine.StatusReader
+}
+
+func NewCustomPodStatusReader(mapper meta.RESTMapper) engine.StatusReader {
+ genericStatusReader := statusreaders.NewGenericStatusReader(mapper, podConditions)
+ return &customPodStatusReader{
+ genericStatusReader: genericStatusReader,
+ }
+}
+
+func (j *customPodStatusReader) Supports(gk schema.GroupKind) bool {
+ return gk == corev1.SchemeGroupVersion.WithKind("Pod").GroupKind()
+}
+
+func (j *customPodStatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader, resource object.ObjMetadata) (*event.ResourceStatus, error) {
+ return j.genericStatusReader.ReadStatus(ctx, reader, resource)
+}
+
+func (j *customPodStatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader, resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
+ return j.genericStatusReader.ReadStatusForObject(ctx, reader, resource)
+}
+
+func podConditions(u *unstructured.Unstructured) (*status.Result, error) {
+ obj := u.UnstructuredContent()
+ phase := status.GetStringField(obj, ".status.phase", "")
+ switch corev1.PodPhase(phase) {
+ case corev1.PodSucceeded:
+ message := fmt.Sprintf("pod %s succeeded", u.GetName())
+ return &status.Result{
+ Status: status.CurrentStatus,
+ Message: message,
+ Conditions: []status.Condition{
+ {
+ Type: status.ConditionStalled,
+ Status: corev1.ConditionTrue,
+ Message: message,
+ },
+ },
+ }, nil
+ case corev1.PodFailed:
+ message := fmt.Sprintf("pod %s failed", u.GetName())
+ return &status.Result{
+ Status: status.FailedStatus,
+ Message: message,
+ Conditions: []status.Condition{
+ {
+ Type: status.ConditionStalled,
+ Status: corev1.ConditionTrue,
+ Reason: "PodFailed",
+ Message: message,
+ },
+ },
+ }, nil
+ }
+
+ message := "Pod in progress"
+ return &status.Result{
+ Status: status.InProgressStatus,
+ Message: message,
+ Conditions: []status.Condition{
+ {
+ Type: status.ConditionReconciling,
+ Status: corev1.ConditionTrue,
+ Reason: "PodInProgress",
+ Message: message,
+ },
+ },
+ }, nil
+}
diff --git a/internal/statusreaders/pod_status_reader_test.go b/internal/statusreaders/pod_status_reader_test.go
new file mode 100644
index 000000000..ba0d1f1bb
--- /dev/null
+++ b/internal/statusreaders/pod_status_reader_test.go
@@ -0,0 +1,111 @@
+/*
+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 statusreaders
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ v1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+)
+
+func TestPodConditions(t *testing.T) {
+ tests := []struct {
+ name string
+ pod *v1.Pod
+ expectedStatus status.Status
+ }{
+ {
+ name: "pod without status returns in progress",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-no-status"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{},
+ },
+ expectedStatus: status.InProgressStatus,
+ },
+ {
+ name: "pod succeeded returns current status",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-succeeded"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{
+ Phase: v1.PodSucceeded,
+ },
+ },
+ expectedStatus: status.CurrentStatus,
+ },
+ {
+ name: "pod failed returns failed status",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-failed"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{
+ Phase: v1.PodFailed,
+ },
+ },
+ expectedStatus: status.FailedStatus,
+ },
+ {
+ name: "pod pending returns in progress status",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-pending"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{
+ Phase: v1.PodPending,
+ },
+ },
+ expectedStatus: status.InProgressStatus,
+ },
+ {
+ name: "pod running returns in progress status",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-running"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{
+ Phase: v1.PodRunning,
+ },
+ },
+ expectedStatus: status.InProgressStatus,
+ },
+ {
+ name: "pod with unknown phase returns in progress status",
+ pod: &v1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod-unknown"},
+ Spec: v1.PodSpec{},
+ Status: v1.PodStatus{
+ Phase: v1.PodUnknown,
+ },
+ },
+ expectedStatus: status.InProgressStatus,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ us, err := toUnstructured(t, tc.pod)
+ assert.NoError(t, err)
+ result, err := podConditions(us)
+ assert.NoError(t, err)
+ assert.Equal(t, tc.expectedStatus, result.Status)
+ })
+ }
+}
diff --git a/internal/sympath/walk.go b/internal/sympath/walk.go
index a276cfeff..f67b9f1b9 100644
--- a/internal/sympath/walk.go
+++ b/internal/sympath/walk.go
@@ -21,12 +21,11 @@ limitations under the License.
package sympath
import (
- "log"
+ "fmt"
+ "log/slog"
"os"
"path/filepath"
"sort"
-
- "github.com/pkg/errors"
)
// Walk walks the file tree rooted at root, calling walkFn for each file or directory
@@ -69,9 +68,10 @@ func symwalk(path string, info os.FileInfo, walkFn filepath.WalkFunc) error {
if IsSymlink(info) {
resolved, err := filepath.EvalSymlinks(path)
if err != nil {
- return errors.Wrapf(err, "error evaluating symlink %s", path)
+ return fmt.Errorf("error evaluating symlink %s: %w", path, err)
}
- log.Printf("found symbolic link in path: %s resolves to %s. Contents of linked file included and used", path, resolved)
+ //This log message is to highlight a symlink that is being used within a chart, symlinks can be used for nefarious reasons.
+ slog.Info("found symbolic link in path. Contents of linked file included and used", "path", path, "resolved", resolved)
if info, err = os.Lstat(resolved); err != nil {
return err
}
diff --git a/internal/sympath/walk_test.go b/internal/sympath/walk_test.go
index 25f737134..1eba8b996 100644
--- a/internal/sympath/walk_test.go
+++ b/internal/sympath/walk_test.go
@@ -76,6 +76,7 @@ func walkTree(n *Node, path string, f func(path string, n *Node)) {
}
func makeTree(t *testing.T) {
+ t.Helper()
walkTree(tree, tree.name, func(path string, n *Node) {
if n.entries == nil {
if n.symLinkedTo != "" {
@@ -99,6 +100,7 @@ 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)
@@ -108,18 +110,18 @@ func checkMarks(t *testing.T, report bool) {
}
// Assumes that each node name is unique. Good enough for a test.
-// If clear is true, any incoming error is cleared before return. The errors
-// are always accumulated, though.
-func mark(info os.FileInfo, err error, errors *[]error, clear bool) error {
+// If clearIncomingError is true, any incoming error is cleared before
+// return. The errors are always accumulated, though.
+func mark(info os.FileInfo, err error, errors *[]error, clearIncomingError bool) error {
if err != nil {
*errors = append(*errors, err)
- if clear {
+ if clearIncomingError {
return nil
}
return err
}
name := info.Name()
- walkTree(tree, tree.name, func(path string, n *Node) {
+ walkTree(tree, tree.name, func(_ string, n *Node) {
if n.name == name {
n.marks++
}
@@ -130,9 +132,8 @@ func mark(info os.FileInfo, err error, errors *[]error, clear bool) error {
func TestWalk(t *testing.T) {
makeTree(t)
errors := make([]error, 0, 10)
- clear := true
- markFn := func(path string, info os.FileInfo, err error) error {
- return mark(info, err, &errors, clear)
+ markFn := func(_ string, info os.FileInfo, err error) error {
+ return mark(info, err, &errors, true)
}
// Expect no errors.
err := Walk(tree.name, markFn)
diff --git a/internal/test/ensure/ensure.go b/internal/test/ensure/ensure.go
index ff2d180fe..a72f48c2d 100644
--- a/internal/test/ensure/ensure.go
+++ b/internal/test/ensure/ensure.go
@@ -21,20 +21,20 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
// HelmHome sets up a Helm Home in a temp dir.
func HelmHome(t *testing.T) {
t.Helper()
base := t.TempDir()
- os.Setenv(xdg.CacheHomeEnvVar, base)
- os.Setenv(xdg.ConfigHomeEnvVar, base)
- os.Setenv(xdg.DataHomeEnvVar, base)
- os.Setenv(helmpath.CacheHomeEnvVar, "")
- os.Setenv(helmpath.ConfigHomeEnvVar, "")
- os.Setenv(helmpath.DataHomeEnvVar, "")
+ t.Setenv(xdg.CacheHomeEnvVar, base)
+ t.Setenv(xdg.ConfigHomeEnvVar, base)
+ t.Setenv(xdg.DataHomeEnvVar, base)
+ t.Setenv(helmpath.CacheHomeEnvVar, "")
+ t.Setenv(helmpath.ConfigHomeEnvVar, "")
+ t.Setenv(helmpath.DataHomeEnvVar, "")
}
// TempFile ensures a temp file for unit testing purposes.
@@ -46,9 +46,10 @@ func HelmHome(t *testing.T) {
// tempdir := TempFile(t, "foo", []byte("bar"))
// filename := filepath.Join(tempdir, "foo")
func TempFile(t *testing.T, name string, data []byte) string {
+ t.Helper()
path := t.TempDir()
filename := filepath.Join(path, name)
- if err := os.WriteFile(filename, data, 0755); err != nil {
+ if err := os.WriteFile(filename, data, 0o755); err != nil {
t.Fatal(err)
}
return path
diff --git a/internal/test/test.go b/internal/test/test.go
index e6821282c..632bc72fd 100644
--- a/internal/test/test.go
+++ b/internal/test/test.go
@@ -19,10 +19,9 @@ package test
import (
"bytes"
"flag"
+ "fmt"
"os"
"path/filepath"
-
- "github.com/pkg/errors"
)
// UpdateGolden writes out the golden files with the latest values, rather than failing the test.
@@ -75,11 +74,11 @@ func compare(actual []byte, filename string) error {
expected, err := os.ReadFile(filename)
if err != nil {
- return errors.Wrapf(err, "unable to read testdata %s", filename)
+ return fmt.Errorf("unable to read testdata %s: %w", filename, err)
}
expected = normalize(expected)
if !bytes.Equal(expected, actual) {
- return errors.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'", filename, expected, actual)
+ return fmt.Errorf("does not match golden file %s\n\nWANT:\n'%s'\n\nGOT:\n'%s'", filename, expected, actual)
}
return nil
}
@@ -92,5 +91,5 @@ func update(filename string, in []byte) error {
}
func normalize(in []byte) []byte {
- return bytes.Replace(in, []byte("\r\n"), []byte("\n"), -1)
+ return bytes.ReplaceAll(in, []byte("\r\n"), []byte("\n"))
}
diff --git a/internal/third_party/dep/fs/fs.go b/internal/third_party/dep/fs/fs.go
index 4e4eacc60..6e2720f3b 100644
--- a/internal/third_party/dep/fs/fs.go
+++ b/internal/third_party/dep/fs/fs.go
@@ -32,13 +32,14 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package fs
import (
+ "errors"
+ "fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
"runtime"
"syscall"
-
- "github.com/pkg/errors"
)
// fs contains a copy of a few functions from dep tool code to avoid a dependency on golang/dep.
@@ -51,7 +52,7 @@ import (
func RenameWithFallback(src, dst string) error {
_, err := os.Stat(src)
if err != nil {
- return errors.Wrapf(err, "cannot stat %s", src)
+ return fmt.Errorf("cannot stat %s: %w", src, err)
}
err = os.Rename(src, dst)
@@ -69,20 +70,24 @@ func renameByCopy(src, dst string) error {
if dir, _ := IsDir(src); dir {
cerr = CopyDir(src, dst)
if cerr != nil {
- cerr = errors.Wrap(cerr, "copying directory failed")
+ cerr = fmt.Errorf("copying directory failed: %w", cerr)
}
} else {
- cerr = copyFile(src, dst)
+ cerr = CopyFile(src, dst)
if cerr != nil {
- cerr = errors.Wrap(cerr, "copying file failed")
+ cerr = fmt.Errorf("copying file failed: %w", cerr)
}
}
if cerr != nil {
- return errors.Wrapf(cerr, "rename fallback failed: cannot rename %s to %s", src, dst)
+ return fmt.Errorf("rename fallback failed: cannot rename %s to %s: %w", src, dst, cerr)
+ }
+
+ if err := os.RemoveAll(src); err != nil {
+ return fmt.Errorf("cannot delete %s: %w", src, err)
}
- return errors.Wrapf(os.RemoveAll(src), "cannot delete %s", src)
+ return nil
}
var (
@@ -107,7 +112,7 @@ func CopyDir(src, dst string) error {
}
_, err = os.Stat(dst)
- if err != nil && !os.IsNotExist(err) {
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
if err == nil {
@@ -115,12 +120,12 @@ func CopyDir(src, dst string) error {
}
if err = os.MkdirAll(dst, fi.Mode()); err != nil {
- return errors.Wrapf(err, "cannot mkdir %s", dst)
+ return fmt.Errorf("cannot mkdir %s: %w", dst, err)
}
entries, err := os.ReadDir(src)
if err != nil {
- return errors.Wrapf(err, "cannot read directory %s", dst)
+ return fmt.Errorf("cannot read directory %s: %w", dst, err)
}
for _, entry := range entries {
@@ -129,13 +134,13 @@ func CopyDir(src, dst string) error {
if entry.IsDir() {
if err = CopyDir(srcPath, dstPath); err != nil {
- return errors.Wrap(err, "copying directory failed")
+ return fmt.Errorf("copying directory failed: %w", err)
}
} else {
// This will include symlinks, which is what we want when
// copying things.
- if err = copyFile(srcPath, dstPath); err != nil {
- return errors.Wrap(err, "copying file failed")
+ if err = CopyFile(srcPath, dstPath); err != nil {
+ return fmt.Errorf("copying file failed: %w", err)
}
}
}
@@ -143,13 +148,13 @@ func CopyDir(src, dst string) error {
return nil
}
-// copyFile copies the contents of the file named src to the file named
+// CopyFile copies the contents of the file named src to the file named
// by dst. The file will be created if it does not already exist. If the
// destination file exists, all its contents will be replaced by the contents
// of the source file. The file mode will be copied from the source.
-func copyFile(src, dst string) (err error) {
+func CopyFile(src, dst string) (err error) {
if sym, err := IsSymlink(src); err != nil {
- return errors.Wrap(err, "symlink check failed")
+ return fmt.Errorf("symlink check failed: %w", err)
} else if sym {
if err := cloneSymlink(src, dst); err != nil {
if runtime.GOOS == "windows" {
@@ -172,28 +177,28 @@ func copyFile(src, dst string) (err error) {
in, err := os.Open(src)
if err != nil {
- return
+ return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
- return
+ return err
}
if _, err = io.Copy(out, in); err != nil {
out.Close()
- return
+ return err
}
// Check for write errors on Close
if err = out.Close(); err != nil {
- return
+ return err
}
si, err := os.Stat(src)
if err != nil {
- return
+ return err
}
// Temporary fix for Go < 1.9
@@ -205,7 +210,7 @@ func copyFile(src, dst string) (err error) {
}
err = os.Chmod(dst, si.Mode())
- return
+ return err
}
// cloneSymlink will create a new symlink that points to the resolved path of sl.
@@ -226,7 +231,7 @@ func IsDir(name string) (bool, error) {
return false, err
}
if !fi.IsDir() {
- return false, errors.Errorf("%q is not a directory", name)
+ return false, fmt.Errorf("%q is not a directory", name)
}
return true, nil
}
@@ -260,7 +265,7 @@ func fixLongPath(path string) string {
// minus 12)." Since MAX_PATH is 260, 260 - 12 = 248.
//
// The MSDN docs appear to say that a normal path that is 248 bytes long
- // will work; empirically the path must be less then 248 bytes long.
+ // will work; empirically the path must be less than 248 bytes long.
if len(path) < 248 {
// Don't fix. (This is how Go 1.7 and earlier worked,
// not automatically generating the \\?\ form)
diff --git a/internal/third_party/dep/fs/fs_test.go b/internal/third_party/dep/fs/fs_test.go
index d42c3f110..610771bc3 100644
--- a/internal/third_party/dep/fs/fs_test.go
+++ b/internal/third_party/dep/fs/fs_test.go
@@ -33,17 +33,11 @@ package fs
import (
"os"
- "os/exec"
"path/filepath"
"runtime"
- "sync"
"testing"
)
-var (
- mu sync.Mutex
-)
-
func TestRenameWithFallback(t *testing.T) {
dir := t.TempDir()
@@ -332,7 +326,7 @@ func TestCopyFile(t *testing.T) {
srcf.Close()
destf := filepath.Join(dir, "destf")
- if err := copyFile(srcf.Name(), destf); err != nil {
+ if err := CopyFile(srcf.Name(), destf); err != nil {
t.Fatal(err)
}
@@ -360,19 +354,6 @@ func TestCopyFile(t *testing.T) {
}
}
-func cleanUpDir(dir string) {
- // NOTE(mattn): It seems that sometimes git.exe is not dead
- // when cleanUpDir() is called. But we do not know any way to wait for it.
- if runtime.GOOS == "windows" {
- mu.Lock()
- exec.Command(`taskkill`, `/F`, `/IM`, `git.exe`).Run()
- mu.Unlock()
- }
- if dir != "" {
- os.RemoveAll(dir)
- }
-}
-
func TestCopyFileSymlink(t *testing.T) {
tempdir := t.TempDir()
@@ -385,7 +366,7 @@ func TestCopyFileSymlink(t *testing.T) {
for symlink, dst := range testcases {
t.Run(symlink, func(t *testing.T) {
var err error
- if err = copyFile(symlink, dst); err != nil {
+ if err = CopyFile(symlink, dst); err != nil {
t.Fatalf("failed to copy symlink: %s", err)
}
@@ -457,7 +438,7 @@ func TestCopyFileFail(t *testing.T) {
defer cleanup()
fn := filepath.Join(dstdir, "file")
- if err := copyFile(srcf.Name(), fn); err == nil {
+ if err := CopyFile(srcf.Name(), fn); err == nil {
t.Fatalf("expected error for %s, got none", fn)
}
}
@@ -476,6 +457,7 @@ func TestCopyFileFail(t *testing.T) {
// files this function creates. It is the caller's responsibility to call
// this function before the test is done running, whether there's an error or not.
func setupInaccessibleDir(t *testing.T, op func(dir string) error) func() {
+ t.Helper()
dir := t.TempDir()
subdir := filepath.Join(dir, "dir")
diff --git a/internal/third_party/dep/fs/rename.go b/internal/third_party/dep/fs/rename.go
index a3e5e56a6..5f13b1ca3 100644
--- a/internal/third_party/dep/fs/rename.go
+++ b/internal/third_party/dep/fs/rename.go
@@ -34,10 +34,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package fs
import (
+ "fmt"
"os"
"syscall"
-
- "github.com/pkg/errors"
)
// renameFallback attempts to determine the appropriate fallback to failed rename
@@ -51,7 +50,7 @@ func renameFallback(err error, src, dst string) error {
if !ok {
return err
} else if terr.Err != syscall.EXDEV {
- return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst)
+ return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr)
}
return renameByCopy(src, dst)
diff --git a/internal/third_party/dep/fs/rename_windows.go b/internal/third_party/dep/fs/rename_windows.go
index a377720a6..566f695d3 100644
--- a/internal/third_party/dep/fs/rename_windows.go
+++ b/internal/third_party/dep/fs/rename_windows.go
@@ -34,10 +34,9 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package fs
import (
+ "fmt"
"os"
"syscall"
-
- "github.com/pkg/errors"
)
// renameFallback attempts to determine the appropriate fallback to failed rename
@@ -61,7 +60,7 @@ func renameFallback(err error, src, dst string) error {
// 0x11 (ERROR_NOT_SAME_DEVICE) is the windows error.
// See https://msdn.microsoft.com/en-us/library/cc231199.aspx
if ok && noerr != 0x11 {
- return errors.Wrapf(terr, "link error: cannot rename %s to %s", src, dst)
+ return fmt.Errorf("link error: cannot rename %s to %s: %w", src, dst, terr)
}
}
diff --git a/internal/tlsutil/cfg.go b/internal/tlsutil/cfg.go
deleted file mode 100644
index 8b9d4329f..000000000
--- a/internal/tlsutil/cfg.go
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
-Copyright The Helm Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package tlsutil
-
-import (
- "crypto/tls"
- "crypto/x509"
- "os"
-
- "github.com/pkg/errors"
-)
-
-// Options represents configurable options used to create client and server TLS configurations.
-type Options struct {
- CaCertFile string
- // If either the KeyFile or CertFile is empty, ClientConfig() will not load them.
- KeyFile string
- CertFile string
- // Client-only options
- InsecureSkipVerify bool
-}
-
-// ClientConfig returns a TLS configuration for use by a Helm client.
-func ClientConfig(opts Options) (cfg *tls.Config, err error) {
- var cert *tls.Certificate
- var pool *x509.CertPool
-
- if opts.CertFile != "" || opts.KeyFile != "" {
- if cert, err = CertFromFilePair(opts.CertFile, opts.KeyFile); err != nil {
- if os.IsNotExist(err) {
- return nil, errors.Wrapf(err, "could not load x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile)
- }
- return nil, errors.Wrapf(err, "could not read x509 key pair (cert: %q, key: %q)", opts.CertFile, opts.KeyFile)
- }
- }
- if !opts.InsecureSkipVerify && opts.CaCertFile != "" {
- if pool, err = CertPoolFromFile(opts.CaCertFile); err != nil {
- return nil, err
- }
- }
-
- cfg = &tls.Config{InsecureSkipVerify: opts.InsecureSkipVerify, Certificates: []tls.Certificate{*cert}, RootCAs: pool}
- return cfg, nil
-}
diff --git a/internal/tlsutil/tls.go b/internal/tlsutil/tls.go
index dc832ed80..645834c29 100644
--- a/internal/tlsutil/tls.go
+++ b/internal/tlsutil/tls.go
@@ -19,60 +19,104 @@ package tlsutil
import (
"crypto/tls"
"crypto/x509"
+ "fmt"
"os"
- "github.com/pkg/errors"
+ "errors"
)
-// NewClientTLS returns tls.Config appropriate for client auth.
-func NewClientTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*tls.Config, error) {
- config := tls.Config{
- InsecureSkipVerify: insecureSkipTLSverify,
+type TLSConfigOptions struct {
+ insecureSkipTLSverify bool
+ certPEMBlock, keyPEMBlock []byte
+ caPEMBlock []byte
+}
+
+type TLSConfigOption func(options *TLSConfigOptions) error
+
+func WithInsecureSkipVerify(insecureSkipTLSverify bool) TLSConfigOption {
+ return func(options *TLSConfigOptions) error {
+ options.insecureSkipTLSverify = insecureSkipTLSverify
+
+ return nil
}
+}
+
+func WithCertKeyPairFiles(certFile, keyFile string) TLSConfigOption {
+ return func(options *TLSConfigOptions) error {
+ if certFile == "" && keyFile == "" {
+ return nil
+ }
- if certFile != "" && keyFile != "" {
- cert, err := CertFromFilePair(certFile, keyFile)
+ certPEMBlock, err := os.ReadFile(certFile)
if err != nil {
- return nil, err
+ return fmt.Errorf("unable to read cert file: %q: %w", certFile, err)
}
- config.Certificates = []tls.Certificate{*cert}
- }
- if caFile != "" {
- cp, err := CertPoolFromFile(caFile)
+ keyPEMBlock, err := os.ReadFile(keyFile)
if err != nil {
- return nil, err
+ return fmt.Errorf("unable to read key file: %q: %w", keyFile, err)
}
- config.RootCAs = cp
+
+ options.certPEMBlock = certPEMBlock
+ options.keyPEMBlock = keyPEMBlock
+
+ return nil
}
+}
- return &config, nil
+func WithCAFile(caFile string) TLSConfigOption {
+ return func(options *TLSConfigOptions) error {
+ if caFile == "" {
+ return nil
+ }
+
+ caPEMBlock, err := os.ReadFile(caFile)
+ if err != nil {
+ return fmt.Errorf("can't read CA file: %q: %w", caFile, err)
+ }
+
+ options.caPEMBlock = caPEMBlock
+
+ return nil
+ }
}
-// CertPoolFromFile returns an x509.CertPool containing the certificates
-// in the given PEM-encoded file.
-// Returns an error if the file could not be read, a certificate could not
-// be parsed, or if the file does not contain any certificates
-func CertPoolFromFile(filename string) (*x509.CertPool, error) {
- b, err := os.ReadFile(filename)
- if err != nil {
- return nil, errors.Errorf("can't read CA file: %v", filename)
+func NewTLSConfig(options ...TLSConfigOption) (*tls.Config, error) {
+ to := TLSConfigOptions{}
+
+ errs := []error{}
+ for _, option := range options {
+ err := option(&to)
+ if err != nil {
+ errs = append(errs, err)
+ }
}
- cp := x509.NewCertPool()
- if !cp.AppendCertsFromPEM(b) {
- return nil, errors.Errorf("failed to append certificates from file: %s", filename)
+
+ if len(errs) > 0 {
+ return nil, errors.Join(errs...)
}
- return cp, nil
-}
-// CertFromFilePair returns an tls.Certificate containing the
-// certificates public/private key pair from a pair of given PEM-encoded files.
-// Returns an error if the file could not be read, a certificate could not
-// be parsed, or if the file does not contain any certificates
-func CertFromFilePair(certFile, keyFile string) (*tls.Certificate, error) {
- cert, err := tls.LoadX509KeyPair(certFile, keyFile)
- if err != nil {
- return nil, errors.Wrapf(err, "can't load key pair from cert %s and key %s", certFile, keyFile)
+ config := tls.Config{
+ InsecureSkipVerify: to.insecureSkipTLSverify,
}
- return &cert, err
+
+ if len(to.certPEMBlock) > 0 && len(to.keyPEMBlock) > 0 {
+ cert, err := tls.X509KeyPair(to.certPEMBlock, to.keyPEMBlock)
+ if err != nil {
+ return nil, fmt.Errorf("unable to load cert from key pair: %w", err)
+ }
+
+ config.Certificates = []tls.Certificate{cert}
+ }
+
+ if len(to.caPEMBlock) > 0 {
+ cp := x509.NewCertPool()
+ if !cp.AppendCertsFromPEM(to.caPEMBlock) {
+ return nil, fmt.Errorf("failed to append certificates from pem block")
+ }
+
+ config.RootCAs = cp
+ }
+
+ return &config, nil
}
diff --git a/internal/tlsutil/tls_test.go b/internal/tlsutil/tls_test.go
new file mode 100644
index 000000000..3d7e75c86
--- /dev/null
+++ b/internal/tlsutil/tls_test.go
@@ -0,0 +1,106 @@
+/*
+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 tlsutil
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+const tlsTestDir = "../../testdata"
+
+const (
+ testCaCertFile = "rootca.crt"
+ testCertFile = "crt.pem"
+ testKeyFile = "key.pem"
+)
+
+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)
+ }
+ return path
+}
+
+func TestNewTLSConfig(t *testing.T) {
+ certFile := testfile(t, testCertFile)
+ keyFile := testfile(t, testKeyFile)
+ caCertFile := testfile(t, testCaCertFile)
+ insecureSkipTLSverify := false
+
+ {
+ cfg, err := NewTLSConfig(
+ WithInsecureSkipVerify(insecureSkipTLSverify),
+ WithCertKeyPairFiles(certFile, keyFile),
+ WithCAFile(caCertFile),
+ )
+ if err != nil {
+ t.Error(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")
+ }
+ }
+ {
+ cfg, err := NewTLSConfig(
+ WithInsecureSkipVerify(insecureSkipTLSverify),
+ WithCAFile(caCertFile),
+ )
+ if err != nil {
+ t.Error(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")
+ }
+ }
+
+ {
+ cfg, err := NewTLSConfig(
+ WithInsecureSkipVerify(insecureSkipTLSverify),
+ WithCertKeyPairFiles(certFile, keyFile),
+ )
+ if err != nil {
+ t.Error(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")
+ }
+ }
+}
diff --git a/internal/tlsutil/tlsutil_test.go b/internal/tlsutil/tlsutil_test.go
deleted file mode 100644
index e31a873d3..000000000
--- a/internal/tlsutil/tlsutil_test.go
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
-Copyright The Helm Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package tlsutil
-
-import (
- "path/filepath"
- "testing"
-)
-
-const tlsTestDir = "../../testdata"
-
-const (
- testCaCertFile = "rootca.crt"
- testCertFile = "crt.pem"
- testKeyFile = "key.pem"
-)
-
-func TestClientConfig(t *testing.T) {
- opts := Options{
- CaCertFile: testfile(t, testCaCertFile),
- CertFile: testfile(t, testCertFile),
- KeyFile: testfile(t, testKeyFile),
- InsecureSkipVerify: false,
- }
-
- cfg, err := ClientConfig(opts)
- if err != nil {
- t.Fatalf("error building tls client config: %v", 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")
- }
-}
-
-func testfile(t *testing.T, file string) (path string) {
- var err error
- if path, err = filepath.Abs(filepath.Join(tlsTestDir, file)); err != nil {
- t.Fatalf("error getting absolute path to test file %q: %v", file, err)
- }
- return path
-}
-
-func TestNewClientTLS(t *testing.T) {
- certFile := testfile(t, testCertFile)
- keyFile := testfile(t, testKeyFile)
- caCertFile := testfile(t, testCaCertFile)
- insecureSkipTLSverify := false
-
- cfg, err := NewClientTLS(certFile, keyFile, caCertFile, insecureSkipTLSverify)
- if err != nil {
- t.Error(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")
- }
-
- cfg, err = NewClientTLS("", "", caCertFile, insecureSkipTLSverify)
- if err != nil {
- t.Error(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")
- }
-
- cfg, err = NewClientTLS(certFile, keyFile, "", insecureSkipTLSverify)
- if err != nil {
- t.Error(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")
- }
-}
diff --git a/internal/version/version.go b/internal/version/version.go
index b29891ec6..aa64e618f 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package version // import "helm.sh/helm/v3/internal/version"
+package version // import "helm.sh/helm/v4/internal/version"
import (
"flag"
@@ -29,7 +29,7 @@ var (
//
// Increment major number for new feature additions and behavioral changes.
// Increment minor number for bug fixes and performance enhancements.
- version = "v3.13"
+ version = "v4.0"
// metadata is extra build time data
metadata = ""
diff --git a/pkg/action/action.go b/pkg/action/action.go
index 5693f4838..42dc56c96 100644
--- a/pkg/action/action.go
+++ b/pkg/action/action.go
@@ -18,31 +18,38 @@ package action
import (
"bytes"
+ "errors"
"fmt"
+ "io"
+ "log/slog"
+ "maps"
"os"
"path"
"path/filepath"
- "regexp"
+ "slices"
"strings"
+ "sync"
+ "text/template"
- "github.com/pkg/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/discovery"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
-
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/engine"
- "helm.sh/helm/v3/pkg/kube"
- "helm.sh/helm/v3/pkg/postrender"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
- "helm.sh/helm/v3/pkg/storage"
- "helm.sh/helm/v3/pkg/storage/driver"
- "helm.sh/helm/v3/pkg/time"
+ "sigs.k8s.io/kustomize/kyaml/kio"
+ kyaml "sigs.k8s.io/kustomize/kyaml/yaml"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/engine"
+ "helm.sh/helm/v4/pkg/kube"
+ "helm.sh/helm/v4/pkg/postrender"
+ "helm.sh/helm/v4/pkg/registry"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage"
+ "helm.sh/helm/v4/pkg/storage/driver"
+ "helm.sh/helm/v4/pkg/time"
)
// Timestamper is a function capable of producing a timestamp.Timestamper.
@@ -62,21 +69,6 @@ var (
errPending = errors.New("another operation (install/upgrade/rollback) is in progress")
)
-// ValidName is a regular expression for resource names.
-//
-// DEPRECATED: This will be removed in Helm 4, and is no longer used here. See
-// pkg/lint/rules.validateMetadataNameFunc for the replacement.
-//
-// According to the Kubernetes help text, the regular expression it uses is:
-//
-// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
-//
-// This follows the above regular expression (but requires a full string match, not partial).
-//
-// The Kubernetes documentation is here, though it is not entirely correct:
-// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
-var ValidName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
-
// Configuration injects the dependencies that all actions share.
type Configuration struct {
// RESTClientGetter is an interface that loads Kubernetes clients.
@@ -94,7 +86,88 @@ type Configuration struct {
// Capabilities describes the capabilities of the Kubernetes cluster.
Capabilities *chartutil.Capabilities
- Log func(string, ...interface{})
+ // CustomTemplateFuncs is defined by users to provide custom template funcs
+ CustomTemplateFuncs template.FuncMap
+
+ // HookOutputFunc called with container name and returns and expects writer that will receive the log output.
+ HookOutputFunc func(namespace, pod, container string) io.Writer
+
+ mutex sync.Mutex
+}
+
+const (
+ // filenameAnnotation is the annotation key used to store the original filename
+ // information in manifest annotations for post-rendering reconstruction.
+ filenameAnnotation = "postrenderer.helm.sh/postrender-filename"
+)
+
+// annotateAndMerge combines multiple YAML files into a single stream of documents,
+// adding filename annotations to each document for later reconstruction.
+func annotateAndMerge(files map[string]string) (string, error) {
+ var combinedManifests []*kyaml.RNode
+
+ // Get sorted filenames to ensure result is deterministic
+ fnames := slices.Sorted(maps.Keys(files))
+
+ for _, fname := range fnames {
+ content := files[fname]
+ // Skip partials and empty files.
+ if strings.HasPrefix(path.Base(fname), "_") || strings.TrimSpace(content) == "" {
+ continue
+ }
+
+ manifests, err := kio.ParseAll(content)
+ 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)
+ }
+ }
+
+ merged, err := kio.StringAll(combinedManifests)
+ if err != nil {
+ return "", fmt.Errorf("writing merged docs: %w", err)
+ }
+ return merged, nil
+}
+
+// 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) {
+ manifests, err := kio.ParseAll(postrendered)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing YAML: %w", err)
+ }
+
+ manifestsByFilename := make(map[string][]*kyaml.RNode)
+ for i, manifest := range manifests {
+ meta, err := manifest.GetMeta()
+ if err != nil {
+ return nil, fmt.Errorf("getting metadata: %w", err)
+ }
+ fname := meta.Annotations[filenameAnnotation]
+ if fname == "" {
+ fname = fmt.Sprintf("generated-by-postrender-%d.yaml", i)
+ }
+ if err := manifest.PipeE(kyaml.ClearAnnotation(filenameAnnotation)); err != nil {
+ return nil, fmt.Errorf("clearing filename annotation: %w", err)
+ }
+ manifestsByFilename[fname] = append(manifestsByFilename[fname], manifest)
+ }
+
+ reconstructed := make(map[string]string, len(manifestsByFilename))
+ for fname, docs := range manifestsByFilename {
+ fileContents, err := kio.StringAll(docs)
+ if err != nil {
+ return nil, fmt.Errorf("re-writing %s: %w", fname, err)
+ }
+ reconstructed[fname] = fileContents
+ }
+ return reconstructed, nil
}
// renderResources renders the templates in a chart
@@ -103,8 +176,8 @@ type Configuration struct {
// 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 chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS bool) ([]*release.Hook, *bytes.Buffer, string, error) {
- hs := []*release.Hook{}
+func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) {
+ var hs []*release.Hook
b := bytes.NewBuffer(nil)
caps, err := cfg.getCapabilities()
@@ -114,7 +187,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
if ch.Metadata.KubeVersion != "" {
if !chartutil.IsCompatibleRange(ch.Metadata.KubeVersion, caps.KubeVersion.String()) {
- return hs, b, "", errors.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String())
+ return hs, b, "", fmt.Errorf("chart requires kubeVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, caps.KubeVersion.String())
}
}
@@ -122,7 +195,7 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
var err2 error
// A `helm template` should not talk to the remote cluster. However, commands with the flag
- //`--dry-run` with the value of `false`, `none`, or `server` should try to interact with the cluster.
+ // `--dry-run` with the value of `false`, `none`, or `server` should try to interact with the cluster.
// It may break in interesting and exotic ways because other data (e.g. discovery) is mocked.
if interactWithRemote && cfg.RESTClientGetter != nil {
restConfig, err := cfg.RESTClientGetter.ToRESTConfig()
@@ -131,10 +204,14 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
}
e := engine.New(restConfig)
e.EnableDNS = enableDNS
+ e.CustomTemplateFuncs = cfg.CustomTemplateFuncs
+
files, err2 = e.Render(ch, values)
} else {
var e engine.Engine
e.EnableDNS = enableDNS
+ e.CustomTemplateFuncs = cfg.CustomTemplateFuncs
+
files, err2 = e.Render(ch, values)
}
@@ -162,10 +239,37 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
}
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)
+ }
+
+ // 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)
+ }
+ }
+
// Sort hooks, manifests, and partials. Only hooks and manifests are returned,
// as partials are not used after renderer.Render. Empty manifests are also
// removed here.
- hs, manifests, err := releaseutil.SortManifests(files, caps.APIVersions, releaseutil.InstallOrder)
+ hs, manifests, err := releaseutil.SortManifests(files, nil, releaseutil.InstallOrder)
if err != nil {
// By catching parse errors here, we can prevent bogus releases from going
// to Kubernetes.
@@ -200,7 +304,11 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
for _, m := range manifests {
if outputDir == "" {
- fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content)
+ if hideSecret && m.Head.Kind == "Secret" && m.Head.Version == "v1" {
+ fmt.Fprintf(b, "---\n# Source: %s\n# HIDDEN: The Secret output has been suppressed\n", m.Name)
+ } else {
+ fmt.Fprintf(b, "---\n# Source: %s\n%s\n", m.Name, m.Content)
+ }
} else {
newDir := outputDir
if useReleaseName {
@@ -218,13 +326,6 @@ func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Valu
}
}
- if pr != nil {
- b, err = pr.Run(b)
- if err != nil {
- return hs, b, notes, errors.Wrap(err, "error while running post render on files")
- }
- }
-
return hs, b, notes, nil
}
@@ -235,9 +336,6 @@ type RESTClientGetter interface {
ToRESTMapper() (meta.RESTMapper, error)
}
-// DebugLog sets the logger that writes debug strings
-type DebugLog func(format string, v ...interface{})
-
// capabilities builds a Capabilities from discovery information.
func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) {
if cfg.Capabilities != nil {
@@ -245,13 +343,13 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) {
}
dc, err := cfg.RESTClientGetter.ToDiscoveryClient()
if err != nil {
- return nil, errors.Wrap(err, "could not get Kubernetes discovery client")
+ return nil, fmt.Errorf("could not get Kubernetes discovery client: %w", err)
}
// force a discovery cache invalidation to always fetch the latest server version/capabilities.
dc.Invalidate()
kubeVersion, err := dc.ServerVersion()
if err != nil {
- return nil, errors.Wrap(err, "could not get server version from Kubernetes")
+ return nil, fmt.Errorf("could not get server version from Kubernetes: %w", err)
}
// Issue #6361:
// Client-Go emits an error when an API service is registered but unimplemented.
@@ -261,10 +359,10 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) {
apiVersions, err := GetVersionSet(dc)
if err != nil {
if discovery.IsGroupDiscoveryFailedError(err) {
- cfg.Log("WARNING: The Kubernetes server has an orphaned API service. Server reports: %s", err)
- cfg.Log("WARNING: To fix this, kubectl delete apiservice ")
+ slog.Warn("the kubernetes server has an orphaned API service", slog.Any("error", err))
+ slog.Warn("to fix this, kubectl delete apiservice ")
} else {
- return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes")
+ return nil, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err)
}
}
@@ -284,7 +382,7 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) {
func (cfg *Configuration) KubernetesClientSet() (kubernetes.Interface, error) {
conf, err := cfg.RESTClientGetter.ToRESTConfig()
if err != nil {
- return nil, errors.Wrap(err, "unable to generate config for kubernetes client")
+ return nil, fmt.Errorf("unable to generate config for kubernetes client: %w", err)
}
return kubernetes.NewForConfig(conf)
@@ -300,7 +398,7 @@ func (cfg *Configuration) Now() time.Time {
func (cfg *Configuration) releaseContent(name string, version int) (*release.Release, error) {
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, errors.Errorf("releaseContent: Release name is invalid: %s", name)
+ return nil, fmt.Errorf("releaseContent: Release name is invalid: %s", name)
}
if version <= 0 {
@@ -314,7 +412,7 @@ func (cfg *Configuration) releaseContent(name string, version int) (*release.Rel
func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) {
groups, resources, err := client.ServerGroupsAndResources()
if err != nil && !discovery.IsGroupDiscoveryFailedError(err) {
- return chartutil.DefaultVersionSet, errors.Wrap(err, "could not get apiVersions from Kubernetes")
+ return chartutil.DefaultVersionSet, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err)
}
// FIXME: The Kubernetes test fixture for cli appears to always return nil
@@ -326,7 +424,7 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version
}
versionMap := make(map[string]interface{})
- versions := []string{}
+ var versions []string
// Extract the groups
for _, g := range groups {
@@ -361,14 +459,13 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version
// recordRelease with an update operation in case reuse has been set.
func (cfg *Configuration) recordRelease(r *release.Release) {
if err := cfg.Releases.Update(r); err != nil {
- cfg.Log("warning: Failed to update release %s: %s", r.Name, err)
+ slog.Warn("failed to update release", "name", r.Name, "revision", r.Version, slog.Any("error", err))
}
}
// Init initializes the action configuration
-func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string, log DebugLog) error {
+func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namespace, helmDriver string) error {
kc := kube.New(getter)
- kc.Log = log
lazyClient := &lazyClient{
namespace: namespace,
@@ -379,19 +476,17 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
switch helmDriver {
case "secret", "secrets", "":
d := driver.NewSecrets(newSecretClient(lazyClient))
- d.Log = log
store = storage.Init(d)
case "configmap", "configmaps":
d := driver.NewConfigMaps(newConfigMapClient(lazyClient))
- d.Log = log
store = storage.Init(d)
case "memory":
var d *driver.Memory
if cfg.Releases != nil {
if mem, ok := cfg.Releases.Driver.(*driver.Memory); ok {
// This function can be called more than once (e.g., helm list --all-namespaces).
- // If a memory driver was already initialized, re-use it but set the possibly new namespace.
- // We re-use it in case some releases where already created in the existing memory driver.
+ // If a memory driver was already initialized, reuse it but set the possibly new namespace.
+ // We reuse it in case some releases where already created in the existing memory driver.
d = mem
}
}
@@ -403,22 +498,25 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
case "sql":
d, err := driver.NewSQL(
os.Getenv("HELM_DRIVER_SQL_CONNECTION_STRING"),
- log,
namespace,
)
if err != nil {
- panic(fmt.Sprintf("Unable to instantiate SQL driver: %v", err))
+ return fmt.Errorf("unable to instantiate SQL driver: %w", err)
}
store = storage.Init(d)
default:
- // Not sure what to do here.
- panic("Unknown driver in HELM_DRIVER: " + helmDriver)
+ return fmt.Errorf("unknown driver %q", helmDriver)
}
cfg.RESTClientGetter = getter
cfg.KubeClient = kc
cfg.Releases = store
- cfg.Log = log
+ cfg.HookOutputFunc = func(_, _, _ string) io.Writer { return io.Discard }
return nil
}
+
+// SetHookOutputFunc sets the HookOutputFunc on the Configuration.
+func (cfg *Configuration) SetHookOutputFunc(hookOutputFunc func(_, _, _ string) io.Writer) {
+ cfg.HookOutputFunc = hookOutputFunc
+}
diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go
index c4ef6c056..43cf94622 100644
--- a/pkg/action/action_test.go
+++ b/pkg/action/action_test.go
@@ -16,26 +16,45 @@ limitations under the License.
package action
import (
+ "bytes"
+ "errors"
"flag"
+ "fmt"
"io"
+ "log/slog"
+ "strings"
"testing"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
fakeclientset "k8s.io/client-go/kubernetes/fake"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage"
- "helm.sh/helm/v3/pkg/storage/driver"
- "helm.sh/helm/v3/pkg/time"
+ "helm.sh/helm/v4/internal/logging"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ "helm.sh/helm/v4/pkg/registry"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage"
+ "helm.sh/helm/v4/pkg/storage/driver"
+ "helm.sh/helm/v4/pkg/time"
)
-var verbose = flag.Bool("test.log", false, "enable test logging")
+var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)")
func actionConfigFixture(t *testing.T) *Configuration {
t.Helper()
+ return actionConfigFixtureWithDummyResources(t, nil)
+}
+
+func actionConfigFixtureWithDummyResources(t *testing.T, dummyResources kube.ResourceList) *Configuration {
+ t.Helper()
+
+ logger := logging.NewLogger(func() bool {
+ return *verbose
+ })
+ slog.SetDefault(logger)
registryClient, err := registry.NewClient()
if err != nil {
@@ -44,15 +63,9 @@ func actionConfigFixture(t *testing.T) *Configuration {
return &Configuration{
Releases: storage.Init(driver.NewMemory()),
- KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}},
+ KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: dummyResources},
Capabilities: chartutil.DefaultCapabilities,
RegistryClient: registryClient,
- Log: func(format string, v ...interface{}) {
- t.Helper()
- if *verbose {
- t.Logf(format, v...)
- }
- },
}
}
@@ -109,6 +122,14 @@ type chartOptions struct {
type chartOption func(*chartOptions)
func buildChart(opts ...chartOption) *chart.Chart {
+ defaultTemplates := []*chart.File{
+ {Name: "templates/hello", Data: []byte("hello: world")},
+ {Name: "templates/hooks", Data: []byte(manifestWithHook)},
+ }
+ return buildChartWithTemplates(defaultTemplates, opts...)
+}
+
+func buildChartWithTemplates(templates []*chart.File, opts ...chartOption) *chart.Chart {
c := &chartOptions{
Chart: &chart.Chart{
// TODO: This should be more complete.
@@ -117,18 +138,13 @@ func buildChart(opts ...chartOption) *chart.Chart {
Name: "hello",
Version: "0.1.0",
},
- // This adds a basic template and hooks.
- Templates: []*chart.File{
- {Name: "templates/hello", Data: []byte("hello: world")},
- {Name: "templates/hooks", Data: []byte(manifestWithHook)},
- },
+ Templates: templates,
},
}
for _, opt := range opts {
opt(c)
}
-
return c.Chart
}
@@ -195,6 +211,13 @@ func withSampleTemplates() chartOption {
}
}
+func withSampleSecret() chartOption {
+ return func(opts *chartOptions) {
+ sampleSecret := &chart.File{Name: "templates/secret.yaml", Data: []byte("apiVersion: v1\nkind: Secret\n")}
+ opts.Templates = append(opts.Templates, sampleSecret)
+ }
+}
+
func withSampleIncludingIncorrectTemplates() chartOption {
return func(opts *chartOptions) {
sampleTemplates := []*chart.File{
@@ -266,8 +289,76 @@ func namedReleaseStub(name string, status release.Status) *release.Release {
}
}
+func TestConfiguration_Init(t *testing.T) {
+ tests := []struct {
+ name string
+ helmDriver string
+ expectedDriverType interface{}
+ expectErr bool
+ errMsg string
+ }{
+ {
+ name: "Test secret driver",
+ helmDriver: "secret",
+ expectedDriverType: &driver.Secrets{},
+ },
+ {
+ name: "Test secrets driver",
+ helmDriver: "secrets",
+ expectedDriverType: &driver.Secrets{},
+ },
+ {
+ name: "Test empty driver",
+ helmDriver: "",
+ expectedDriverType: &driver.Secrets{},
+ },
+ {
+ name: "Test configmap driver",
+ helmDriver: "configmap",
+ expectedDriverType: &driver.ConfigMaps{},
+ },
+ {
+ name: "Test configmaps driver",
+ helmDriver: "configmaps",
+ expectedDriverType: &driver.ConfigMaps{},
+ },
+ {
+ name: "Test memory driver",
+ helmDriver: "memory",
+ expectedDriverType: &driver.Memory{},
+ },
+ {
+ name: "Test sql driver",
+ helmDriver: "sql",
+ expectErr: true,
+ errMsg: "unable to instantiate SQL driver",
+ },
+ {
+ name: "Test unknown driver",
+ helmDriver: "someDriver",
+ expectErr: true,
+ errMsg: fmt.Sprintf("unknown driver %q", "someDriver"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &Configuration{}
+
+ actualErr := cfg.Init(nil, "default", tt.helmDriver)
+ if tt.expectErr {
+ assert.Error(t, actualErr)
+ assert.Contains(t, actualErr.Error(), tt.errMsg)
+ } else {
+ assert.NoError(t, actualErr)
+ assert.IsType(t, tt.expectedDriverType, cfg.Releases.Driver)
+ }
+ })
+ }
+}
+
func TestGetVersionSet(t *testing.T) {
- client := fakeclientset.NewSimpleClientset()
+ client := fakeclientset.NewClientset()
vs, err := GetVersionSet(client.Discovery())
if err != nil {
@@ -281,3 +372,577 @@ func TestGetVersionSet(t *testing.T) {
t.Error("Non-existent version is reported found.")
}
}
+
+// Mock PostRenderer for testing
+type mockPostRenderer struct {
+ shouldError bool
+ transform func(string) string
+}
+
+func (m *mockPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) {
+ if m.shouldError {
+ return nil, errors.New("mock post-renderer error")
+ }
+
+ content := renderedManifests.String()
+ if m.transform != nil {
+ content = m.transform(content)
+ }
+
+ return bytes.NewBufferString(content), nil
+}
+
+func TestAnnotateAndMerge(t *testing.T) {
+ tests := []struct {
+ name string
+ files map[string]string
+ expectedError string
+ expected string
+ }{
+ {
+ name: "no files",
+ files: map[string]string{},
+ expected: "",
+ },
+ {
+ name: "single file with single manifest",
+ files: map[string]string{
+ "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",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ merged, err := annotateAndMerge(tt.files)
+
+ if tt.expectedError != "" {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedError)
+ } else {
+ assert.NoError(t, err)
+ assert.NotNil(t, merged)
+ assert.Equal(t, tt.expected, merged)
+ }
+ })
+ }
+}
+
+func TestSplitAndDeannotate(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expectedFiles map[string]string
+ expectedError string
+ }{
+ {
+ name: "single annotated manifest",
+ input: `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+ annotations:
+ postrenderer.helm.sh/postrender-filename: templates/configmap.yaml
+data:
+ key: value`,
+ expectedFiles: map[string]string{
+ "templates/configmap.yaml": `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+data:
+ key: value
+`,
+ },
+ },
+ {
+ name: "multiple manifests with different filenames",
+ input: `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==`,
+ expectedFiles: 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==
+`,
+ },
+ },
+ {
+ name: "multiple manifests with same filename",
+ input: `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`,
+ expectedFiles: 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
+`,
+ },
+ },
+ {
+ name: "manifest with other annotations",
+ input: `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+ annotations:
+ postrenderer.helm.sh/postrender-filename: templates/configmap.yaml
+ other-annotation: should-remain
+data:
+ key: value`,
+ expectedFiles: map[string]string{
+ "templates/configmap.yaml": `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+ annotations:
+ other-annotation: should-remain
+data:
+ key: value
+`,
+ },
+ },
+ {
+ name: "invalid yaml input",
+ input: "invalid: yaml: content:",
+ expectedError: "error parsing YAML: MalformedYAMLError",
+ },
+ {
+ name: "manifest without filename annotation",
+ input: `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+data:
+ key: value`,
+ expectedFiles: map[string]string{
+ "generated-by-postrender-0.yaml": `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-cm
+data:
+ key: value
+`,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ files, err := splitAndDeannotate(tt.input)
+
+ 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))
+
+ for expectedFile, expectedContent := range tt.expectedFiles {
+ actualContent, exists := files[expectedFile]
+ assert.True(t, exists, "Expected file %s not found", expectedFile)
+ assert.Equal(t, expectedContent, actualContent)
+ }
+ }
+ })
+ }
+}
+
+func TestAnnotateAndMerge_SplitAndDeannotate_Roundtrip(t *testing.T) {
+ // Test that merge/split operations are symmetric
+ originalFiles := 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==`,
+ "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`,
+ }
+
+ // Merge and annotate
+ merged, err := annotateAndMerge(originalFiles)
+ require.NoError(t, err)
+
+ // Split and deannotate
+ reconstructed, err := splitAndDeannotate(merged)
+ require.NoError(t, err)
+
+ // Compare the results
+ assert.Equal(t, len(originalFiles), len(reconstructed))
+ for filename, originalContent := range originalFiles {
+ reconstructedContent, exists := reconstructed[filename]
+ assert.True(t, exists, "File %s should exist in reconstructed files", filename)
+
+ // Normalize whitespace for comparison since YAML processing might affect formatting
+ normalizeContent := func(content string) string {
+ return strings.TrimSpace(strings.ReplaceAll(content, "\r\n", "\n"))
+ }
+
+ assert.Equal(t, normalizeContent(originalContent), normalizeContent(reconstructedContent))
+ }
+}
+
+func TestRenderResources_PostRenderer_Success(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ // Create a simple mock post-renderer
+ mockPR := &mockPostRenderer{
+ transform: func(content string) string {
+ content = strings.ReplaceAll(content, "hello", "yellow")
+ content = strings.ReplaceAll(content, "goodbye", "foodpie")
+ return strings.ReplaceAll(content, "test-cm", "test-cm-postrendered")
+ },
+ }
+
+ ch := buildChart(withSampleTemplates())
+ values := map[string]interface{}{}
+
+ hooks, buf, notes, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ mockPR, false, false, false,
+ )
+
+ assert.NoError(t, err)
+ assert.NotNil(t, hooks)
+ assert.NotNil(t, buf)
+ assert.Equal(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:
+ name: test-cm-postrendered
+ annotations:
+ "helm.sh/hook": post-install,pre-delete,post-upgrade
+data:
+ name: value`
+
+ assert.Equal(t, expectedBuf, buf.String())
+ assert.Len(t, hooks, 1)
+ assert.Equal(t, expectedHook, hooks[0].Manifest)
+}
+
+func TestRenderResources_PostRenderer_Error(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ // Create a post-renderer that returns an error
+ mockPR := &mockPostRenderer{
+ shouldError: true,
+ }
+
+ ch := buildChart(withSampleTemplates())
+ values := map[string]interface{}{}
+
+ _, _, _, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ mockPR, false, false, false,
+ )
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "error while running post render on files")
+}
+
+func TestRenderResources_PostRenderer_MergeError(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ // Create a mock post-renderer
+ mockPR := &mockPostRenderer{}
+
+ // Create a chart with invalid YAML that would cause AnnotateAndMerge to fail
+ ch := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: "v1",
+ Name: "test-chart",
+ Version: "0.1.0",
+ },
+ Templates: []*chart.File{
+ {Name: "templates/invalid", Data: []byte("invalid: yaml: content:")},
+ },
+ }
+ values := map[string]interface{}{}
+
+ _, _, _, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ mockPR, false, false, false,
+ )
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "error merging manifests")
+}
+
+func TestRenderResources_PostRenderer_SplitError(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ // Create a post-renderer that returns invalid YAML
+ mockPR := &mockPostRenderer{
+ transform: func(_ string) string {
+ return "invalid: yaml: content:"
+ },
+ }
+
+ ch := buildChart(withSampleTemplates())
+ values := map[string]interface{}{}
+
+ _, _, _, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ mockPR, false, false, false,
+ )
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "error while parsing post rendered output: error parsing YAML: MalformedYAMLError:")
+}
+
+func TestRenderResources_PostRenderer_Integration(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ mockPR := &mockPostRenderer{
+ transform: func(content string) string {
+ return strings.ReplaceAll(content, "metadata:", "color: blue\nmetadata:")
+ },
+ }
+
+ ch := buildChart(withSampleTemplates())
+ values := map[string]interface{}{}
+
+ hooks, buf, notes, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ mockPR, false, false, false,
+ )
+
+ assert.NoError(t, err)
+ assert.NotNil(t, hooks)
+ assert.NotNil(t, buf)
+ assert.Equal(t, "", notes) // Notes should be empty for this test
+
+ // Verify that the post-renderer modifications are present in the output
+ output := buf.String()
+ expected := `---
+# 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"))
+ assert.Equal(t, expected, output)
+}
+
+func TestRenderResources_NoPostRenderer(t *testing.T) {
+ cfg := actionConfigFixture(t)
+
+ ch := buildChart(withSampleTemplates())
+ values := map[string]interface{}{}
+
+ hooks, buf, notes, err := cfg.renderResources(
+ ch, values, "test-release", "", false, false, false,
+ nil, false, false, false,
+ )
+
+ assert.NoError(t, err)
+ assert.NotNil(t, hooks)
+ assert.NotNil(t, buf)
+ assert.Equal(t, "", notes)
+}
diff --git a/pkg/action/dependency.go b/pkg/action/dependency.go
index 3265f1f17..03c370c8e 100644
--- a/pkg/action/dependency.go
+++ b/pkg/action/dependency.go
@@ -26,18 +26,25 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/gosuri/uitable"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
// Dependency is the action for building a given chart's dependency tree.
//
// It provides the implementation of 'helm dependency' and its respective subcommands.
type Dependency struct {
- Verify bool
- Keyring string
- SkipRefresh bool
- ColumnWidth uint
+ Verify bool
+ Keyring string
+ SkipRefresh bool
+ ColumnWidth uint
+ Username string
+ Password string
+ CertFile string
+ KeyFile string
+ CaFile string
+ InsecureSkipTLSverify bool
+ PlainHTTP bool
}
// NewDependency creates a new Dependency object with the given configuration.
diff --git a/pkg/action/dependency_test.go b/pkg/action/dependency_test.go
index c29587aec..5be7bf5a9 100644
--- a/pkg/action/dependency_test.go
+++ b/pkg/action/dependency_test.go
@@ -24,9 +24,9 @@ import (
"github.com/stretchr/testify/assert"
- "helm.sh/helm/v3/internal/test"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
+ "helm.sh/helm/v4/internal/test"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
func TestList(t *testing.T) {
diff --git a/pkg/action/get.go b/pkg/action/get.go
index f44b53307..dbe5f4cb3 100644
--- a/pkg/action/get.go
+++ b/pkg/action/get.go
@@ -17,7 +17,7 @@ limitations under the License.
package action
import (
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// Get is the action for checking a given release's information.
diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go
index ec096ae16..4cb77361a 100644
--- a/pkg/action/get_metadata.go
+++ b/pkg/action/get_metadata.go
@@ -16,7 +16,13 @@ limitations under the License.
package action
-import "time"
+import (
+ "sort"
+ "strings"
+ "time"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+)
// GetMetadata is the action for checking a given release's metadata.
//
@@ -32,10 +38,15 @@ type Metadata struct {
Chart string `json:"chart" yaml:"chart"`
Version string `json:"version" yaml:"version"`
AppVersion string `json:"appVersion" yaml:"appVersion"`
- Namespace string `json:"namespace" yaml:"namespace"`
- Revision int `json:"revision" yaml:"revision"`
- Status string `json:"status" yaml:"status"`
- DeployedAt string `json:"deployedAt" yaml:"deployedAt"`
+ // Annotations are fetched from the Chart.yaml file
+ Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"`
+ // Labels of the release which are stored in driver metadata fields storage
+ Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
+ Dependencies []*chart.Dependency `json:"dependencies,omitempty" yaml:"dependencies,omitempty"`
+ Namespace string `json:"namespace" yaml:"namespace"`
+ Revision int `json:"revision" yaml:"revision"`
+ Status string `json:"status" yaml:"status"`
+ DeployedAt string `json:"deployedAt" yaml:"deployedAt"`
}
// NewGetMetadata creates a new GetMetadata object with the given configuration.
@@ -57,13 +68,27 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) {
}
return &Metadata{
- Name: rel.Name,
- Chart: rel.Chart.Metadata.Name,
- Version: rel.Chart.Metadata.Version,
- AppVersion: rel.Chart.Metadata.AppVersion,
- Namespace: rel.Namespace,
- Revision: rel.Version,
- Status: rel.Info.Status.String(),
- DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339),
+ Name: rel.Name,
+ Chart: rel.Chart.Metadata.Name,
+ Version: rel.Chart.Metadata.Version,
+ AppVersion: rel.Chart.Metadata.AppVersion,
+ Dependencies: rel.Chart.Metadata.Dependencies,
+ Annotations: rel.Chart.Metadata.Annotations,
+ Labels: rel.Labels,
+ Namespace: rel.Namespace,
+ Revision: rel.Version,
+ Status: rel.Info.Status.String(),
+ DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339),
}, nil
}
+
+// FormattedDepNames formats metadata.dependencies names into a comma-separated list.
+func (m *Metadata) FormattedDepNames() string {
+ depsNames := make([]string, 0, len(m.Dependencies))
+ for _, dep := range m.Dependencies {
+ depsNames = append(depsNames, dep.Name)
+ }
+ sort.StringSlice(depsNames).Sort()
+
+ return strings.Join(depsNames, ",")
+}
diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go
new file mode 100644
index 000000000..6ceb34951
--- /dev/null
+++ b/pkg/action/get_metadata_test.go
@@ -0,0 +1,635 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package action
+
+import (
+ "errors"
+ "io"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
+)
+
+// unreachableKubeClient is a test client that always returns an error for IsReachable
+type unreachableKubeClient struct {
+ kubefake.PrintingKubeClient
+}
+
+func (u *unreachableKubeClient) IsReachable() error {
+ return errors.New("connection refused")
+}
+
+func TestNewGetMetadata(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ assert.NotNil(t, client)
+ assert.Equal(t, cfg, client.cfg)
+ assert.Equal(t, 0, client.Version)
+}
+
+func TestGetMetadata_Run_BasicMetadata(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.2.3",
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.Equal(t, "v1.2.3", result.AppVersion)
+ assert.Equal(t, "default", result.Namespace)
+ assert.Equal(t, 1, result.Revision)
+ assert.Equal(t, "deployed", result.Status)
+ assert.Equal(t, deployedTime.Format(time.RFC3339), result.DeployedAt)
+ assert.Empty(t, result.Dependencies)
+ assert.Empty(t, result.Annotations)
+}
+
+func TestGetMetadata_Run_WithDependencies(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ dependencies := []*chart.Dependency{
+ {
+ Name: "mysql",
+ Version: "8.0.25",
+ Repository: "https://charts.bitnami.com/bitnami",
+ },
+ {
+ Name: "redis",
+ Version: "6.2.4",
+ Repository: "https://charts.bitnami.com/bitnami",
+ },
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.2.3",
+ Dependencies: dependencies,
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.Equal(t, dependencies, result.Dependencies)
+ assert.Len(t, result.Dependencies, 2)
+ assert.Equal(t, "mysql", result.Dependencies[0].Name)
+ assert.Equal(t, "redis", result.Dependencies[1].Name)
+}
+
+func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ dependencies := []*chart.Dependency{
+ {
+ Name: "mysql",
+ Version: "8.0.25",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Alias: "database",
+ },
+ {
+ Name: "redis",
+ Version: "6.2.4",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Alias: "cache",
+ },
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.2.3",
+ Dependencies: dependencies,
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.Equal(t, dependencies, result.Dependencies)
+ assert.Len(t, result.Dependencies, 2)
+ assert.Equal(t, "mysql", result.Dependencies[0].Name)
+ assert.Equal(t, "database", result.Dependencies[0].Alias)
+ assert.Equal(t, "redis", result.Dependencies[1].Name)
+ assert.Equal(t, "cache", result.Dependencies[1].Alias)
+}
+
+func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ dependencies := []*chart.Dependency{
+ {
+ Name: "mysql",
+ Version: "8.0.25",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Alias: "database",
+ },
+ {
+ Name: "nginx",
+ Version: "1.20.0",
+ Repository: "https://charts.bitnami.com/bitnami",
+ },
+ {
+ Name: "redis",
+ Version: "6.2.4",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Alias: "cache",
+ },
+ {
+ Name: "postgresql",
+ Version: "11.0.0",
+ Repository: "https://charts.bitnami.com/bitnami",
+ },
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.2.3",
+ Dependencies: dependencies,
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, "1.0.0", result.Version)
+ assert.Equal(t, dependencies, result.Dependencies)
+ assert.Len(t, result.Dependencies, 4)
+
+ // Verify dependencies with aliases
+ assert.Equal(t, "mysql", result.Dependencies[0].Name)
+ assert.Equal(t, "database", result.Dependencies[0].Alias)
+ assert.Equal(t, "redis", result.Dependencies[2].Name)
+ assert.Equal(t, "cache", result.Dependencies[2].Alias)
+
+ // Verify dependencies without aliases
+ assert.Equal(t, "nginx", result.Dependencies[1].Name)
+ assert.Equal(t, "", result.Dependencies[1].Alias)
+ assert.Equal(t, "postgresql", result.Dependencies[3].Name)
+ assert.Equal(t, "", result.Dependencies[3].Alias)
+}
+
+func TestGetMetadata_Run_WithAnnotations(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ annotations := map[string]string{
+ "helm.sh/hook": "pre-install",
+ "helm.sh/hook-weight": "5",
+ "custom.annotation": "test-value",
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.2.3",
+ Annotations: annotations,
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, annotations, result.Annotations)
+ assert.Equal(t, "pre-install", result.Annotations["helm.sh/hook"])
+ assert.Equal(t, "5", result.Annotations["helm.sh/hook-weight"])
+ assert.Equal(t, "test-value", result.Annotations["custom.annotation"])
+}
+
+func TestGetMetadata_Run_SpecificVersion(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+ client.Version = 2
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ rel1 := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusSuperseded,
+ LastDeployed: helmtime.Time{Time: deployedTime.Time.Add(-time.Hour)},
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.0.0",
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ rel2 := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.1.0",
+ AppVersion: "v1.1.0",
+ },
+ },
+ Version: 2,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel1)
+ cfg.Releases.Create(rel2)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, releaseName, result.Name)
+ assert.Equal(t, "test-chart", result.Chart)
+ assert.Equal(t, "1.1.0", result.Version)
+ assert.Equal(t, "v1.1.0", result.AppVersion)
+ assert.Equal(t, 2, result.Revision)
+ assert.Equal(t, "deployed", result.Status)
+}
+
+func TestGetMetadata_Run_DifferentStatuses(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ testCases := []struct {
+ name string
+ status release.Status
+ expected string
+ }{
+ {"deployed", release.StatusDeployed, "deployed"},
+ {"failed", release.StatusFailed, "failed"},
+ {"uninstalled", release.StatusUninstalled, "uninstalled"},
+ {"pending-install", release.StatusPendingInstall, "pending-install"},
+ {"pending-upgrade", release.StatusPendingUpgrade, "pending-upgrade"},
+ {"pending-rollback", release.StatusPendingRollback, "pending-rollback"},
+ {"superseded", release.StatusSuperseded, "superseded"},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ releaseName := "test-release-" + tc.name
+ deployedTime := helmtime.Now()
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: tc.status,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "v1.0.0",
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.expected, result.Status)
+ })
+ }
+}
+
+func TestGetMetadata_Run_UnreachableKubeClient(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ cfg.KubeClient = &unreachableKubeClient{
+ PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard},
+ }
+
+ client := NewGetMetadata(cfg)
+
+ _, err := client.Run("test-release")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "connection refused")
+}
+
+func TestGetMetadata_Run_ReleaseNotFound(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ _, err := client.Run("non-existent-release")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetMetadata(cfg)
+
+ releaseName := "test-release"
+ deployedTime := helmtime.Now()
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: deployedTime,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ AppVersion: "", // Empty app version
+ },
+ },
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+
+ assert.Equal(t, "", result.AppVersion)
+}
+
+func TestMetadata_FormattedDepNames(t *testing.T) {
+ testCases := []struct {
+ name string
+ dependencies []*chart.Dependency
+ expected string
+ }{
+ {
+ name: "no dependencies",
+ dependencies: []*chart.Dependency{},
+ expected: "",
+ },
+ {
+ name: "single dependency",
+ dependencies: []*chart.Dependency{
+ {Name: "mysql"},
+ },
+ expected: "mysql",
+ },
+ {
+ name: "multiple dependencies sorted",
+ dependencies: []*chart.Dependency{
+ {Name: "redis"},
+ {Name: "mysql"},
+ {Name: "nginx"},
+ },
+ expected: "mysql,nginx,redis",
+ },
+ {
+ name: "already sorted dependencies",
+ dependencies: []*chart.Dependency{
+ {Name: "apache"},
+ {Name: "mysql"},
+ {Name: "zookeeper"},
+ },
+ expected: "apache,mysql,zookeeper",
+ },
+ {
+ name: "duplicate names",
+ dependencies: []*chart.Dependency{
+ {Name: "mysql"},
+ {Name: "redis"},
+ {Name: "mysql"},
+ },
+ expected: "mysql,mysql,redis",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ metadata := &Metadata{
+ Dependencies: tc.dependencies,
+ }
+
+ result := metadata.FormattedDepNames()
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestMetadata_FormattedDepNames_WithComplexDependencies(t *testing.T) {
+ dependencies := []*chart.Dependency{
+ {
+ Name: "zookeeper",
+ Version: "10.0.0",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Condition: "zookeeper.enabled",
+ },
+ {
+ Name: "apache",
+ Version: "9.0.0",
+ Repository: "https://charts.bitnami.com/bitnami",
+ },
+ {
+ Name: "mysql",
+ Version: "8.0.25",
+ Repository: "https://charts.bitnami.com/bitnami",
+ Condition: "mysql.enabled",
+ },
+ }
+
+ metadata := &Metadata{
+ Dependencies: dependencies,
+ }
+
+ result := metadata.FormattedDepNames()
+ assert.Equal(t, "apache,mysql,zookeeper", result)
+}
+
+func TestMetadata_FormattedDepNames_WithAliases(t *testing.T) {
+ testCases := []struct {
+ name string
+ dependencies []*chart.Dependency
+ expected string
+ }{
+ {
+ name: "dependencies with aliases",
+ dependencies: []*chart.Dependency{
+ {Name: "mysql", Alias: "database"},
+ {Name: "redis", Alias: "cache"},
+ },
+ expected: "mysql,redis",
+ },
+ {
+ name: "mixed dependencies with and without aliases",
+ dependencies: []*chart.Dependency{
+ {Name: "mysql", Alias: "database"},
+ {Name: "nginx"},
+ {Name: "redis", Alias: "cache"},
+ },
+ expected: "mysql,nginx,redis",
+ },
+ {
+ name: "empty alias should use name",
+ dependencies: []*chart.Dependency{
+ {Name: "mysql", Alias: ""},
+ {Name: "redis", Alias: "cache"},
+ },
+ expected: "mysql,redis",
+ },
+ {
+ name: "sorted by name not alias",
+ dependencies: []*chart.Dependency{
+ {Name: "zookeeper", Alias: "a-service"},
+ {Name: "apache", Alias: "z-service"},
+ },
+ expected: "apache,zookeeper",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ metadata := &Metadata{
+ Dependencies: tc.dependencies,
+ }
+
+ result := metadata.FormattedDepNames()
+ assert.Equal(t, tc.expected, result)
+ })
+ }
+}
+
+func TestGetMetadata_Labels(t *testing.T) {
+ rel := releaseStub()
+ rel.Info.Status = release.StatusDeployed
+ customLabels := map[string]string{"key1": "value1", "key2": "value2"}
+ rel.Labels = customLabels
+
+ metaGetter := NewGetMetadata(actionConfigFixture(t))
+ err := metaGetter.cfg.Releases.Create(rel)
+ assert.NoError(t, err)
+
+ metadata, err := metaGetter.Run(rel.Name)
+ assert.NoError(t, err)
+
+ assert.Equal(t, metadata.Name, rel.Name)
+ assert.Equal(t, metadata.Labels, customLabels)
+}
diff --git a/pkg/action/get_values.go b/pkg/action/get_values.go
index 9c32db213..18b8b4838 100644
--- a/pkg/action/get_values.go
+++ b/pkg/action/get_values.go
@@ -17,7 +17,7 @@ limitations under the License.
package action
import (
- "helm.sh/helm/v3/pkg/chartutil"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
// GetValues is the action for checking a given release's values.
diff --git a/pkg/action/get_values_test.go b/pkg/action/get_values_test.go
new file mode 100644
index 000000000..ec785b5c7
--- /dev/null
+++ b/pkg/action/get_values_test.go
@@ -0,0 +1,218 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package action
+
+import (
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+)
+
+func TestNewGetValues(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+
+ assert.NotNil(t, client)
+ assert.Equal(t, cfg, client.cfg)
+ assert.Equal(t, 0, client.Version)
+ assert.Equal(t, false, client.AllValues)
+}
+
+func TestGetValues_Run_UserConfigOnly(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+
+ releaseName := "test-release"
+ userConfig := map[string]interface{}{
+ "database": map[string]interface{}{
+ "host": "localhost",
+ "port": 5432,
+ },
+ "app": map[string]interface{}{
+ "name": "my-app",
+ "replicas": 3,
+ },
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ },
+ Values: map[string]interface{}{
+ "defaultKey": "defaultValue",
+ "app": map[string]interface{}{
+ "name": "default-app",
+ "timeout": 30,
+ },
+ },
+ },
+ Config: userConfig,
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+ assert.Equal(t, userConfig, result)
+}
+
+func TestGetValues_Run_AllValues(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+ client.AllValues = true
+
+ releaseName := "test-release"
+ userConfig := map[string]interface{}{
+ "database": map[string]interface{}{
+ "host": "localhost",
+ "port": 5432,
+ },
+ "app": map[string]interface{}{
+ "name": "my-app",
+ },
+ }
+
+ chartDefaultValues := map[string]interface{}{
+ "defaultKey": "defaultValue",
+ "app": map[string]interface{}{
+ "name": "default-app",
+ "timeout": 30,
+ },
+ }
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ },
+ Values: chartDefaultValues,
+ },
+ Config: userConfig,
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ 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, "defaultValue", result["defaultKey"])
+ assert.Equal(t, "localhost", result["database"].(map[string]interface{})["host"])
+ assert.Equal(t, 5432, result["database"].(map[string]interface{})["port"])
+}
+
+func TestGetValues_Run_EmptyValues(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+
+ releaseName := "test-release"
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ },
+ },
+ Config: map[string]interface{}{},
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+ assert.Equal(t, map[string]interface{}{}, result)
+}
+
+func TestGetValues_Run_UnreachableKubeClient(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ cfg.KubeClient = &unreachableKubeClient{
+ PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard},
+ }
+
+ client := NewGetValues(cfg)
+
+ _, err := client.Run("test-release")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "connection refused")
+}
+
+func TestGetValues_Run_ReleaseNotFound(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+
+ _, err := client.Run("non-existent-release")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "not found")
+}
+
+func TestGetValues_Run_NilConfig(t *testing.T) {
+ cfg := actionConfigFixture(t)
+ client := NewGetValues(cfg)
+
+ releaseName := "test-release"
+
+ rel := &release.Release{
+ Name: releaseName,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "test-chart",
+ Version: "1.0.0",
+ },
+ },
+ Config: nil,
+ Version: 1,
+ Namespace: "default",
+ }
+
+ cfg.Releases.Create(rel)
+
+ result, err := client.Run(releaseName)
+ require.NoError(t, err)
+ assert.Nil(t, result)
+}
diff --git a/pkg/action/history.go b/pkg/action/history.go
index 0430aaf7a..d7af1d6a4 100644
--- a/pkg/action/history.go
+++ b/pkg/action/history.go
@@ -17,10 +17,12 @@ limitations under the License.
package action
import (
- "github.com/pkg/errors"
+ "log/slog"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
+ "fmt"
+
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// History is the action for checking the release's ledger.
@@ -50,9 +52,9 @@ func (h *History) Run(name string) ([]*release.Release, error) {
}
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, errors.Errorf("release name is invalid: %s", name)
+ return nil, fmt.Errorf("release name is invalid: %s", name)
}
- h.cfg.Log("getting history for release %s", name)
+ slog.Debug("getting history for release", "release", name)
return h.cfg.Releases.History(name)
}
diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go
index 40c1ffdb6..275a1bf52 100644
--- a/pkg/action/hooks.go
+++ b/pkg/action/hooks.go
@@ -17,17 +17,23 @@ package action
import (
"bytes"
+ "fmt"
+ "log"
+ "slices"
"sort"
"time"
- "github.com/pkg/errors"
+ "helm.sh/helm/v4/pkg/kube"
- "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ "go.yaml.in/yaml/v3"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
// execHook executes all of the hooks for the given hook event.
-func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, timeout time.Duration) error {
+func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, timeout time.Duration) error {
executingHooks := []*release.Hook{}
for _, h := range rl.Hooks {
@@ -41,23 +47,17 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent,
// hooke are pre-ordered by kind, so keep order stable
sort.Stable(hookByWeight(executingHooks))
- for _, h := range executingHooks {
+ for i, h := range executingHooks {
// Set default delete policy to before-hook-creation
- if h.DeletePolicies == nil || len(h.DeletePolicies) == 0 {
- // TODO(jlegrone): Only apply before-hook-creation delete policy to run to completion
- // resources. For all other resource types update in place if a
- // resource with the same name already exists and is owned by the
- // current release.
- h.DeletePolicies = []release.HookDeletePolicy{release.HookBeforeHookCreation}
- }
+ cfg.hookSetDeletePolicy(h)
- if err := cfg.deleteHookByPolicy(h, release.HookBeforeHookCreation); err != nil {
+ if err := cfg.deleteHookByPolicy(h, release.HookBeforeHookCreation, waitStrategy, timeout); err != nil {
return err
}
resources, err := cfg.KubeClient.Build(bytes.NewBufferString(h.Manifest), true)
if err != nil {
- return errors.Wrapf(err, "unable to build kubernetes object for %s hook %s", hook, h.Path)
+ return fmt.Errorf("unable to build kubernetes object for %s hook %s: %w", hook, h.Path, err)
}
// Record the time at which the hook was applied to the cluster
@@ -73,33 +73,57 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent,
h.LastRun.Phase = release.HookPhaseUnknown
// Create hook resources
- if _, err := cfg.KubeClient.Create(resources); err != nil {
+ if _, err := cfg.KubeClient.Create(
+ resources,
+ kube.ClientCreateOptionServerSideApply(false, false)); err != nil {
h.LastRun.CompletedAt = helmtime.Now()
h.LastRun.Phase = release.HookPhaseFailed
- return errors.Wrapf(err, "warning: Hook %s %s failed", hook, h.Path)
+ return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err)
}
+ waiter, err := cfg.KubeClient.GetWaiter(waitStrategy)
+ if err != nil {
+ return fmt.Errorf("unable to get waiter: %w", err)
+ }
// Watch hook resources until they have completed
- err = cfg.KubeClient.WatchUntilReady(resources, timeout)
+ err = waiter.WatchUntilReady(resources, timeout)
// Note the time of success/failure
h.LastRun.CompletedAt = helmtime.Now()
// Mark hook as succeeded or failed
if err != nil {
h.LastRun.Phase = release.HookPhaseFailed
+ // If a hook is failed, check the annotation of the hook to determine if we should copy the logs client side
+ if errOutputting := cfg.outputLogsByPolicy(h, rl.Namespace, release.HookOutputOnFailed); errOutputting != nil {
+ // We log the error here as we want to propagate the hook failure upwards to the release object.
+ log.Printf("error outputting logs for hook failure: %v", errOutputting)
+ }
// If a hook is failed, check the annotation of the hook to determine whether the hook should be deleted
// under failed condition. If so, then clear the corresponding resource object in the hook
- if err := cfg.deleteHookByPolicy(h, release.HookFailed); err != nil {
+ if errDeleting := cfg.deleteHookByPolicy(h, release.HookFailed, waitStrategy, timeout); errDeleting != nil {
+ // We log the error here as we want to propagate the hook failure upwards to the release object.
+ log.Printf("error deleting the hook resource on hook failure: %v", errDeleting)
+ }
+
+ // If a hook is failed, check the annotation of the previous successful hooks to determine whether the hooks
+ // should be deleted under succeeded condition.
+ if err := cfg.deleteHooksByPolicy(executingHooks[0:i], release.HookSucceeded, waitStrategy, timeout); err != nil {
return err
}
+
return err
}
h.LastRun.Phase = release.HookPhaseSucceeded
}
// If all hooks are successful, check the annotation of each hook to determine whether the hook should be deleted
- // under succeeded condition. If so, then clear the corresponding resource object in each hook
- for _, h := range executingHooks {
- if err := cfg.deleteHookByPolicy(h, release.HookSucceeded); err != nil {
+ // 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]
+ 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)
+ }
+ if err := cfg.deleteHookByPolicy(h, release.HookSucceeded, waitStrategy, timeout); err != nil {
return err
}
}
@@ -120,32 +144,116 @@ 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) error {
+func (cfg *Configuration) deleteHookByPolicy(h *release.Hook, policy release.HookDeletePolicy, waitStrategy kube.WaitStrategy, timeout time.Duration) error {
// Never delete CustomResourceDefinitions; this could cause lots of
// cascading garbage collection.
if h.Kind == "CustomResourceDefinition" {
return nil
}
- if hookHasDeletePolicy(h, policy) {
+ if cfg.hookHasDeletePolicy(h, policy) {
resources, err := cfg.KubeClient.Build(bytes.NewBufferString(h.Manifest), false)
if err != nil {
- return errors.Wrapf(err, "unable to build kubernetes object for deleting hook %s", h.Path)
+ return fmt.Errorf("unable to build kubernetes object for deleting hook %s: %w", h.Path, err)
}
_, errs := cfg.KubeClient.Delete(resources)
if len(errs) > 0 {
- return errors.New(joinErrors(errs))
+ return joinErrors(errs, "; ")
+ }
+
+ waiter, err := cfg.KubeClient.GetWaiter(waitStrategy)
+ if err != nil {
+ return err
+ }
+ if err := waiter.WaitForDelete(resources, timeout); err != nil {
+ return err
}
}
return nil
}
+// deleteHooksByPolicy deletes all hooks if the hook policy instructs it to
+func (cfg *Configuration) deleteHooksByPolicy(hooks []*release.Hook, policy release.HookDeletePolicy, waitStrategy kube.WaitStrategy, timeout time.Duration) error {
+ for _, h := range hooks {
+ if err := cfg.deleteHookByPolicy(h, policy, waitStrategy, timeout); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// hookHasDeletePolicy determines whether the defined hook deletion policy matches the hook deletion polices
// supported by helm. If so, mark the hook as one should be deleted.
-func hookHasDeletePolicy(h *release.Hook, policy release.HookDeletePolicy) bool {
- for _, v := range h.DeletePolicies {
- if policy == v {
- return true
+func (cfg *Configuration) hookHasDeletePolicy(h *release.Hook, policy release.HookDeletePolicy) bool {
+ cfg.mutex.Lock()
+ defer cfg.mutex.Unlock()
+ return slices.Contains(h.DeletePolicies, policy)
+}
+
+// hookSetDeletePolicy determines whether the defined hook deletion policy matches the hook deletion polices
+// supported by helm. If so, mark the hook as one should be deleted.
+func (cfg *Configuration) hookSetDeletePolicy(h *release.Hook) {
+ cfg.mutex.Lock()
+ defer cfg.mutex.Unlock()
+ if len(h.DeletePolicies) == 0 {
+ // TODO(jlegrone): Only apply before-hook-creation delete policy to run to completion
+ // resources. For all other resource types update in place if a
+ // resource with the same name already exists and is owned by the
+ // current release.
+ h.DeletePolicies = []release.HookDeletePolicy{release.HookBeforeHookCreation}
+ }
+}
+
+// outputLogsByPolicy outputs a pods logs if the hook policy instructs it to
+func (cfg *Configuration) outputLogsByPolicy(h *release.Hook, releaseNamespace string, policy release.HookOutputLogPolicy) error {
+ if !hookHasOutputLogPolicy(h, policy) {
+ return nil
+ }
+ namespace, err := cfg.deriveNamespace(h, releaseNamespace)
+ if err != nil {
+ return err
+ }
+ switch h.Kind {
+ case "Job":
+ return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{LabelSelector: fmt.Sprintf("job-name=%s", h.Name)})
+ case "Pod":
+ return cfg.outputContainerLogsForListOptions(namespace, metav1.ListOptions{FieldSelector: fmt.Sprintf("metadata.name=%s", h.Name)})
+ default:
+ return nil
+ }
+}
+
+func (cfg *Configuration) outputContainerLogsForListOptions(namespace string, listOptions metav1.ListOptions) error {
+ // TODO Helm 4: Remove this check when GetPodList and OutputContainerLogsForPodList are moved from InterfaceLogs to Interface
+ if kubeClient, ok := cfg.KubeClient.(kube.InterfaceLogs); ok {
+ podList, err := kubeClient.GetPodList(namespace, listOptions)
+ if err != nil {
+ return err
}
+ err = kubeClient.OutputContainerLogsForPodList(podList, namespace, cfg.HookOutputFunc)
+ return err
}
- return false
+ return nil
+}
+
+func (cfg *Configuration) deriveNamespace(h *release.Hook, namespace string) (string, error) {
+ tmp := struct {
+ Metadata struct {
+ Namespace string
+ }
+ }{}
+ err := yaml.Unmarshal([]byte(h.Manifest), &tmp)
+ if err != nil {
+ return "", fmt.Errorf("unable to parse metadata.namespace from kubernetes manifest for output logs hook %s: %w", h.Path, err)
+ }
+ if tmp.Metadata.Namespace == "" {
+ return namespace, nil
+ }
+ return tmp.Metadata.Namespace, nil
+}
+
+// hookHasOutputLogPolicy determines whether the defined hook output log policy matches the hook output log policies
+// supported by helm.
+func hookHasOutputLogPolicy(h *release.Hook, policy release.HookOutputLogPolicy) bool {
+ return slices.Contains(h.OutputLogPolicies, policy)
}
diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go
new file mode 100644
index 000000000..ad1de2c59
--- /dev/null
+++ b/pkg/action/hooks_test.go
@@ -0,0 +1,403 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package action
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/util/yaml"
+ "k8s.io/cli-runtime/pkg/resource"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage"
+ "helm.sh/helm/v4/pkg/storage/driver"
+)
+
+func podManifestWithOutputLogs(hookDefinitions []release.HookOutputLogPolicy) string {
+ hookDefinitionString := convertHooksToCommaSeparated(hookDefinitions)
+ return fmt.Sprintf(`kind: Pod
+metadata:
+ name: finding-sharky,
+ annotations:
+ "helm.sh/hook": pre-install
+ "helm.sh/hook-output-log-policy": %s
+spec:
+ containers:
+ - name: sharky-test
+ image: fake-image
+ cmd: fake-command`, hookDefinitionString)
+}
+
+func podManifestWithOutputLogWithNamespace(hookDefinitions []release.HookOutputLogPolicy) string {
+ hookDefinitionString := convertHooksToCommaSeparated(hookDefinitions)
+ return fmt.Sprintf(`kind: Pod
+metadata:
+ name: finding-george
+ namespace: sneaky-namespace
+ annotations:
+ "helm.sh/hook": pre-install
+ "helm.sh/hook-output-log-policy": %s
+spec:
+ containers:
+ - name: george-test
+ image: fake-image
+ cmd: fake-command`, hookDefinitionString)
+}
+
+func jobManifestWithOutputLog(hookDefinitions []release.HookOutputLogPolicy) string {
+ hookDefinitionString := convertHooksToCommaSeparated(hookDefinitions)
+ return fmt.Sprintf(`kind: Job
+apiVersion: batch/v1
+metadata:
+ name: losing-religion
+ annotations:
+ "helm.sh/hook": pre-install
+ "helm.sh/hook-output-log-policy": %s
+spec:
+ completions: 1
+ parallelism: 1
+ activeDeadlineSeconds: 30
+ template:
+ spec:
+ containers:
+ - name: religion-container
+ image: religion-image
+ cmd: religion-command`, hookDefinitionString)
+}
+
+func jobManifestWithOutputLogWithNamespace(hookDefinitions []release.HookOutputLogPolicy) string {
+ hookDefinitionString := convertHooksToCommaSeparated(hookDefinitions)
+ return fmt.Sprintf(`kind: Job
+apiVersion: batch/v1
+metadata:
+ name: losing-religion
+ namespace: rem-namespace
+ annotations:
+ "helm.sh/hook": pre-install
+ "helm.sh/hook-output-log-policy": %s
+spec:
+ completions: 1
+ parallelism: 1
+ activeDeadlineSeconds: 30
+ template:
+ spec:
+ containers:
+ - name: religion-container
+ image: religion-image
+ cmd: religion-command`, hookDefinitionString)
+}
+
+func convertHooksToCommaSeparated(hookDefinitions []release.HookOutputLogPolicy) string {
+ var commaSeparated string
+ for i, policy := range hookDefinitions {
+ if i+1 == len(hookDefinitions) {
+ commaSeparated += policy.String()
+ } else {
+ commaSeparated += policy.String() + ","
+ }
+ }
+ return commaSeparated
+}
+
+func TestInstallRelease_HookOutputLogsOnFailure(t *testing.T) {
+ // Should output on failure with expected namespace if hook-failed is set
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "sneaky-namespace", true)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "rem-namespace", true)
+
+ // Should not output on failure with expected namespace if hook-succeed is set
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "", false)
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "", false)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "", false)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "", false)
+}
+
+func TestInstallRelease_HookOutputLogsOnSuccess(t *testing.T) {
+ // Should output on success with expected namespace if hook-succeeded is set
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "spaced", true)
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "sneaky-namespace", true)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "spaced", true)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded}), "rem-namespace", true)
+
+ // Should not output on success if hook-failed is set
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "", false)
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "", false)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "", false)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnFailed}), "", false)
+}
+
+func TestInstallRelease_HooksOutputLogsOnSuccessAndFailure(t *testing.T) {
+ // Should output on success with expected namespace if hook-succeeded and hook-failed is set
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithSuccess(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "sneaky-namespace", true)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithSuccess(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "rem-namespace", true)
+
+ // Should output on failure if hook-succeeded and hook-failed is set
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogs([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithFailure(t, podManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "sneaky-namespace", true)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLog([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "spaced", true)
+ runInstallForHooksWithFailure(t, jobManifestWithOutputLogWithNamespace([]release.HookOutputLogPolicy{release.HookOutputOnSucceeded, release.HookOutputOnFailed}), "rem-namespace", true)
+}
+
+func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace string, shouldOutput bool) {
+ t.Helper()
+ var expectedOutput string
+ if shouldOutput {
+ expectedOutput = fmt.Sprintf("attempted to output logs for namespace: %s", expectedNamespace)
+ }
+ is := assert.New(t)
+ instAction := installAction(t)
+ instAction.ReleaseName = "failed-hooks"
+ outBuffer := &bytes.Buffer{}
+ instAction.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
+
+ templates := []*chart.File{
+ {Name: "templates/hello", Data: []byte("hello: world")},
+ {Name: "templates/hooks", Data: []byte(manifest)},
+ }
+ vals := map[string]interface{}{}
+
+ res, err := instAction.Run(buildChartWithTemplates(templates), vals)
+ is.NoError(err)
+ is.Equal(expectedOutput, outBuffer.String())
+ is.Equal(release.StatusDeployed, res.Info.Status)
+}
+
+func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace string, shouldOutput bool) {
+ t.Helper()
+ var expectedOutput string
+ if shouldOutput {
+ expectedOutput = fmt.Sprintf("attempted to output logs for namespace: %s", expectedNamespace)
+ }
+ is := assert.New(t)
+ instAction := installAction(t)
+ instAction.ReleaseName = "failed-hooks"
+ failingClient := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
+ failingClient.WatchUntilReadyError = fmt.Errorf("failed watch")
+ instAction.cfg.KubeClient = failingClient
+ outBuffer := &bytes.Buffer{}
+ failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
+
+ templates := []*chart.File{
+ {Name: "templates/hello", Data: []byte("hello: world")},
+ {Name: "templates/hooks", Data: []byte(manifest)},
+ }
+ vals := map[string]interface{}{}
+
+ res, err := instAction.Run(buildChartWithTemplates(templates), vals)
+ is.Error(err)
+ is.Contains(res.Info.Description, "failed pre-install")
+ is.Equal(expectedOutput, outBuffer.String())
+ is.Equal(release.StatusFailed, res.Info.Status)
+}
+
+type HookFailedError struct{}
+
+func (e *HookFailedError) Error() string {
+ return "Hook failed!"
+}
+
+type HookFailingKubeClient struct {
+ kubefake.PrintingKubeClient
+ failOn resource.Info
+ deleteRecord []resource.Info
+}
+
+type HookFailingKubeWaiter struct {
+ *kubefake.PrintingKubeWaiter
+ failOn resource.Info
+}
+
+func (*HookFailingKubeClient) Build(reader io.Reader, _ bool) (kube.ResourceList, error) {
+ configMap := &v1.ConfigMap{}
+
+ err := yaml.NewYAMLOrJSONDecoder(reader, 1000).Decode(configMap)
+
+ if err != nil {
+ return kube.ResourceList{}, err
+ }
+
+ return kube.ResourceList{{
+ Name: configMap.Name,
+ Namespace: configMap.Namespace,
+ }}, nil
+}
+
+func (h *HookFailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error {
+ for _, res := range resources {
+ if res.Name == h.failOn.Name && res.Namespace == h.failOn.Namespace {
+ return &HookFailedError{}
+ }
+ }
+ return nil
+}
+
+func (h *HookFailingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, []error) {
+ for _, res := range resources {
+ h.deleteRecord = append(h.deleteRecord, resource.Info{
+ Name: res.Name,
+ Namespace: res.Namespace,
+ })
+ }
+
+ return h.PrintingKubeClient.Delete(resources)
+}
+
+func (h *HookFailingKubeClient) GetWaiter(strategy kube.WaitStrategy) (kube.Waiter, error) {
+ waiter, _ := h.PrintingKubeClient.GetWaiter(strategy)
+ return &HookFailingKubeWaiter{
+ PrintingKubeWaiter: waiter.(*kubefake.PrintingKubeWaiter),
+ failOn: h.failOn,
+ }, nil
+}
+
+func TestHooksCleanUp(t *testing.T) {
+ hookEvent := release.HookPreInstall
+
+ testCases := []struct {
+ name string
+ inputRelease release.Release
+ failOn resource.Info
+ expectedDeleteRecord []resource.Info
+ expectError bool
+ }{
+ {
+ "Deletion hook runs for previously successful hook on failure of a heavier weight hook",
+ release.Release{
+ Name: "test-release",
+ Namespace: "test",
+ Hooks: []*release.Hook{
+ {
+ Name: "hook-1",
+ Kind: "ConfigMap",
+ Path: "templates/service_account.yaml",
+ Manifest: `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: build-config-1
+ namespace: test
+data:
+ foo: bar
+`,
+ Weight: -5,
+ Events: []release.HookEvent{
+ hookEvent,
+ },
+ DeletePolicies: []release.HookDeletePolicy{
+ release.HookBeforeHookCreation,
+ release.HookSucceeded,
+ release.HookFailed,
+ },
+ LastRun: release.HookExecution{
+ Phase: release.HookPhaseSucceeded,
+ },
+ },
+ {
+ Name: "hook-2",
+ Kind: "ConfigMap",
+ Path: "templates/job.yaml",
+ Manifest: `apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: build-config-2
+ namespace: test
+data:
+ foo: bar
+`,
+ Weight: 0,
+ Events: []release.HookEvent{
+ hookEvent,
+ },
+ DeletePolicies: []release.HookDeletePolicy{
+ release.HookBeforeHookCreation,
+ release.HookSucceeded,
+ release.HookFailed,
+ },
+ LastRun: release.HookExecution{
+ Phase: release.HookPhaseFailed,
+ },
+ },
+ },
+ }, resource.Info{
+ Name: "build-config-2",
+ Namespace: "test",
+ }, []resource.Info{
+ {
+ // This should be in the record for `before-hook-creation`
+ Name: "build-config-1",
+ Namespace: "test",
+ },
+ {
+ // This should be in the record for `before-hook-creation`
+ Name: "build-config-2",
+ Namespace: "test",
+ },
+ {
+ // This should be in the record for cleaning up (the failure first)
+ Name: "build-config-2",
+ Namespace: "test",
+ },
+ {
+ // This should be in the record for cleaning up (then the previously successful)
+ Name: "build-config-1",
+ Namespace: "test",
+ },
+ }, true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ kubeClient := &HookFailingKubeClient{
+ kubefake.PrintingKubeClient{Out: io.Discard}, tc.failOn, []resource.Info{},
+ }
+
+ configuration := &Configuration{
+ Releases: storage.Init(driver.NewMemory()),
+ KubeClient: kubeClient,
+ Capabilities: chartutil.DefaultCapabilities,
+ }
+
+ err := configuration.execHook(&tc.inputRelease, hookEvent, kube.StatusWatcherStrategy, 600)
+
+ if !reflect.DeepEqual(kubeClient.deleteRecord, tc.expectedDeleteRecord) {
+ t.Fatalf("Got unexpected delete record, expected: %#v, but got: %#v", kubeClient.deleteRecord, tc.expectedDeleteRecord)
+ }
+
+ if err != nil && !tc.expectError {
+ t.Fatalf("Got an unexpected error.")
+ }
+
+ if err == nil && tc.expectError {
+ t.Fatalf("Expected and error but did not get it.")
+ }
+ })
+ }
+}
diff --git a/pkg/action/install.go b/pkg/action/install.go
index e3538a4f5..8f76eee7b 100644
--- a/pkg/action/install.go
+++ b/pkg/action/install.go
@@ -19,8 +19,11 @@ package action
import (
"bytes"
"context"
+ "errors"
"fmt"
"io"
+ "io/fs"
+ "log/slog"
"net/url"
"os"
"path"
@@ -31,7 +34,6 @@ import (
"time"
"github.com/Masterminds/sprig/v3"
- "github.com/pkg/errors"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
@@ -39,23 +41,23 @@ import (
"k8s.io/cli-runtime/pkg/resource"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/kube"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/postrender"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/storage"
- "helm.sh/helm/v3/pkg/storage/driver"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/kube"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ "helm.sh/helm/v4/pkg/postrender"
+ "helm.sh/helm/v4/pkg/registry"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/storage"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
-// NOTESFILE_SUFFIX that we want to treat special. It goes through the templating engine
+// notesFileSuffix that we want to treat special. It goes through the templating engine
// but it's not a yaml file (resource) hence can't have hooks, etc. And the user actually
// wants to see this file after rendering in the status command. However, it must be a suffix
// since there can be filepath in front of it.
@@ -69,27 +71,36 @@ type Install struct {
ChartPathOptions
- ClientOnly bool
- Force bool
- CreateNamespace bool
- DryRun bool
- DryRunOption string
- DisableHooks bool
- Replace bool
- Wait bool
- WaitForJobs bool
- Devel bool
- DependencyUpdate bool
- Timeout time.Duration
- Namespace string
- ReleaseName string
- GenerateName bool
- NameTemplate string
- Description string
- OutputDir string
- Atomic bool
+ ClientOnly bool
+ // ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway.
+ //
+ // This should be used with caution.
+ ForceReplace bool
+ CreateNamespace bool
+ DryRun bool
+ DryRunOption string
+ // HideSecret can be set to true when DryRun is enabled in order to hide
+ // Kubernetes Secrets in the output. It cannot be used outside of DryRun.
+ HideSecret bool
+ DisableHooks bool
+ Replace bool
+ WaitStrategy kube.WaitStrategy
+ WaitForJobs bool
+ Devel bool
+ DependencyUpdate bool
+ Timeout time.Duration
+ Namespace string
+ ReleaseName string
+ GenerateName bool
+ NameTemplate string
+ Description string
+ OutputDir string
+ // RollbackOnFailure enables rolling back (uninstalling) the release on failure if set
+ RollbackOnFailure bool
SkipCRDs bool
SubNotes bool
+ HideNotes bool
+ SkipSchemaValidation bool
DisableOpenAPIValidation bool
IncludeCRDs bool
Labels map[string]string
@@ -105,7 +116,9 @@ type Install struct {
// Used by helm template to add the release as part of OutputDir path
// OutputDir/
UseReleaseName bool
- PostRenderer postrender.PostRenderer
+ // TakeOwnership will ignore the check for helm annotations and take ownership of the resources.
+ TakeOwnership bool
+ PostRenderer postrender.PostRenderer
// Lock to control raceconditions when the process receives a SIGTERM
Lock sync.Mutex
}
@@ -135,19 +148,19 @@ func NewInstall(cfg *Configuration) *Install {
in := &Install{
cfg: cfg,
}
- in.ChartPathOptions.registryClient = cfg.RegistryClient
+ in.registryClient = cfg.RegistryClient
return in
}
// SetRegistryClient sets the registry client for the install action
func (i *Install) SetRegistryClient(registryClient *registry.Client) {
- i.ChartPathOptions.registryClient = registryClient
+ i.registryClient = registryClient
}
// GetRegistryClient get the registry client.
func (i *Install) GetRegistryClient() *registry.Client {
- return i.ChartPathOptions.registryClient
+ return i.registryClient
}
func (i *Install) installCRDs(crds []chart.CRD) error {
@@ -157,24 +170,30 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
// Read in the resources
res, err := i.cfg.KubeClient.Build(bytes.NewBuffer(obj.File.Data), false)
if err != nil {
- return errors.Wrapf(err, "failed to install CRD %s", obj.Name)
+ return fmt.Errorf("failed to install CRD %s: %w", obj.Name, err)
}
// Send them to Kube
- if _, err := i.cfg.KubeClient.Create(res); err != nil {
+ if _, err := i.cfg.KubeClient.Create(
+ res,
+ kube.ClientCreateOptionServerSideApply(false, false)); err != nil {
// If the error is CRD already exists, continue.
if apierrors.IsAlreadyExists(err) {
crdName := res[0].Name
- i.cfg.Log("CRD %s is already present. Skipping.", crdName)
+ slog.Debug("CRD is already present. Skipping", "crd", crdName)
continue
}
- return errors.Wrapf(err, "failed to install CRD %s", obj.Name)
+ return fmt.Errorf("failed to install CRD %s: %w", obj.Name, err)
}
totalItems = append(totalItems, res...)
}
if len(totalItems) > 0 {
+ waiter, err := i.cfg.KubeClient.GetWaiter(i.WaitStrategy)
+ if err != nil {
+ return fmt.Errorf("unable to get waiter: %w", err)
+ }
// Give time for the CRD to be recognized.
- if err := i.cfg.KubeClient.Wait(totalItems, 60*time.Second); err != nil {
+ if err := waiter.Wait(totalItems, 60*time.Second); err != nil {
return err
}
@@ -189,7 +208,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
return err
}
- i.cfg.Log("Clearing discovery cache")
+ slog.Debug("clearing discovery cache")
discoveryClient.Invalidate()
_, _ = discoveryClient.ServerGroups()
@@ -202,7 +221,7 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
return err
}
if resettable, ok := restMapper.(meta.ResettableRESTMapper); ok {
- i.cfg.Log("Clearing REST mapper cache")
+ slog.Debug("clearing REST mapper cache")
resettable.Reset()
}
}
@@ -218,7 +237,7 @@ func (i *Install) Run(chrt *chart.Chart, vals map[string]interface{}) (*release.
return i.RunWithContext(ctx, chrt, vals)
}
-// Run executes the installation with Context
+// RunWithContext executes the installation with Context
//
// When the task is cancelled through ctx, the function returns and the install
// proceeds in the background.
@@ -226,16 +245,25 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
// Check reachability of cluster unless in client-only mode (e.g. `helm template` without `--validate`)
if !i.ClientOnly {
if err := i.cfg.KubeClient.IsReachable(); err != nil {
- return nil, err
+ slog.Error(fmt.Sprintf("cluster reachability check failed: %v", err))
+ return nil, fmt.Errorf("cluster reachability check failed: %w", err)
}
}
+ // HideSecret must be used with dry run. Otherwise, return an error.
+ if !i.isDryRun() && i.HideSecret {
+ slog.Error("hiding Kubernetes secrets requires a dry-run mode")
+ return nil, errors.New("hiding Kubernetes secrets requires a dry-run mode")
+ }
+
if err := i.availableName(); err != nil {
- return nil, err
+ slog.Error("release name check failed", slog.Any("error", err))
+ return nil, fmt.Errorf("release name check failed: %w", err)
}
- if err := chartutil.ProcessDependenciesWithMerge(chrt, vals); err != nil {
- return nil, err
+ if err := chartutil.ProcessDependencies(chrt, vals); err != nil {
+ slog.Error("chart dependencies processing failed", slog.Any("error", err))
+ return nil, fmt.Errorf("chart dependencies processing failed: %w", err)
}
var interactWithRemote bool
@@ -248,7 +276,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
if crds := chrt.CRDObjects(); !i.ClientOnly && !i.SkipCRDs && len(crds) > 0 {
// On dry run, bail here
if i.isDryRun() {
- i.cfg.Log("WARNING: This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.")
+ slog.Warn("This chart or one of its subcharts contains CRDs. Rendering may fail or contain inaccuracies.")
} else if err := i.installCRDs(crds); err != nil {
return nil, err
}
@@ -268,12 +296,14 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
mem.SetNamespace(i.Namespace)
i.cfg.Releases = storage.Init(mem)
} else if !i.ClientOnly && len(i.APIVersions) > 0 {
- i.cfg.Log("API Version list given outside of client only mode, this list will be ignored")
+ slog.Debug("API Version list given outside of client only mode, this list will be ignored")
}
- // Make sure if Atomic is set, that wait is set as well. This makes it so
+ // Make sure if RollbackOnFailure is set, that wait is set as well. This makes it so
// the user doesn't have to specify both
- i.Wait = i.Wait || i.Atomic
+ if i.WaitStrategy == kube.HookOnlyStrategy && i.RollbackOnFailure {
+ i.WaitStrategy = kube.StatusWatcherStrategy
+ }
caps, err := i.cfg.getCapabilities()
if err != nil {
@@ -289,19 +319,19 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
IsInstall: !isUpgrade,
IsUpgrade: isUpgrade,
}
- valuesToRender, err := chartutil.ToRenderValues(chrt, vals, options, caps)
+ valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chrt, vals, options, caps, i.SkipSchemaValidation)
if err != nil {
return nil, err
}
if driver.ContainsSystemLabels(i.Labels) {
- return nil, fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
+ return nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
}
rel := i.createRelease(chrt, vals, i.Labels)
var manifestDoc *bytes.Buffer
- rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithRemote, i.EnableDNS)
+ rel.Hooks, manifestDoc, rel.Info.Notes, err = i.cfg.renderResources(chrt, valuesToRender, i.ReleaseName, i.OutputDir, i.SubNotes, i.UseReleaseName, i.IncludeCRDs, i.PostRenderer, interactWithRemote, i.EnableDNS, i.HideSecret)
// Even for errors, attach this if available
if manifestDoc != nil {
rel.Manifest = manifestDoc.String()
@@ -319,10 +349,10 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
var toBeAdopted kube.ResourceList
resources, err := i.cfg.KubeClient.Build(bytes.NewBufferString(rel.Manifest), !i.DisableOpenAPIValidation)
if err != nil {
- return nil, errors.Wrap(err, "unable to build kubernetes objects from release manifest")
+ return nil, fmt.Errorf("unable to build kubernetes objects from release manifest: %w", err)
}
- // It is safe to use "force" here because these are resources currently rendered by the chart.
+ // It is safe to use "forceOwnership" here because these are resources currently rendered by the chart.
err = resources.Visit(setMetadataVisitor(rel.Name, rel.Namespace, true))
if err != nil {
return nil, err
@@ -335,9 +365,13 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
// deleting the release because the manifest will be pointing at that
// resource
if !i.ClientOnly && !isUpgrade && len(resources) > 0 {
- toBeAdopted, err = existingResourceConflict(resources, rel.Name, rel.Namespace)
+ if i.TakeOwnership {
+ toBeAdopted, err = requireAdoption(resources)
+ } else {
+ toBeAdopted, err = existingResourceConflict(resources, rel.Name, rel.Namespace)
+ }
if err != nil {
- return nil, errors.Wrap(err, "Unable to continue with install")
+ return nil, fmt.Errorf("unable to continue with install: %w", err)
}
}
@@ -368,12 +402,14 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
if err != nil {
return nil, err
}
- if _, err := i.cfg.KubeClient.Create(resourceList); err != nil && !apierrors.IsAlreadyExists(err) {
+ if _, err := i.cfg.KubeClient.Create(
+ resourceList,
+ kube.ClientCreateOptionServerSideApply(false, false)); err != nil && !apierrors.IsAlreadyExists(err) {
return nil, err
}
}
- // If Replace is true, we need to supercede the last release.
+ // If Replace is true, we need to supersede the last release.
if i.Replace {
if err := i.replaceRelease(rel); err != nil {
return nil, err
@@ -428,36 +464,47 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
var err error
// pre-install hooks
if !i.DisableHooks {
- if err := i.cfg.execHook(rel, release.HookPreInstall, i.Timeout); err != nil {
+ if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.Timeout); err != nil {
return rel, fmt.Errorf("failed pre-install: %s", err)
}
}
// At this point, we can do the install. Note that before we were detecting whether to
- // do an update, but it's not clear whether we WANT to do an update if the re-use is set
+ // do an update, but it's not clear whether we WANT to do an update if the reuse is set
// to true, since that is basically an upgrade operation.
if len(toBeAdopted) == 0 && len(resources) > 0 {
- _, err = i.cfg.KubeClient.Create(resources)
+ _, err = i.cfg.KubeClient.Create(
+ resources,
+ kube.ClientCreateOptionServerSideApply(false, false))
} else if len(resources) > 0 {
- _, err = i.cfg.KubeClient.Update(toBeAdopted, resources, i.Force)
+ updateThreeWayMergeForUnstructured := i.TakeOwnership
+ _, err = i.cfg.KubeClient.Update(
+ toBeAdopted,
+ resources,
+ kube.ClientUpdateOptionServerSideApply(false, false),
+ kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured),
+ kube.ClientUpdateOptionForceReplace(i.ForceReplace))
}
if err != nil {
return rel, err
}
- if i.Wait {
- if i.WaitForJobs {
- err = i.cfg.KubeClient.WaitWithJobs(resources, i.Timeout)
- } else {
- err = i.cfg.KubeClient.Wait(resources, i.Timeout)
- }
- if err != nil {
- return rel, err
- }
+ waiter, err := i.cfg.KubeClient.GetWaiter(i.WaitStrategy)
+ if err != nil {
+ return rel, fmt.Errorf("failed to get waiter: %w", err)
+ }
+
+ if i.WaitForJobs {
+ err = waiter.WaitWithJobs(resources, i.Timeout)
+ } else {
+ err = waiter.Wait(resources, i.Timeout)
+ }
+ if err != nil {
+ return rel, err
}
if !i.DisableHooks {
- if err := i.cfg.execHook(rel, release.HookPostInstall, i.Timeout); err != nil {
+ if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.Timeout); err != nil {
return rel, fmt.Errorf("failed post-install: %s", err)
}
}
@@ -476,7 +523,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
// One possible strategy would be to do a timed retry to see if we can get
// this stored in the future.
if err := i.recordRelease(rel); err != nil {
- i.cfg.Log("failed to record the release: %s", err)
+ slog.Error("failed to record the release", slog.Any("error", err))
}
return rel, nil
@@ -484,16 +531,16 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
func (i *Install) failRelease(rel *release.Release, err error) (*release.Release, error) {
rel.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", i.ReleaseName, err.Error()))
- if i.Atomic {
- i.cfg.Log("Install failed and atomic is set, uninstalling release")
+ if i.RollbackOnFailure {
+ slog.Debug("install failed and rollback-on-failure is set, uninstalling release", "release", i.ReleaseName)
uninstall := NewUninstall(i.cfg)
uninstall.DisableHooks = i.DisableHooks
uninstall.KeepHistory = false
uninstall.Timeout = i.Timeout
if _, uninstallErr := uninstall.Run(i.ReleaseName); uninstallErr != nil {
- return rel, errors.Wrapf(uninstallErr, "an error occurred while uninstalling the release. original install error: %s", err)
+ return rel, fmt.Errorf("an error occurred while uninstalling the release. original install error: %w: %w", err, uninstallErr)
}
- return rel, errors.Wrapf(err, "release %s failed, and has been uninstalled due to atomic being set", i.ReleaseName)
+ return rel, fmt.Errorf("release %s failed, and has been uninstalled due to rollback-on-failure being set: %w", i.ReleaseName, err)
}
i.recordRelease(rel) // Ignore the error, since we have another error to deal with.
return rel, err
@@ -511,7 +558,7 @@ func (i *Install) availableName() error {
start := i.ReleaseName
if err := chartutil.ValidateReleaseName(start); err != nil {
- return errors.Wrapf(err, "release name %q", start)
+ return fmt.Errorf("release name %q: %w", start, err)
}
// On dry run, bail here
if i.isDryRun() {
@@ -528,7 +575,7 @@ func (i *Install) availableName() error {
if st := rel.Info.Status; i.Replace && (st == release.StatusUninstalled || st == release.StatusFailed) {
return nil
}
- return errors.New("cannot re-use a name that is still in use")
+ return errors.New("cannot reuse a name that is still in use")
}
// createRelease creates a new release object
@@ -558,7 +605,7 @@ func (i *Install) recordRelease(r *release.Release) error {
// replaceRelease replaces an older release with this one
//
-// This allows us to re-use names by superseding an existing release with a new one
+// This allows us to reuse names by superseding an existing release with a new one
func (i *Install) replaceRelease(rel *release.Release) error {
hist, err := i.cfg.Releases.History(rel.Name)
if err != nil || len(hist) == 0 {
@@ -582,8 +629,8 @@ func (i *Install) replaceRelease(rel *release.Release) error {
return i.recordRelease(last)
}
-// write the to /. controls if the file is created or content will be appended
-func writeToFile(outputDir string, name string, data string, append bool) error {
+// write the to /. controls if the file is created or content will be appended
+func writeToFile(outputDir string, name string, data string, appendData bool) error {
outfileName := strings.Join([]string{outputDir, name}, string(filepath.Separator))
err := ensureDirectoryForFile(outfileName)
@@ -591,14 +638,14 @@ func writeToFile(outputDir string, name string, data string, append bool) error
return err
}
- f, err := createOrOpenFile(outfileName, append)
+ f, err := createOrOpenFile(outfileName, appendData)
if err != nil {
return err
}
defer f.Close()
- _, err = f.WriteString(fmt.Sprintf("---\n# Source: %s\n%s\n", name, data))
+ _, err = fmt.Fprintf(f, "---\n# Source: %s\n%s\n", name, data)
if err != nil {
return err
@@ -608,18 +655,18 @@ func writeToFile(outputDir string, name string, data string, append bool) error
return nil
}
-func createOrOpenFile(filename string, append bool) (*os.File, error) {
- if append {
+func createOrOpenFile(filename string, appendData bool) (*os.File, error) {
+ if appendData {
return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600)
}
return os.Create(filename)
}
-// check if the directory exists to create file. creates if don't exists
+// check if the directory exists to create file. creates if doesn't exist
func ensureDirectoryForFile(file string) error {
baseDir := path.Dir(file)
_, err := os.Stat(baseDir)
- if err != nil && !os.IsNotExist(err) {
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
@@ -641,7 +688,7 @@ func (i *Install) NameAndChart(args []string) (string, string, error) {
}
if len(args) > 2 {
- return args[0], args[1], errors.Errorf("expected at most two arguments, unexpected arguments: %v", strings.Join(args[2:], ", "))
+ return args[0], args[1], fmt.Errorf("expected at most two arguments, unexpected arguments: %v", strings.Join(args[2:], ", "))
}
if len(args) == 2 {
@@ -706,11 +753,30 @@ OUTER:
}
if len(missing) > 0 {
- return errors.Errorf("found in Chart.yaml, but missing in charts/ directory: %s", strings.Join(missing, ", "))
+ return fmt.Errorf("found in Chart.yaml, but missing in charts/ directory: %s", strings.Join(missing, ", "))
}
return nil
}
+func portOrDefault(u *url.URL) string {
+ if p := u.Port(); p != "" {
+ return p
+ }
+
+ switch u.Scheme {
+ case "http":
+ return "80"
+ case "https":
+ return "443"
+ default:
+ return ""
+ }
+}
+
+func urlEqual(u1, u2 *url.URL) bool {
+ return u1.Scheme == u2.Scheme && u1.Hostname() == u2.Hostname() && portOrDefault(u1) == portOrDefault(u2)
+}
+
// LocateChart looks for a chart directory in known places, and returns either the full path or an error.
//
// This does not ensure that the chart is well-formed; only that the requested filename exists.
@@ -735,14 +801,14 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
return abs, err
}
if c.Verify {
- if _, err := downloader.VerifyChart(abs, c.Keyring); err != nil {
+ if _, err := downloader.VerifyChart(abs, abs+".prov", c.Keyring); err != nil {
return "", err
}
}
return abs, nil
}
if filepath.IsAbs(name) || strings.HasPrefix(name, ".") {
- return name, errors.Errorf("path %q not found", name)
+ return name, fmt.Errorf("path %q not found", name)
}
dl := downloader.ChartDownloader{
@@ -754,9 +820,11 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
getter.WithTLSClientConfig(c.CertFile, c.KeyFile, c.CaFile),
getter.WithInsecureSkipVerifyTLS(c.InsecureSkipTLSverify),
getter.WithPlainHTTP(c.PlainHTTP),
+ getter.WithBasicAuth(c.Username, c.Password),
},
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
RegistryClient: c.registryClient,
}
@@ -768,8 +836,16 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
dl.Verify = downloader.VerifyAlways
}
if c.RepoURL != "" {
- chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(c.RepoURL, c.Username, c.Password, name, version,
- c.CertFile, c.KeyFile, c.CaFile, c.InsecureSkipTLSverify, c.PassCredentialsAll, getter.All(settings))
+ chartURL, err := repo.FindChartInRepoURL(
+ c.RepoURL,
+ name,
+ getter.All(settings),
+ repo.WithChartVersion(version),
+ repo.WithClientTLS(c.CertFile, c.KeyFile, c.CaFile),
+ repo.WithUsernamePassword(c.Username, c.Password),
+ repo.WithInsecureSkipTLSverify(c.InsecureSkipTLSverify),
+ repo.WithPassCredentialsAll(c.PassCredentialsAll),
+ )
if err != nil {
return "", err
}
@@ -789,7 +865,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
// Host on URL (returned from url.Parse) contains the port if present.
// This check ensures credentials are not passed between different
// services on different ports.
- if c.PassCredentialsAll || (u1.Scheme == u2.Scheme && u1.Host == u2.Host) {
+ if c.PassCredentialsAll || urlEqual(u1, u2) {
dl.Options = append(dl.Options, getter.WithBasicAuth(c.Username, c.Password))
} else {
dl.Options = append(dl.Options, getter.WithBasicAuth("", ""))
@@ -802,7 +878,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
return "", err
}
- filename, _, err := dl.DownloadTo(name, version, settings.RepositoryCache)
+ filename, _, err := dl.DownloadToCache(name, version)
if err != nil {
return "", err
}
diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go
index bc0890115..f567b3df4 100644
--- a/pkg/action/install_test.go
+++ b/pkg/action/install_test.go
@@ -17,9 +17,14 @@ limitations under the License.
package action
import (
+ "bytes"
"context"
+ "errors"
"fmt"
"io"
+ "io/fs"
+ "net/http"
+ "net/url"
"os"
"path/filepath"
"regexp"
@@ -30,14 +35,23 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
-
- "helm.sh/helm/v3/internal/test"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage/driver"
- helmtime "helm.sh/helm/v3/pkg/time"
+ appsv1 "k8s.io/api/apps/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kuberuntime "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest/fake"
+
+ "helm.sh/helm/v4/internal/test"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
type nameTemplateTestCase struct {
@@ -46,7 +60,64 @@ type nameTemplateTestCase struct {
expectedErrorStr string
}
+func createDummyResourceList(owned bool) kube.ResourceList {
+ obj := &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "dummyName",
+ Namespace: "spaced",
+ },
+ }
+
+ if owned {
+ obj.Labels = map[string]string{
+ "app.kubernetes.io/managed-by": "Helm",
+ }
+ obj.Annotations = map[string]string{
+ "meta.helm.sh/release-name": "test-install-release",
+ "meta.helm.sh/release-namespace": "spaced",
+ }
+ }
+
+ resInfo := resource.Info{
+ Name: "dummyName",
+ Namespace: "spaced",
+ Mapping: &meta.RESTMapping{
+ Resource: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+ GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ Scope: meta.RESTScopeNamespace,
+ },
+ Object: obj,
+ }
+ body := io.NopCloser(bytes.NewReader([]byte(kuberuntime.EncodeOrDie(appsv1Codec, obj))))
+
+ resInfo.Client = &fake.RESTClient{
+ GroupVersion: schema.GroupVersion{Group: "apps", Version: "v1"},
+ NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
+ Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
+ header := http.Header{}
+ header.Set("Content-Type", kuberuntime.ContentTypeJSON)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: header,
+ Body: body,
+ }, nil
+ }),
+ }
+ var resourceList kube.ResourceList
+ resourceList.Append(&resInfo)
+ return resourceList
+}
+
+func installActionWithConfig(config *Configuration) *Install {
+ instAction := NewInstall(config)
+ instAction.Namespace = "spaced"
+ instAction.ReleaseName = "test-install-release"
+
+ return instAction
+}
+
func installAction(t *testing.T) *Install {
+ t.Helper()
config := actionConfigFixture(t)
instAction := NewInstall(config)
instAction.Namespace = "spaced"
@@ -61,7 +132,7 @@ func TestInstallRelease(t *testing.T) {
instAction := installAction(t)
vals := map[string]interface{}{}
- ctx, done := context.WithCancel(context.Background())
+ ctx, done := context.WithCancel(t.Context())
res, err := instAction.RunWithContext(ctx, buildChart(), vals)
if err != nil {
t.Fatalf("Failed install: %s", err)
@@ -91,6 +162,61 @@ func TestInstallRelease(t *testing.T) {
is.Equal(lastRelease.Info.Status, release.StatusDeployed)
}
+func TestInstallReleaseWithTakeOwnership_ResourceNotOwned(t *testing.T) {
+ // This test will test checking ownership of a resource
+ // returned by the fake client. If the resource is not
+ // owned by the chart, ownership is taken.
+ // To verify ownership has been taken, the fake client
+ // needs to store state which is a bigger rewrite.
+ // TODO: Ensure fake kube client stores state. Maybe using
+ // "k8s.io/client-go/kubernetes/fake" could be sufficient? i.e
+ // "Client{Namespace: namespace, kubeClient: k8sfake.NewClientset()}"
+
+ is := assert.New(t)
+
+ // Resource list from cluster is NOT owned by helm chart
+ config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false))
+ instAction := installActionWithConfig(config)
+ instAction.TakeOwnership = true
+ res, err := instAction.Run(buildChart(), nil)
+ if err != nil {
+ t.Fatalf("Failed install: %s", err)
+ }
+
+ rel, err := instAction.cfg.Releases.Get(res.Name, res.Version)
+ is.NoError(err)
+
+ is.Equal(rel.Info.Description, "Install complete")
+}
+
+func TestInstallReleaseWithTakeOwnership_ResourceOwned(t *testing.T) {
+ is := assert.New(t)
+
+ // Resource list from cluster is owned by helm chart
+ config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(true))
+ instAction := installActionWithConfig(config)
+ instAction.TakeOwnership = false
+ res, err := instAction.Run(buildChart(), nil)
+ if err != nil {
+ t.Fatalf("Failed install: %s", err)
+ }
+ rel, err := instAction.cfg.Releases.Get(res.Name, res.Version)
+ is.NoError(err)
+
+ is.Equal(rel.Info.Description, "Install complete")
+}
+
+func TestInstallReleaseWithTakeOwnership_ResourceOwnedNoFlag(t *testing.T) {
+ is := assert.New(t)
+
+ // Resource list from cluster is NOT owned by helm chart
+ config := actionConfigFixtureWithDummyResources(t, createDummyResourceList(false))
+ instAction := installActionWithConfig(config)
+ _, err := instAction.Run(buildChart(), nil)
+ is.Error(err)
+ is.Contains(err.Error(), "unable to continue with install")
+}
+
func TestInstallReleaseWithValues(t *testing.T) {
is := assert.New(t)
instAction := installAction(t)
@@ -255,6 +381,46 @@ func TestInstallRelease_DryRun(t *testing.T) {
is.Equal(res.Info.Description, "Dry run complete")
}
+func TestInstallRelease_DryRunHiddenSecret(t *testing.T) {
+ is := assert.New(t)
+ instAction := installAction(t)
+
+ // First perform a normal dry-run with the secret and confirm its presence.
+ instAction.DryRun = true
+ vals := map[string]interface{}{}
+ res, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals)
+ if err != nil {
+ t.Fatalf("Failed install: %s", err)
+ }
+ is.Contains(res.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret")
+
+ _, err = instAction.cfg.Releases.Get(res.Name, res.Version)
+ is.Error(err)
+ is.Equal(res.Info.Description, "Dry run complete")
+
+ // Perform a dry-run where the secret should not be present
+ instAction.HideSecret = true
+ vals = map[string]interface{}{}
+ res2, err := instAction.Run(buildChart(withSampleSecret(), withSampleTemplates()), vals)
+ if err != nil {
+ t.Fatalf("Failed install: %s", err)
+ }
+
+ is.NotContains(res2.Manifest, "---\n# Source: hello/templates/secret.yaml\napiVersion: v1\nkind: Secret")
+
+ _, err = instAction.cfg.Releases.Get(res2.Name, res2.Version)
+ is.Error(err)
+ is.Equal(res2.Info.Description, "Dry run complete")
+
+ // Ensure there is an error when HideSecret True but not in a dry-run mode
+ instAction.DryRun = false
+ vals = map[string]interface{}{}
+ _, 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")
+ }
+}
+
// Regression test for #7955
func TestInstallRelease_DryRun_Lookup(t *testing.T) {
is := assert.New(t)
@@ -282,7 +448,9 @@ func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) {
instAction.DryRun = true
vals := map[string]interface{}{}
_, err := instAction.Run(buildChart(withSampleIncludingIncorrectTemplates()), vals)
- expectedErr := "\"hello/templates/incorrect\" at <.Values.bad.doh>: nil pointer evaluating interface {}.doh"
+ expectedErr := `hello/templates/incorrect:1:10
+ executing "hello/templates/incorrect" at <.Values.bad.doh>:
+ nil pointer evaluating interface {}.doh`
if err == nil {
t.Fatalf("Install should fail containing error: %s", expectedErr)
}
@@ -314,11 +482,14 @@ func TestInstallRelease_FailedHooks(t *testing.T) {
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WatchUntilReadyError = fmt.Errorf("Failed watch")
instAction.cfg.KubeClient = failer
+ outBuffer := &bytes.Buffer{}
+ failer.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer}
vals := map[string]interface{}{}
res, err := instAction.Run(buildChart(), vals)
is.Error(err)
is.Contains(res.Info.Description, "failed post-install")
+ is.Equal("", outBuffer.String())
is.Equal(release.StatusFailed, res.Info.Status)
}
@@ -367,7 +538,7 @@ func TestInstallRelease_Wait(t *testing.T) {
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitError = fmt.Errorf("I timed out")
instAction.cfg.KubeClient = failer
- instAction.Wait = true
+ instAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{}
goroutines := runtime.NumGoroutine()
@@ -386,19 +557,17 @@ func TestInstallRelease_Wait_Interrupted(t *testing.T) {
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitDuration = 10 * time.Second
instAction.cfg.KubeClient = failer
- instAction.Wait = true
+ instAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{}
- ctx := context.Background()
- ctx, cancel := context.WithCancel(ctx)
+ ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel)
goroutines := runtime.NumGoroutine()
- res, err := instAction.RunWithContext(ctx, buildChart(), vals)
+ _, err := instAction.RunWithContext(ctx, buildChart(), vals)
is.Error(err)
- is.Contains(res.Info.Description, "Release \"interrupted-release\" failed: context canceled")
- is.Equal(res.Info.Status, release.StatusFailed)
+ is.Contains(err.Error(), "context canceled")
is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background
time.Sleep(10 * time.Second) // wait for goroutine to finish
@@ -411,7 +580,7 @@ func TestInstallRelease_WaitForJobs(t *testing.T) {
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitError = fmt.Errorf("I timed out")
instAction.cfg.KubeClient = failer
- instAction.Wait = true
+ instAction.WaitStrategy = kube.StatusWatcherStrategy
instAction.WaitForJobs = true
vals := map[string]interface{}{}
@@ -421,37 +590,40 @@ func TestInstallRelease_WaitForJobs(t *testing.T) {
is.Equal(res.Info.Status, release.StatusFailed)
}
-func TestInstallRelease_Atomic(t *testing.T) {
+func TestInstallRelease_RollbackOnFailure(t *testing.T) {
is := assert.New(t)
- t.Run("atomic uninstall succeeds", func(t *testing.T) {
+ t.Run("rollback-on-failure uninstall succeeds", func(t *testing.T) {
instAction := installAction(t)
instAction.ReleaseName = "come-fail-away"
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitError = fmt.Errorf("I timed out")
instAction.cfg.KubeClient = failer
- instAction.Atomic = true
+ 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{}{}
res, err := instAction.Run(buildChart(), vals)
is.Error(err)
is.Contains(err.Error(), "I timed out")
- is.Contains(err.Error(), "atomic")
+ is.Contains(err.Error(), "rollback-on-failure")
- // Now make sure it isn't in storage any more
+ // Now make sure it isn't in storage anymore
_, err = instAction.cfg.Releases.Get(res.Name, res.Version)
is.Error(err)
is.Equal(err, driver.ErrReleaseNotFound)
})
- t.Run("atomic uninstall fails", func(t *testing.T) {
+ t.Run("rollback-on-failure uninstall fails", func(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")
instAction.cfg.KubeClient = failer
- instAction.Atomic = true
+ instAction.RollbackOnFailure = true
vals := map[string]interface{}{}
_, err := instAction.Run(buildChart(), vals)
@@ -461,7 +633,7 @@ func TestInstallRelease_Atomic(t *testing.T) {
is.Contains(err.Error(), "an error occurred while uninstalling the release")
})
}
-func TestInstallRelease_Atomic_Interrupted(t *testing.T) {
+func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) {
is := assert.New(t)
instAction := installAction(t)
@@ -469,23 +641,27 @@ func TestInstallRelease_Atomic_Interrupted(t *testing.T) {
failer := instAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitDuration = 10 * time.Second
instAction.cfg.KubeClient = failer
- instAction.Atomic = true
+ instAction.RollbackOnFailure = true
vals := map[string]interface{}{}
- ctx := context.Background()
- ctx, cancel := context.WithCancel(ctx)
+ ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel)
+ goroutines := runtime.NumGoroutine()
+
res, err := instAction.RunWithContext(ctx, buildChart(), vals)
is.Error(err)
is.Contains(err.Error(), "context canceled")
- is.Contains(err.Error(), "atomic")
+ is.Contains(err.Error(), "rollback-on-failure")
is.Contains(err.Error(), "uninstalled")
- // Now make sure it isn't in storage any more
+ // Now make sure it isn't in storage anymore
_, err = instAction.cfg.Releases.Get(res.Name, res.Version)
is.Error(err)
is.Equal(err, driver.ErrReleaseNotFound)
+ is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background
+ time.Sleep(10 * time.Second) // wait for goroutine to finish
+ is.Equal(goroutines, runtime.NumGoroutine())
}
func TestNameTemplate(t *testing.T) {
@@ -586,7 +762,7 @@ 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(os.IsNotExist(err))
+ is.True(errors.Is(err, fs.ErrNotExist))
}
func TestInstallOutputDirWithReleaseName(t *testing.T) {
@@ -622,7 +798,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(os.IsNotExist(err))
+ is.True(errors.Is(err, fs.ErrNotExist))
}
func TestNameAndChart(t *testing.T) {
@@ -714,7 +890,6 @@ func TestNameAndChartGenerateName(t *testing.T) {
}
for _, tc := range tests {
- tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
@@ -756,5 +931,70 @@ func TestInstallWithSystemLabels(t *testing.T) {
t.Fatal("expected an error")
}
- is.Equal(fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err)
+ is.Equal(fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err)
+}
+
+func TestUrlEqual(t *testing.T) {
+ is := assert.New(t)
+
+ tests := []struct {
+ name string
+ url1 string
+ url2 string
+ expected bool
+ }{
+ {
+ name: "identical URLs",
+ url1: "https://example.com:443",
+ url2: "https://example.com:443",
+ expected: true,
+ },
+ {
+ name: "same host, scheme, default HTTPS port vs explicit",
+ url1: "https://example.com",
+ url2: "https://example.com:443",
+ expected: true,
+ },
+ {
+ name: "same host, scheme, default HTTP port vs explicit",
+ url1: "http://example.com",
+ url2: "http://example.com:80",
+ expected: true,
+ },
+ {
+ name: "different schemes",
+ url1: "http://example.com",
+ url2: "https://example.com",
+ expected: false,
+ },
+ {
+ name: "different hosts",
+ url1: "https://example.com",
+ url2: "https://www.example.com",
+ expected: false,
+ },
+ {
+ name: "different ports",
+ url1: "https://example.com:8080",
+ url2: "https://example.com:9090",
+ expected: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ u1, err := url.Parse(tc.url1)
+ if err != nil {
+ t.Fatalf("Failed to parse URL1 %s: %v", tc.url1, err)
+ }
+ u2, err := url.Parse(tc.url2)
+ if err != nil {
+ t.Fatalf("Failed to parse URL2 %s: %v", tc.url2, err)
+ }
+
+ is.Equal(tc.expected, urlEqual(u1, u2))
+ })
+ }
}
diff --git a/pkg/action/lint.go b/pkg/action/lint.go
index e71cfe733..7b3c00ad2 100644
--- a/pkg/action/lint.go
+++ b/pkg/action/lint.go
@@ -17,25 +17,26 @@ limitations under the License.
package action
import (
+ "fmt"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint"
- "helm.sh/helm/v3/pkg/lint/support"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint"
+ "helm.sh/helm/v4/pkg/lint/support"
)
// Lint is the action for checking that the semantics of a chart are well-formed.
//
// It provides the implementation of 'helm lint'.
type Lint struct {
- Strict bool
- Namespace string
- WithSubcharts bool
- Quiet bool
+ Strict bool
+ Namespace string
+ WithSubcharts bool
+ Quiet bool
+ SkipSchemaValidation bool
+ KubeVersion *chartutil.KubeVersion
}
// LintResult is the result of Lint
@@ -58,7 +59,7 @@ func (l *Lint) Run(paths []string, vals map[string]interface{}) *LintResult {
}
result := &LintResult{}
for _, path := range paths {
- linter, err := lintChart(path, vals, l.Namespace, l.Strict)
+ linter, err := lintChart(path, vals, l.Namespace, l.KubeVersion, l.SkipSchemaValidation)
if err != nil {
result.Errors = append(result.Errors, err)
continue
@@ -85,33 +86,33 @@ func HasWarningsOrErrors(result *LintResult) bool {
return len(result.Errors) > 0
}
-func lintChart(path string, vals map[string]interface{}, namespace string, strict bool) (support.Linter, error) {
+func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion, skipSchemaValidation bool) (support.Linter, error) {
var chartPath string
linter := support.Linter{}
if strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".tar.gz") {
tempDir, err := os.MkdirTemp("", "helm-lint")
if err != nil {
- return linter, errors.Wrap(err, "unable to create temp dir to extract tarball")
+ return linter, fmt.Errorf("unable to create temp dir to extract tarball: %w", err)
}
defer os.RemoveAll(tempDir)
file, err := os.Open(path)
if err != nil {
- return linter, errors.Wrap(err, "unable to open tarball")
+ return linter, fmt.Errorf("unable to open tarball: %w", err)
}
defer file.Close()
if err = chartutil.Expand(tempDir, file); err != nil {
- return linter, errors.Wrap(err, "unable to extract tarball")
+ return linter, fmt.Errorf("unable to extract tarball: %w", err)
}
files, err := os.ReadDir(tempDir)
if err != nil {
- return linter, errors.Wrapf(err, "unable to read temporary output directory %s", tempDir)
+ return linter, fmt.Errorf("unable to read temporary output directory %s: %w", tempDir, err)
}
if !files[0].IsDir() {
- return linter, errors.Errorf("unexpected file %s in temporary output directory %s", files[0].Name(), tempDir)
+ return linter, fmt.Errorf("unexpected file %s in temporary output directory %s", files[0].Name(), tempDir)
}
chartPath = filepath.Join(tempDir, files[0].Name())
@@ -121,8 +122,14 @@ func lintChart(path string, vals map[string]interface{}, namespace string, stric
// Guard: Error out if this is not a chart.
if _, err := os.Stat(filepath.Join(chartPath, "Chart.yaml")); err != nil {
- return linter, errors.Wrap(err, "unable to check Chart.yaml file in chart")
+ return linter, fmt.Errorf("unable to check Chart.yaml file in chart: %w", err)
}
- return lint.All(chartPath, vals, namespace, strict), nil
+ return lint.RunAll(
+ chartPath,
+ vals,
+ namespace,
+ lint.WithKubeVersion(kubeVersion),
+ lint.WithSkipSchemaValidation(skipSchemaValidation),
+ ), nil
}
diff --git a/pkg/action/lint_test.go b/pkg/action/lint_test.go
index ff69407ca..613149a4d 100644
--- a/pkg/action/lint_test.go
+++ b/pkg/action/lint_test.go
@@ -23,7 +23,6 @@ import (
var (
values = make(map[string]interface{})
namespace = "testNamespace"
- strict = false
chart1MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-1"
chart2MultipleChartLint = "testdata/charts/multiplecharts-lint-chart-2"
corruptedTgzChart = "testdata/charts/corrupted-compressed-chart.tgz"
@@ -32,9 +31,10 @@ var (
func TestLintChart(t *testing.T) {
tests := []struct {
- name string
- chartPath string
- err bool
+ name string
+ chartPath string
+ err bool
+ skipSchemaValidation bool
}{
{
name: "decompressed-chart",
@@ -70,6 +70,11 @@ func TestLintChart(t *testing.T) {
name: "chart-with-schema-negative",
chartPath: "testdata/charts/chart-with-schema-negative",
},
+ {
+ name: "chart-with-schema-negative-skip-validation",
+ chartPath: "testdata/charts/chart-with-schema-negative",
+ skipSchemaValidation: true,
+ },
{
name: "pre-release-chart",
chartPath: "testdata/charts/pre-release-chart-0.1.0-alpha.tgz",
@@ -78,7 +83,7 @@ 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, strict)
+ _, err := lintChart(tt.chartPath, map[string]interface{}{}, namespace, nil, tt.skipSchemaValidation)
switch {
case err != nil && !tt.err:
t.Errorf("%s", err)
@@ -149,12 +154,12 @@ func TestLint_ChartWithWarnings(t *testing.T) {
}
})
- t.Run("should pass with no errors when strict", func(t *testing.T) {
+ t.Run("should fail with one error when strict", func(t *testing.T) {
testCharts := []string{chartWithNoTemplatesDir}
testLint := NewLint()
testLint.Strict = true
- if result := testLint.Run(testCharts, values); len(result.Errors) != 0 {
- t.Error("expected no errors, but got", len(result.Errors))
+ if result := testLint.Run(testCharts, values); len(result.Errors) != 1 {
+ t.Error("expected one error, but got", len(result.Errors))
}
})
}
diff --git a/pkg/action/list.go b/pkg/action/list.go
index af0725c4a..82500582f 100644
--- a/pkg/action/list.go
+++ b/pkg/action/list.go
@@ -22,8 +22,8 @@ import (
"k8s.io/apimachinery/pkg/labels"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// ListStates represents zero or more status codes that a list item may have set
diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go
index 73009d523..b6f89fa1e 100644
--- a/pkg/action/list_test.go
+++ b/pkg/action/list_test.go
@@ -21,8 +21,8 @@ import (
"github.com/stretchr/testify/assert"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage"
)
func TestListStates(t *testing.T) {
@@ -64,13 +64,14 @@ func TestList_Empty(t *testing.T) {
}
func newListFixture(t *testing.T) *List {
+ t.Helper()
return NewList(actionConfigFixture(t))
}
func TestList_OneNamespace(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 3)
@@ -79,7 +80,7 @@ func TestList_OneNamespace(t *testing.T) {
func TestList_AllNamespaces(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
lister.AllNamespaces = true
lister.SetStateMask()
list, err := lister.Run()
@@ -91,7 +92,7 @@ func TestList_Sort(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
lister.Sort = ByNameDesc // Other sorts are tested elsewhere
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 3)
@@ -104,7 +105,7 @@ func TestList_Limit(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
lister.Limit = 2
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 2)
@@ -117,7 +118,7 @@ func TestList_BigLimit(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
lister.Limit = 20
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 3)
@@ -133,7 +134,7 @@ func TestList_LimitOffset(t *testing.T) {
lister := newListFixture(t)
lister.Limit = 2
lister.Offset = 1
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 2)
@@ -148,7 +149,7 @@ func TestList_LimitOffsetOutOfBounds(t *testing.T) {
lister := newListFixture(t)
lister.Limit = 2
lister.Offset = 3 // Last item is index 2
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
list, err := lister.Run()
is.NoError(err)
is.Len(list, 0)
@@ -163,7 +164,7 @@ func TestList_LimitOffsetOutOfBounds(t *testing.T) {
func TestList_StateMask(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
one, err := lister.cfg.Releases.Get("one", 1)
is.NoError(err)
one.SetStatus(release.StatusUninstalled, "uninstalled")
@@ -193,7 +194,7 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) {
lister := newListFixture(t)
lister.StateMask = ListFailed
- makeMeSomeReleasesWithStaleFailure(lister.cfg.Releases, t)
+ makeMeSomeReleasesWithStaleFailure(t, lister.cfg.Releases)
res, err := lister.Run()
@@ -205,7 +206,7 @@ func TestList_StateMaskWithStaleRevisions(t *testing.T) {
is.Equal("failed", res[0].Name)
}
-func makeMeSomeReleasesWithStaleFailure(store *storage.Storage, t *testing.T) {
+func makeMeSomeReleasesWithStaleFailure(t *testing.T, store *storage.Storage) {
t.Helper()
one := namedReleaseStub("clean", release.StatusDeployed)
one.Namespace = "default"
@@ -242,7 +243,7 @@ func TestList_Filter(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
lister.Filter = "th."
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
res, err := lister.Run()
is.NoError(err)
@@ -254,13 +255,13 @@ func TestList_FilterFailsCompile(t *testing.T) {
is := assert.New(t)
lister := newListFixture(t)
lister.Filter = "t[h.{{{"
- makeMeSomeReleases(lister.cfg.Releases, t)
+ makeMeSomeReleases(t, lister.cfg.Releases)
_, err := lister.Run()
is.Error(err)
}
-func makeMeSomeReleases(store *storage.Storage, t *testing.T) {
+func makeMeSomeReleases(t *testing.T, store *storage.Storage) {
t.Helper()
one := releaseStub()
one.Name = "one"
diff --git a/pkg/action/package.go b/pkg/action/package.go
index 698169032..e57ce4921 100644
--- a/pkg/action/package.go
+++ b/pkg/action/package.go
@@ -18,17 +18,17 @@ package action
import (
"bufio"
+ "errors"
"fmt"
"os"
"syscall"
"github.com/Masterminds/semver/v3"
- "github.com/pkg/errors"
"golang.org/x/term"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/provenance"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/provenance"
)
// Package is the action for packaging a chart.
@@ -39,22 +39,34 @@ type Package struct {
Key string
Keyring string
PassphraseFile string
+ cachedPassphrase []byte
Version string
AppVersion string
Destination string
DependencyUpdate bool
- RepositoryConfig string
- RepositoryCache string
+ RepositoryConfig string
+ RepositoryCache string
+ PlainHTTP bool
+ Username string
+ Password string
+ CertFile string
+ KeyFile string
+ CaFile string
+ InsecureSkipTLSverify bool
}
+const (
+ passPhraseFileStdin = "-"
+)
+
// NewPackage creates a new Package object with the given configuration.
func NewPackage() *Package {
return &Package{}
}
// Run executes 'helm package' against the given chart and returns the path to the packaged chart.
-func (p *Package) Run(path string, vals map[string]interface{}) (string, error) {
+func (p *Package) Run(path string, _ map[string]interface{}) (string, error) {
ch, err := loader.LoadDir(path)
if err != nil {
return "", err
@@ -93,7 +105,7 @@ func (p *Package) Run(path string, vals map[string]interface{}) (string, error)
name, err := chartutil.Save(ch, dest)
if err != nil {
- return "", errors.Wrap(err, "failed to save")
+ return "", fmt.Errorf("failed to save: %w", err)
}
if p.Sign {
@@ -121,7 +133,7 @@ func (p *Package) Clearsign(filename string) error {
passphraseFetcher := promptUser
if p.PassphraseFile != "" {
- passphraseFetcher, err = passphraseFileFetcher(p.PassphraseFile, os.Stdin)
+ passphraseFetcher, err = p.passphraseFileFetcher(p.PassphraseFile, os.Stdin)
if err != nil {
return err
}
@@ -149,25 +161,42 @@ func promptUser(name string) ([]byte, error) {
return pw, err
}
-func passphraseFileFetcher(passphraseFile string, stdin *os.File) (provenance.PassphraseFetcher, error) {
- file, err := openPassphraseFile(passphraseFile, stdin)
- if err != nil {
- return nil, err
- }
- defer file.Close()
+func (p *Package) passphraseFileFetcher(passphraseFile string, stdin *os.File) (provenance.PassphraseFetcher, error) {
+ // When reading from stdin we cache the passphrase here. If we are
+ // packaging multiple charts, we reuse the cached passphrase. This
+ // allows giving the passphrase once on stdin without failing with
+ // complaints about stdin already being closed.
+ //
+ // An alternative to this would be to omit file.Close() for stdin
+ // below and require the user to provide the same passphrase once
+ // per chart on stdin, but that does not seem very user-friendly.
+
+ if p.cachedPassphrase == nil {
+ file, err := openPassphraseFile(passphraseFile, stdin)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
- reader := bufio.NewReader(file)
- passphrase, _, err := reader.ReadLine()
- if err != nil {
- return nil, err
+ reader := bufio.NewReader(file)
+ passphrase, _, err := reader.ReadLine()
+ if err != nil {
+ return nil, err
+ }
+ p.cachedPassphrase = passphrase
+
+ return func(_ string) ([]byte, error) {
+ return passphrase, nil
+ }, nil
}
- return func(name string) ([]byte, error) {
- return passphrase, nil
+
+ return func(_ string) ([]byte, error) {
+ return p.cachedPassphrase, nil
}, nil
}
func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) {
- if passphraseFile == "-" {
+ if passphraseFile == passPhraseFileStdin {
stat, err := stdin.Stat()
if err != nil {
return nil, err
diff --git a/pkg/action/package_test.go b/pkg/action/package_test.go
index d04efdaa6..12bea10dd 100644
--- a/pkg/action/package_test.go
+++ b/pkg/action/package_test.go
@@ -23,14 +23,15 @@ import (
"github.com/Masterminds/semver/v3"
- "helm.sh/helm/v3/internal/test/ensure"
+ "helm.sh/helm/v4/internal/test/ensure"
)
func TestPassphraseFileFetcher(t *testing.T) {
secret := "secret"
directory := ensure.TempFile(t, "passphrase-file", []byte(secret))
+ testPkg := NewPackage()
- fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil)
+ fetcher, err := testPkg.passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil)
if err != nil {
t.Fatal("Unable to create passphraseFileFetcher", err)
}
@@ -48,8 +49,9 @@ func TestPassphraseFileFetcher(t *testing.T) {
func TestPassphraseFileFetcher_WithLineBreak(t *testing.T) {
secret := "secret"
directory := ensure.TempFile(t, "passphrase-file", []byte(secret+"\n\n."))
+ testPkg := NewPackage()
- fetcher, err := passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil)
+ fetcher, err := testPkg.passphraseFileFetcher(path.Join(directory, "passphrase-file"), nil)
if err != nil {
t.Fatal("Unable to create passphraseFileFetcher", err)
}
@@ -66,17 +68,48 @@ func TestPassphraseFileFetcher_WithLineBreak(t *testing.T) {
func TestPassphraseFileFetcher_WithInvalidStdin(t *testing.T) {
directory := t.TempDir()
+ testPkg := NewPackage()
stdin, err := os.CreateTemp(directory, "non-existing")
if err != nil {
t.Fatal("Unable to create test file", err)
}
- if _, err := passphraseFileFetcher("-", stdin); err == nil {
+ if _, err := testPkg.passphraseFileFetcher("-", stdin); err == nil {
t.Error("Expected passphraseFileFetcher returning an error")
}
}
+func TestPassphraseFileFetcher_WithStdinAndMultipleFetches(t *testing.T) {
+ testPkg := NewPackage()
+ stdin, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal("Unable to create pipe", err)
+ }
+
+ passphrase := "secret-from-stdin"
+
+ go func() {
+ w.Write([]byte(passphrase + "\n"))
+ }()
+
+ for i := 0; i < 4; i++ {
+ fetcher, err := testPkg.passphraseFileFetcher("-", stdin)
+ if err != nil {
+ t.Errorf("Expected passphraseFileFetcher to not return an error, but got %v", err)
+ }
+
+ pass, err := fetcher("key")
+ if err != nil {
+ t.Errorf("Expected passphraseFileFetcher invocation to succeed, failed with %v", err)
+ }
+
+ if string(pass) != string(passphrase) {
+ t.Errorf("Expected multiple passphrase fetch to return %q, got %q", passphrase, pass)
+ }
+ }
+}
+
func TestValidateVersion(t *testing.T) {
type args struct {
ver string
diff --git a/pkg/action/pull.go b/pkg/action/pull.go
index 787553125..c1f77e44c 100644
--- a/pkg/action/pull.go
+++ b/pkg/action/pull.go
@@ -22,14 +22,12 @@ import (
"path/filepath"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
)
// Pull is the action for checking a given release's information.
@@ -56,13 +54,8 @@ func WithConfig(cfg *Configuration) PullOpt {
}
}
-// NewPull creates a new Pull object.
-func NewPull() *Pull {
- return NewPullWithOpts()
-}
-
-// NewPullWithOpts creates a new pull, with configuration options.
-func NewPullWithOpts(opts ...PullOpt) *Pull {
+// NewPull creates a new Pull with configuration options.
+func NewPull(opts ...PullOpt) *Pull {
p := &Pull{}
for _, fn := range opts {
fn(p)
@@ -95,6 +88,7 @@ func (p *Pull) Run(chartRef string) (string, error) {
RegistryClient: p.cfg.RegistryClient,
RepositoryConfig: p.Settings.RepositoryConfig,
RepositoryCache: p.Settings.RepositoryCache,
+ ContentCache: p.Settings.ContentCache,
}
if registry.IsOCI(chartRef) {
@@ -116,20 +110,30 @@ func (p *Pull) Run(chartRef string) (string, error) {
var err error
dest, err = os.MkdirTemp("", "helm-")
if err != nil {
- return out.String(), errors.Wrap(err, "failed to untar")
+ return out.String(), fmt.Errorf("failed to untar: %w", err)
}
defer os.RemoveAll(dest)
}
+ downloadSourceRef := chartRef
if p.RepoURL != "" {
- chartURL, err := repo.FindChartInAuthAndTLSAndPassRepoURL(p.RepoURL, p.Username, p.Password, chartRef, p.Version, p.CertFile, p.KeyFile, p.CaFile, p.InsecureSkipTLSverify, p.PassCredentialsAll, getter.All(p.Settings))
+ chartURL, err := repo.FindChartInRepoURL(
+ p.RepoURL,
+ chartRef,
+ getter.All(p.Settings),
+ repo.WithChartVersion(p.Version),
+ repo.WithClientTLS(p.CertFile, p.KeyFile, p.CaFile),
+ repo.WithUsernamePassword(p.Username, p.Password),
+ repo.WithInsecureSkipTLSverify(p.InsecureSkipTLSverify),
+ repo.WithPassCredentialsAll(p.PassCredentialsAll),
+ )
if err != nil {
return out.String(), err
}
- chartRef = chartURL
+ downloadSourceRef = chartURL
}
- saved, v, err := c.DownloadTo(chartRef, p.Version, dest)
+ saved, v, err := c.DownloadTo(downloadSourceRef, p.Version, dest)
if err != nil {
return out.String(), err
}
@@ -159,11 +163,10 @@ func (p *Pull) Run(chartRef string) (string, error) {
if _, err := os.Stat(udCheck); err != nil {
if err := os.MkdirAll(udCheck, 0755); err != nil {
- return out.String(), errors.Wrap(err, "failed to untar (mkdir)")
+ return out.String(), fmt.Errorf("failed to untar (mkdir): %w", err)
}
-
} else {
- return out.String(), errors.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck)
+ return out.String(), fmt.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck)
}
return out.String(), chartutil.ExpandFile(ud, saved)
diff --git a/pkg/action/push.go b/pkg/action/push.go
index 68d2ba42d..35472415c 100644
--- a/pkg/action/push.go
+++ b/pkg/action/push.go
@@ -20,10 +20,10 @@ import (
"io"
"strings"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/pusher"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/uploader"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/pusher"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/uploader"
)
// Push is the action for uploading a chart.
@@ -73,7 +73,7 @@ func WithPlainHTTP(plainHTTP bool) PushOpt {
}
}
-// WithOptWriter sets the registryOut field on the push configuration object.
+// WithPushOptWriter sets the registryOut field on the push configuration object.
func WithPushOptWriter(out io.Writer) PushOpt {
return func(p *Push) {
p.out = out
diff --git a/pkg/action/registry_login.go b/pkg/action/registry_login.go
index a55f2de58..fd9d4bfc6 100644
--- a/pkg/action/registry_login.go
+++ b/pkg/action/registry_login.go
@@ -19,16 +19,17 @@ package action
import (
"io"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/registry"
)
// RegistryLogin performs a registry login operation.
type RegistryLogin struct {
- cfg *Configuration
- certFile string
- keyFile string
- caFile string
- insecure bool
+ cfg *Configuration
+ certFile string
+ keyFile string
+ caFile string
+ insecure bool
+ plainHTTP bool
}
type RegistryLoginOpt func(*RegistryLogin) error
@@ -41,7 +42,7 @@ func WithCertFile(certFile string) RegistryLoginOpt {
}
}
-// WithKeyFile specifies whether to very certificates when communicating.
+// WithInsecure specifies whether to verify certificates.
func WithInsecure(insecure bool) RegistryLoginOpt {
return func(r *RegistryLogin) error {
r.insecure = insecure
@@ -65,6 +66,14 @@ func WithCAFile(caFile string) RegistryLoginOpt {
}
}
+// WithPlainHTTPLogin use http rather than https for login.
+func WithPlainHTTPLogin(isPlain bool) RegistryLoginOpt {
+ return func(r *RegistryLogin) error {
+ r.plainHTTP = isPlain
+ return nil
+ }
+}
+
// NewRegistryLogin creates a new RegistryLogin object with the given configuration.
func NewRegistryLogin(cfg *Configuration) *RegistryLogin {
return &RegistryLogin{
@@ -73,7 +82,7 @@ func NewRegistryLogin(cfg *Configuration) *RegistryLogin {
}
// Run executes the registry login operation
-func (a *RegistryLogin) Run(out io.Writer, hostname string, username string, password string, opts ...RegistryLoginOpt) error {
+func (a *RegistryLogin) Run(_ io.Writer, hostname string, username string, password string, opts ...RegistryLoginOpt) error {
for _, opt := range opts {
if err := opt(a); err != nil {
return err
@@ -84,5 +93,7 @@ func (a *RegistryLogin) Run(out io.Writer, hostname string, username string, pas
hostname,
registry.LoginOptBasicAuth(username, password),
registry.LoginOptInsecure(a.insecure),
- registry.LoginOptTLSClientConfig(a.certFile, a.keyFile, a.caFile))
+ registry.LoginOptTLSClientConfig(a.certFile, a.keyFile, a.caFile),
+ registry.LoginOptPlainText(a.plainHTTP),
+ )
}
diff --git a/pkg/action/registry_logout.go b/pkg/action/registry_logout.go
index 69add4163..7ce92defc 100644
--- a/pkg/action/registry_logout.go
+++ b/pkg/action/registry_logout.go
@@ -33,6 +33,6 @@ func NewRegistryLogout(cfg *Configuration) *RegistryLogout {
}
// Run executes the registry logout operation
-func (a *RegistryLogout) Run(out io.Writer, hostname string) error {
+func (a *RegistryLogout) Run(_ io.Writer, hostname string) error {
return a.cfg.RegistryClient.Logout(hostname)
}
diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go
index 3c10cecf8..b5f6fe712 100644
--- a/pkg/action/release_testing.go
+++ b/pkg/action/release_testing.go
@@ -20,14 +20,15 @@ import (
"context"
"fmt"
"io"
+ "slices"
"sort"
"time"
- "github.com/pkg/errors"
v1 "k8s.io/api/core/v1"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
const (
@@ -44,6 +45,7 @@ type ReleaseTesting struct {
// Used for fetching logs from test pods
Namespace string
Filters map[string][]string
+ HideNotes bool
}
// NewReleaseTesting creates a new ReleaseTesting object with the given configuration.
@@ -61,7 +63,7 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
}
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, errors.Errorf("releaseTest: Release name is invalid: %s", name)
+ return nil, fmt.Errorf("releaseTest: Release name is invalid: %s", name)
}
// finds the non-deleted release with the given name
@@ -74,7 +76,7 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
executingHooks := []*release.Hook{}
if len(r.Filters[ExcludeNameFilter]) != 0 {
for _, h := range rel.Hooks {
- if contains(r.Filters[ExcludeNameFilter], h.Name) {
+ if slices.Contains(r.Filters[ExcludeNameFilter], h.Name) {
skippedHooks = append(skippedHooks, h)
} else {
executingHooks = append(executingHooks, h)
@@ -85,7 +87,7 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
if len(r.Filters[IncludeNameFilter]) != 0 {
executingHooks = nil
for _, h := range rel.Hooks {
- if contains(r.Filters[IncludeNameFilter], h.Name) {
+ if slices.Contains(r.Filters[IncludeNameFilter], h.Name) {
executingHooks = append(executingHooks, h)
} else {
skippedHooks = append(skippedHooks, h)
@@ -94,7 +96,7 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
rel.Hooks = executingHooks
}
- if err := r.cfg.execHook(rel, release.HookTest, r.Timeout); err != nil {
+ if err := r.cfg.execHook(rel, release.HookTest, kube.StatusWatcherStrategy, r.Timeout); err != nil {
rel.Hooks = append(skippedHooks, rel.Hooks...)
r.cfg.Releases.Update(rel)
return rel, err
@@ -110,7 +112,7 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
func (r *ReleaseTesting) GetPodLogs(out io.Writer, rel *release.Release) error {
client, err := r.cfg.KubernetesClientSet()
if err != nil {
- return errors.Wrap(err, "unable to get kubernetes client to fetch pod logs")
+ return fmt.Errorf("unable to get kubernetes client to fetch pod logs: %w", err)
}
hooksByWight := append([]*release.Hook{}, rel.Hooks...)
@@ -118,35 +120,26 @@ func (r *ReleaseTesting) GetPodLogs(out io.Writer, rel *release.Release) error {
for _, h := range hooksByWight {
for _, e := range h.Events {
if e == release.HookTest {
- if contains(r.Filters[ExcludeNameFilter], h.Name) {
+ if slices.Contains(r.Filters[ExcludeNameFilter], h.Name) {
continue
}
- if len(r.Filters[IncludeNameFilter]) > 0 && !contains(r.Filters[IncludeNameFilter], h.Name) {
+ 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 errors.Wrapf(err, "unable to get pod logs for %s", h.Name)
+ 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 errors.Wrapf(err, "unable to write pod logs for %s", h.Name)
+ return fmt.Errorf("unable to write pod logs for %s: %w", h.Name, err)
}
}
}
}
return nil
}
-
-func contains(arr []string, value string) bool {
- for _, item := range arr {
- if item == value {
- return true
- }
- }
- return false
-}
diff --git a/pkg/action/resource_policy.go b/pkg/action/resource_policy.go
index 63e83f3d9..b72e94124 100644
--- a/pkg/action/resource_policy.go
+++ b/pkg/action/resource_policy.go
@@ -19,8 +19,8 @@ package action
import (
"strings"
- "helm.sh/helm/v3/pkg/kube"
- "helm.sh/helm/v3/pkg/releaseutil"
+ "helm.sh/helm/v4/pkg/kube"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
)
func filterManifestsToKeep(manifests []releaseutil.Manifest) (keep, remaining []releaseutil.Manifest) {
diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go
index f4ae896e3..dd1f8c390 100644
--- a/pkg/action/rollback.go
+++ b/pkg/action/rollback.go
@@ -19,14 +19,14 @@ package action
import (
"bytes"
"fmt"
+ "log/slog"
"strings"
"time"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
// Rollback is the action for rolling back to a given release.
@@ -35,14 +35,16 @@ import (
type Rollback struct {
cfg *Configuration
- Version int
- Timeout time.Duration
- Wait bool
- WaitForJobs bool
- DisableHooks bool
- DryRun bool
- Recreate bool // will (if true) recreate pods after a rollback.
- Force bool // will (if true) force resource upgrade through uninstall/recreate if needed
+ Version int
+ Timeout time.Duration
+ WaitStrategy kube.WaitStrategy
+ WaitForJobs bool
+ DisableHooks bool
+ DryRun bool
+ // ForceReplace will, if set to `true`, ignore certain warnings and perform the rollback anyway.
+ //
+ // This should be used with caution.
+ ForceReplace bool
CleanupOnFail bool
MaxHistory int // MaxHistory limits the maximum number of revisions saved per release
}
@@ -62,26 +64,26 @@ func (r *Rollback) Run(name string) error {
r.cfg.Releases.MaxHistory = r.MaxHistory
- r.cfg.Log("preparing rollback of %s", name)
+ slog.Debug("preparing rollback", "name", name)
currentRelease, targetRelease, err := r.prepareRollback(name)
if err != nil {
return err
}
if !r.DryRun {
- r.cfg.Log("creating rolled back release for %s", name)
+ slog.Debug("creating rolled back release", "name", name)
if err := r.cfg.Releases.Create(targetRelease); err != nil {
return err
}
}
- r.cfg.Log("performing rollback of %s", name)
+ slog.Debug("performing rollback", "name", name)
if _, err := r.performRollback(currentRelease, targetRelease); err != nil {
return err
}
if !r.DryRun {
- r.cfg.Log("updating status for rolled back release for %s", name)
+ slog.Debug("updating status for rolled back release", "name", name)
if err := r.cfg.Releases.Update(targetRelease); err != nil {
return err
}
@@ -93,7 +95,7 @@ func (r *Rollback) Run(name string) error {
// the previous release's configuration
func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, error) {
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, nil, errors.Errorf("prepareRollback: Release name is invalid: %s", name)
+ return nil, nil, fmt.Errorf("prepareRollback: Release name is invalid: %s", name)
}
if r.Version < 0 {
@@ -125,10 +127,10 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
}
}
if !previousVersionExist {
- return nil, nil, errors.Errorf("release has no %d version", previousVersion)
+ return nil, nil, fmt.Errorf("release has no %d version", previousVersion)
}
- r.cfg.Log("rolling back %s (current: v%d, target: v%d)", name, currentRelease.Version, previousVersion)
+ slog.Debug("rolling back", "name", name, "currentVersion", currentRelease.Version, "targetVersion", previousVersion)
previousRelease, err := r.cfg.Releases.Get(name, previousVersion)
if err != nil {
@@ -151,6 +153,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
Description: fmt.Sprintf("Rollback to %d", previousVersion),
},
Version: currentRelease.Version + 1,
+ Labels: previousRelease.Labels,
Manifest: previousRelease.Manifest,
Hooks: previousRelease.Hooks,
}
@@ -160,89 +163,83 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release) (*release.Release, error) {
if r.DryRun {
- r.cfg.Log("dry run for %s", targetRelease.Name)
+ slog.Debug("dry run", "name", targetRelease.Name)
return targetRelease, nil
}
current, err := r.cfg.KubeClient.Build(bytes.NewBufferString(currentRelease.Manifest), false)
if err != nil {
- return targetRelease, errors.Wrap(err, "unable to build kubernetes objects from current release manifest")
+ return targetRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err)
}
target, err := r.cfg.KubeClient.Build(bytes.NewBufferString(targetRelease.Manifest), false)
if err != nil {
- return targetRelease, errors.Wrap(err, "unable to build kubernetes objects from new release manifest")
+ return targetRelease, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err)
}
// pre-rollback hooks
if !r.DisableHooks {
- if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.Timeout); err != nil {
+ if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.Timeout); err != nil {
return targetRelease, err
}
} else {
- r.cfg.Log("rollback hooks disabled for %s", targetRelease.Name)
+ slog.Debug("rollback hooks disabled", "name", targetRelease.Name)
}
// It is safe to use "force" here because these are resources currently rendered by the chart.
err = target.Visit(setMetadataVisitor(targetRelease.Name, targetRelease.Namespace, true))
if err != nil {
- return targetRelease, errors.Wrap(err, "unable to set metadata visitor from target release")
+ return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err)
}
- results, err := r.cfg.KubeClient.Update(current, target, r.Force)
+ results, err := r.cfg.KubeClient.Update(
+ current,
+ target,
+ kube.ClientUpdateOptionServerSideApply(false, false),
+ kube.ClientUpdateOptionForceReplace(r.ForceReplace))
if err != nil {
msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)
- r.cfg.Log("warning: %s", msg)
+ slog.Warn(msg)
currentRelease.Info.Status = release.StatusSuperseded
targetRelease.Info.Status = release.StatusFailed
targetRelease.Info.Description = msg
r.cfg.recordRelease(currentRelease)
r.cfg.recordRelease(targetRelease)
if r.CleanupOnFail {
- r.cfg.Log("Cleanup on fail set, cleaning up %d resources", len(results.Created))
+ slog.Debug("cleanup on fail set, cleaning up resources", "count", len(results.Created))
_, errs := r.cfg.KubeClient.Delete(results.Created)
if errs != nil {
- var errorList []string
- for _, e := range errs {
- errorList = append(errorList, e.Error())
- }
- return targetRelease, errors.Wrapf(fmt.Errorf("unable to cleanup resources: %s", strings.Join(errorList, ", ")), "an error occurred while cleaning up resources. original rollback error: %s", err)
+ return targetRelease, fmt.Errorf(
+ "an error occurred while cleaning up resources. original rollback error: %w",
+ fmt.Errorf("unable to cleanup resources: %w", joinErrors(errs, ", ")))
}
- r.cfg.Log("Resource cleanup complete")
+ slog.Debug("resource cleanup complete")
}
return targetRelease, err
}
- if r.Recreate {
- // NOTE: Because this is not critical for a release to succeed, we just
- // log if an error occurs and continue onward. If we ever introduce log
- // levels, we should make these error level logs so users are notified
- // that they'll need to go do the cleanup on their own
- if err := recreate(r.cfg, results.Updated); err != nil {
- r.cfg.Log(err.Error())
- }
+ waiter, err := r.cfg.KubeClient.GetWaiter(r.WaitStrategy)
+ if err != nil {
+ return nil, fmt.Errorf("unable to set metadata visitor from target release: %w", err)
}
-
- if r.Wait {
- if r.WaitForJobs {
- if err := r.cfg.KubeClient.WaitWithJobs(target, r.Timeout); err != nil {
- targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error()))
- r.cfg.recordRelease(currentRelease)
- r.cfg.recordRelease(targetRelease)
- return targetRelease, errors.Wrapf(err, "release %s failed", targetRelease.Name)
- }
- } else {
- if err := r.cfg.KubeClient.Wait(target, r.Timeout); err != nil {
- targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error()))
- r.cfg.recordRelease(currentRelease)
- r.cfg.recordRelease(targetRelease)
- return targetRelease, errors.Wrapf(err, "release %s failed", targetRelease.Name)
- }
+ if r.WaitForJobs {
+ if err := waiter.WaitWithJobs(target, r.Timeout); err != nil {
+ targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error()))
+ r.cfg.recordRelease(currentRelease)
+ r.cfg.recordRelease(targetRelease)
+ return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err)
+ }
+ } else {
+ if err := waiter.Wait(target, r.Timeout); err != nil {
+ targetRelease.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", targetRelease.Name, err.Error()))
+ r.cfg.recordRelease(currentRelease)
+ r.cfg.recordRelease(targetRelease)
+ return targetRelease, fmt.Errorf("release %s failed: %w", targetRelease.Name, err)
}
}
// post-rollback hooks
if !r.DisableHooks {
- if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.Timeout); err != nil {
+ if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.Timeout); err != nil {
return targetRelease, err
}
}
@@ -253,7 +250,7 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
}
// Supersede all previous deployments, see issue #2941.
for _, rel := range deployed {
- r.cfg.Log("superseding previous deployment %d", rel.Version)
+ slog.Debug("superseding previous deployment", "version", rel.Version)
rel.Info.Status = release.StatusSuperseded
r.cfg.recordRelease(rel)
}
diff --git a/pkg/action/show.go b/pkg/action/show.go
index 6ed855b83..6d6e10d24 100644
--- a/pkg/action/show.go
+++ b/pkg/action/show.go
@@ -21,14 +21,13 @@ import (
"fmt"
"strings"
- "github.com/pkg/errors"
"k8s.io/cli-runtime/pkg/printers"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/registry"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/registry"
)
// ShowOutputFormat is the format of the output of `helm show`
@@ -65,27 +64,18 @@ type Show struct {
}
// NewShow creates a new Show object with the given configuration.
-// Deprecated: Use NewShowWithConfig
-// TODO Helm 4: Fold NewShowWithConfig back into NewShow
-func NewShow(output ShowOutputFormat) *Show {
- return &Show{
- OutputFormat: output,
- }
-}
-
-// NewShowWithConfig creates a new Show object with the given configuration.
-func NewShowWithConfig(output ShowOutputFormat, cfg *Configuration) *Show {
+func NewShow(output ShowOutputFormat, cfg *Configuration) *Show {
sh := &Show{
OutputFormat: output,
}
- sh.ChartPathOptions.registryClient = cfg.RegistryClient
+ sh.registryClient = cfg.RegistryClient
return sh
}
// SetRegistryClient sets the registry client to use when pulling a chart from a registry.
func (s *Show) SetRegistryClient(client *registry.Client) {
- s.ChartPathOptions.registryClient = client
+ s.registryClient = client
}
// Run executes 'helm show' against the given release.
@@ -114,7 +104,7 @@ func (s *Show) Run(chartpath string) (string, error) {
if s.JSONPathTemplate != "" {
printer, err := printers.NewJSONPathPrinter(s.JSONPathTemplate)
if err != nil {
- return "", errors.Wrapf(err, "error parsing jsonpath %s", s.JSONPathTemplate)
+ return "", fmt.Errorf("error parsing jsonpath %s: %w", s.JSONPathTemplate, err)
}
printer.Execute(&out, s.chart.Values)
} else {
@@ -139,10 +129,10 @@ func (s *Show) Run(chartpath string) (string, error) {
if s.OutputFormat == ShowCRDs || s.OutputFormat == ShowAll {
crds := s.chart.CRDObjects()
if len(crds) > 0 {
- if s.OutputFormat == ShowAll && !bytes.HasPrefix(crds[0].File.Data, []byte("---")) {
- fmt.Fprintln(&out, "---")
- }
for _, crd := range crds {
+ if !bytes.HasPrefix(crd.File.Data, []byte("---")) {
+ fmt.Fprintln(&out, "---")
+ }
fmt.Fprintf(&out, "%s\n", string(crd.File.Data))
}
}
diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go
index 8b617ea85..67eba2338 100644
--- a/pkg/action/show_test.go
+++ b/pkg/action/show_test.go
@@ -19,12 +19,12 @@ package action
import (
"testing"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
func TestShow(t *testing.T) {
config := actionConfigFixture(t)
- client := NewShowWithConfig(ShowAll, config)
+ client := NewShow(ShowAll, config)
client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"},
Files: []*chart.File{
@@ -32,6 +32,7 @@ func TestShow(t *testing.T) {
{Name: "crds/ignoreme.txt", Data: []byte("error")},
{Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")},
{Name: "crds/bar.json", Data: []byte("---\nbar\n")},
+ {Name: "crds/baz.yaml", Data: []byte("baz\n")},
},
Raw: []*chart.File{
{Name: "values.yaml", Data: []byte("VALUES\n")},
@@ -58,6 +59,9 @@ foo
---
bar
+---
+baz
+
`
if output != expect {
t.Errorf("Expected\n%q\nGot\n%q\n", expect, output)
@@ -65,7 +69,8 @@ bar
}
func TestShowNoValues(t *testing.T) {
- client := NewShow(ShowAll)
+ config := actionConfigFixture(t)
+ client := NewShow(ShowAll, config)
client.chart = new(chart.Chart)
// Regression tests for missing values. See issue #1024.
@@ -81,7 +86,8 @@ func TestShowNoValues(t *testing.T) {
}
func TestShowValuesByJsonPathFormat(t *testing.T) {
- client := NewShow(ShowValues)
+ config := actionConfigFixture(t)
+ client := NewShow(ShowValues, config)
client.JSONPathTemplate = "{$.nestedKey.simpleKey}"
client.chart = buildChart(withSampleValues())
output, err := client.Run("")
@@ -95,13 +101,15 @@ func TestShowValuesByJsonPathFormat(t *testing.T) {
}
func TestShowCRDs(t *testing.T) {
- client := NewShow(ShowCRDs)
+ config := actionConfigFixture(t)
+ client := NewShow(ShowCRDs, config)
client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"},
Files: []*chart.File{
{Name: "crds/ignoreme.txt", Data: []byte("error")},
{Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")},
{Name: "crds/bar.json", Data: []byte("---\nbar\n")},
+ {Name: "crds/baz.yaml", Data: []byte("baz\n")},
},
}
@@ -116,6 +124,9 @@ foo
---
bar
+---
+baz
+
`
if output != expect {
t.Errorf("Expected\n%q\nGot\n%q\n", expect, output)
@@ -123,7 +134,8 @@ bar
}
func TestShowNoReadme(t *testing.T) {
- client := NewShow(ShowAll)
+ config := actionConfigFixture(t)
+ client := NewShow(ShowAll, config)
client.chart = &chart.Chart{
Metadata: &chart.Metadata{Name: "alpine"},
Files: []*chart.File{
diff --git a/pkg/action/status.go b/pkg/action/status.go
index ee1c9d613..509c52cd9 100644
--- a/pkg/action/status.go
+++ b/pkg/action/status.go
@@ -20,8 +20,8 @@ import (
"bytes"
"errors"
- "helm.sh/helm/v3/pkg/kube"
- "helm.sh/helm/v3/pkg/release"
+ "helm.sh/helm/v4/pkg/kube"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// Status is the action for checking the deployment status of releases.
@@ -32,15 +32,6 @@ type Status struct {
Version int
- // If true, display description to output format,
- // only affect print type table.
- // TODO Helm 4: Remove this flag and output the description by default.
- ShowDescription bool
-
- // ShowResources sets if the resources should be retrieved with the status.
- // TODO Helm 4: Remove this flag and output the resources by default.
- ShowResources bool
-
// ShowResourcesTable is used with ShowResources. When true this will cause
// the resulting objects to be retrieved as a kind=table.
ShowResourcesTable bool
@@ -59,10 +50,6 @@ func (s *Status) Run(name string) (*release.Release, error) {
return nil, err
}
- if !s.ShowResources {
- return s.cfg.releaseContent(name, s.Version)
- }
-
rel, err := s.cfg.releaseContent(name, s.Version)
if err != nil {
return nil, err
diff --git a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml
index 3cb66dafd..98c70aad4 100755
--- a/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml
+++ b/pkg/action/testdata/charts/chart-with-uncompressed-dependencies/values.yaml
@@ -74,7 +74,7 @@ externalDatabase:
## Database host
host: localhost
- ## non-root Username for Wordpress Database
+ ## non-root Username for WordPress Database
user: bn_wordpress
## Database password
@@ -102,7 +102,7 @@ mariadb:
db:
name: bitnami_wordpress
user: bn_wordpress
- ## If the password is not specified, mariadb will generates a random password
+ ## If the password is not specified, mariadb will generate a random password
##
# password:
@@ -165,7 +165,7 @@ readinessProbe:
successThreshold: 1
## Configure the ingress resource that allows you to access the
-## Wordpress installation. Set up the URL
+## WordPress installation. Set up the URL
## ref: http://kubernetes.io/docs/user-guide/ingress/
##
ingress:
diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go
index 40d82243e..163af290e 100644
--- a/pkg/action/uninstall.go
+++ b/pkg/action/uninstall.go
@@ -17,18 +17,20 @@ limitations under the License.
package action
import (
+ "errors"
+ "fmt"
+ "log/slog"
"strings"
"time"
- "github.com/pkg/errors"
-
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/kube"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
- helmtime "helm.sh/helm/v3/pkg/time"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
// Uninstall is the action for uninstalling releases.
@@ -41,7 +43,7 @@ type Uninstall struct {
DryRun bool
IgnoreNotFound bool
KeepHistory bool
- Wait bool
+ WaitStrategy kube.WaitStrategy
DeletionPropagation string
Timeout time.Duration
Description string
@@ -60,17 +62,24 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
return nil, err
}
+ waiter, err := u.cfg.KubeClient.GetWaiter(u.WaitStrategy)
+ if err != nil {
+ return nil, err
+ }
+
if u.DryRun {
- // In the dry run case, just see if the release exists
r, err := u.cfg.releaseContent(name, 0)
if err != nil {
+ if u.IgnoreNotFound && errors.Is(err, driver.ErrReleaseNotFound) {
+ return nil, nil
+ }
return &release.UninstallReleaseResponse{}, err
}
return &release.UninstallReleaseResponse{Release: r}, nil
}
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, errors.Errorf("uninstall: Release name is invalid: %s", name)
+ return nil, fmt.Errorf("uninstall: Release name is invalid: %s", name)
}
rels, err := u.cfg.Releases.History(name)
@@ -78,7 +87,7 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
if u.IgnoreNotFound {
return nil, nil
}
- return nil, errors.Wrapf(err, "uninstall: Release not loaded: %s", name)
+ return nil, fmt.Errorf("uninstall: Release not loaded: %s: %w", name, err)
}
if len(rels) < 1 {
return nil, errMissingRelease
@@ -92,37 +101,37 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
if rel.Info.Status == release.StatusUninstalled {
if !u.KeepHistory {
if err := u.purgeReleases(rels...); err != nil {
- return nil, errors.Wrap(err, "uninstall: Failed to purge the release")
+ return nil, fmt.Errorf("uninstall: Failed to purge the release: %w", err)
}
return &release.UninstallReleaseResponse{Release: rel}, nil
}
- return nil, errors.Errorf("the release named %q is already deleted", name)
+ return nil, fmt.Errorf("the release named %q is already deleted", name)
}
- u.cfg.Log("uninstall: Deleting %s", name)
+ slog.Debug("uninstall: deleting release", "name", name)
rel.Info.Status = release.StatusUninstalling
rel.Info.Deleted = helmtime.Now()
rel.Info.Description = "Deletion in progress (or silently failed)"
res := &release.UninstallReleaseResponse{Release: rel}
if !u.DisableHooks {
- if err := u.cfg.execHook(rel, release.HookPreDelete, u.Timeout); err != nil {
+ if err := u.cfg.execHook(rel, release.HookPreDelete, u.WaitStrategy, u.Timeout); err != nil {
return res, err
}
} else {
- u.cfg.Log("delete hooks disabled for %s", name)
+ slog.Debug("delete hooks disabled", "release", name)
}
// From here on out, the release is currently considered to be in StatusUninstalling
// state.
if err := u.cfg.Releases.Update(rel); err != nil {
- u.cfg.Log("uninstall: Failed to store updated release: %s", err)
+ slog.Debug("uninstall: Failed to store updated release", slog.Any("error", err))
}
deletedResources, kept, errs := u.deleteRelease(rel)
if errs != nil {
- u.cfg.Log("uninstall: Failed to delete release: %s", errs)
- return nil, errors.Errorf("failed to delete release: %s", name)
+ slog.Debug("uninstall: Failed to delete release", slog.Any("error", errs))
+ return nil, fmt.Errorf("failed to delete release: %s", name)
}
if kept != "" {
@@ -130,16 +139,12 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
}
res.Info = kept
- if u.Wait {
- if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceExt); ok {
- if err := kubeClient.WaitForDelete(deletedResources, u.Timeout); err != nil {
- errs = append(errs, err)
- }
- }
+ if err := waiter.WaitForDelete(deletedResources, u.Timeout); err != nil {
+ errs = append(errs, err)
}
if !u.DisableHooks {
- if err := u.cfg.execHook(rel, release.HookPostDelete, u.Timeout); err != nil {
+ if err := u.cfg.execHook(rel, release.HookPostDelete, u.WaitStrategy, u.Timeout); err != nil {
errs = append(errs, err)
}
}
@@ -152,26 +157,26 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
}
if !u.KeepHistory {
- u.cfg.Log("purge requested for %s", name)
+ slog.Debug("purge requested", "release", name)
err := u.purgeReleases(rels...)
if err != nil {
- errs = append(errs, errors.Wrap(err, "uninstall: Failed to purge the release"))
+ errs = append(errs, fmt.Errorf("uninstall: Failed to purge the release: %w", err))
}
// Return the errors that occurred while deleting the release, if any
if len(errs) > 0 {
- return res, errors.Errorf("uninstallation completed with %d error(s): %s", len(errs), joinErrors(errs))
+ return res, fmt.Errorf("uninstallation completed with %d error(s): %w", len(errs), joinErrors(errs, "; "))
}
return res, nil
}
if err := u.cfg.Releases.Update(rel); err != nil {
- u.cfg.Log("uninstall: Failed to store updated release: %s", err)
+ slog.Debug("uninstall: Failed to store updated release", slog.Any("error", err))
}
if len(errs) > 0 {
- return res, errors.Errorf("uninstallation completed with %d error(s): %s", len(errs), joinErrors(errs))
+ return res, fmt.Errorf("uninstallation completed with %d error(s): %w", len(errs), joinErrors(errs, "; "))
}
return res, nil
}
@@ -185,30 +190,42 @@ func (u *Uninstall) purgeReleases(rels ...*release.Release) error {
return nil
}
-func joinErrors(errs []error) string {
- es := make([]string, 0, len(errs))
- for _, e := range errs {
- es = append(es, e.Error())
+type joinedErrors struct {
+ errs []error
+ sep string
+}
+
+func joinErrors(errs []error, sep string) error {
+ return &joinedErrors{
+ errs: errs,
+ sep: sep,
+ }
+}
+
+func (e *joinedErrors) Error() string {
+ errs := make([]string, 0, len(e.errs))
+ for _, err := range e.errs {
+ errs = append(errs, err.Error())
}
- return strings.Join(es, "; ")
+ return strings.Join(errs, e.sep)
+}
+
+func (e *joinedErrors) Unwrap() []error {
+ return e.errs
}
// deleteRelease deletes the release and returns list of delete resources and manifests that were kept in the deletion process
func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, string, []error) {
var errs []error
- caps, err := u.cfg.getCapabilities()
- if err != nil {
- return nil, rel.Manifest, []error{errors.Wrap(err, "could not get apiVersions from Kubernetes")}
- }
manifests := releaseutil.SplitManifests(rel.Manifest)
- _, files, err := releaseutil.SortManifests(manifests, caps.APIVersions, releaseutil.UninstallOrder)
+ _, files, err := releaseutil.SortManifests(manifests, nil, releaseutil.UninstallOrder)
if err != nil {
// We could instead just delete everything in no particular order.
// FIXME: One way to delete at this point would be to try a label-based
// deletion. The problem with this is that we could get a false positive
// and delete something that was not legitimately part of this release.
- return nil, rel.Manifest, []error{errors.Wrap(err, "corrupted release record. You must manually delete the resources")}
+ return nil, rel.Manifest, []error{fmt.Errorf("corrupted release record. You must manually delete the resources: %w", err)}
}
filesToKeep, filesToDelete := filterManifestsToKeep(files)
@@ -224,11 +241,11 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri
resources, err := u.cfg.KubeClient.Build(strings.NewReader(builder.String()), false)
if err != nil {
- return nil, "", []error{errors.Wrap(err, "unable to build kubernetes objects for delete")}
+ return nil, "", []error{fmt.Errorf("unable to build kubernetes objects for delete: %w", err)}
}
if len(resources) > 0 {
if kubeClient, ok := u.cfg.KubeClient.(kube.InterfaceDeletionPropagation); ok {
- _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.cfg, u.DeletionPropagation))
+ _, errs = kubeClient.DeleteWithPropagationPolicy(resources, parseCascadingFlag(u.DeletionPropagation))
return resources, kept, errs
}
_, errs = u.cfg.KubeClient.Delete(resources)
@@ -236,7 +253,7 @@ func (u *Uninstall) deleteRelease(rel *release.Release) (kube.ResourceList, stri
return resources, kept, errs
}
-func parseCascadingFlag(cfg *Configuration, cascadingFlag string) v1.DeletionPropagation {
+func parseCascadingFlag(cascadingFlag string) v1.DeletionPropagation {
switch cascadingFlag {
case "orphan":
return v1.DeletePropagationOrphan
@@ -245,7 +262,7 @@ func parseCascadingFlag(cfg *Configuration, cascadingFlag string) v1.DeletionPro
case "background":
return v1.DeletePropagationBackground
default:
- cfg.Log("uninstall: given cascade value: %s, defaulting to delete propagation background", cascadingFlag)
+ slog.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 869ffb8c7..44bd66d96 100644
--- a/pkg/action/uninstall_test.go
+++ b/pkg/action/uninstall_test.go
@@ -22,16 +22,29 @@ import (
"github.com/stretchr/testify/assert"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/release"
+ "helm.sh/helm/v4/pkg/kube"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func uninstallAction(t *testing.T) *Uninstall {
+ t.Helper()
config := actionConfigFixture(t)
unAction := NewUninstall(config)
return unAction
}
+func TestUninstallRelease_dryRun_ignoreNotFound(t *testing.T) {
+ unAction := uninstallAction(t)
+ unAction.DryRun = true
+ unAction.IgnoreNotFound = true
+
+ is := assert.New(t)
+ res, err := unAction.Run("release-non-exist")
+ is.Nil(res)
+ is.NoError(err)
+}
+
func TestUninstallRelease_ignoreNotFound(t *testing.T) {
unAction := uninstallAction(t)
unAction.DryRun = false
@@ -42,7 +55,6 @@ func TestUninstallRelease_ignoreNotFound(t *testing.T) {
is.Nil(res)
is.NoError(err)
}
-
func TestUninstallRelease_deleteRelease(t *testing.T) {
is := assert.New(t)
@@ -82,7 +94,7 @@ func TestUninstallRelease_Wait(t *testing.T) {
unAction := uninstallAction(t)
unAction.DisableHooks = true
unAction.DryRun = false
- unAction.Wait = true
+ unAction.WaitStrategy = kube.StatusWatcherStrategy
rel := releaseStub()
rel.Name = "come-fail-away"
@@ -99,7 +111,7 @@ func TestUninstallRelease_Wait(t *testing.T) {
}`
unAction.cfg.Releases.Create(rel)
failer := unAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
- failer.WaitError = fmt.Errorf("U timed out")
+ failer.WaitForDeleteError = fmt.Errorf("U timed out")
unAction.cfg.KubeClient = failer
res, err := unAction.Run(rel.Name)
is.Error(err)
@@ -113,7 +125,7 @@ func TestUninstallRelease_Cascade(t *testing.T) {
unAction := uninstallAction(t)
unAction.DisableHooks = true
unAction.DryRun = false
- unAction.Wait = false
+ unAction.WaitStrategy = kube.HookOnlyStrategy
unAction.DeletionPropagation = "foreground"
rel := releaseStub()
diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go
index c74b502e2..065fa804e 100644
--- a/pkg/action/upgrade.go
+++ b/pkg/action/upgrade.go
@@ -19,23 +19,23 @@ package action
import (
"bytes"
"context"
+ "errors"
"fmt"
+ "log/slog"
"strings"
"sync"
"time"
- "github.com/pkg/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/cli-runtime/pkg/resource"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/kube"
- "helm.sh/helm/v3/pkg/postrender"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
- "helm.sh/helm/v3/pkg/storage/driver"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/kube"
+ "helm.sh/helm/v4/pkg/postrender"
+ "helm.sh/helm/v4/pkg/registry"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
// Upgrade is the action for upgrading releases.
@@ -64,8 +64,8 @@ type Upgrade struct {
SkipCRDs bool
// Timeout is the timeout for this operation
Timeout time.Duration
- // Wait determines whether the wait operation should be performed after the upgrade is requested.
- Wait bool
+ // WaitStrategy determines what type of waiting should be done
+ WaitStrategy kube.WaitStrategy
// WaitForJobs determines whether the wait operation for the Jobs should be performed after the upgrade is requested.
WaitForJobs bool
// DisableHooks disables hook processing if set to true.
@@ -74,28 +74,35 @@ type Upgrade struct {
DryRun bool
// DryRunOption controls whether the operation is prepared, but not executed with options on whether or not to interact with the remote cluster.
DryRunOption string
- // Force will, if set to `true`, ignore certain warnings and perform the upgrade anyway.
+ // HideSecret can be set to true when DryRun is enabled in order to hide
+ // Kubernetes Secrets in the output. It cannot be used outside of DryRun.
+ HideSecret bool
+ // ForceReplace will, if set to `true`, ignore certain warnings and perform the upgrade anyway.
//
// This should be used with caution.
- Force bool
+ ForceReplace bool
// ResetValues will reset the values to the chart's built-ins rather than merging with existing.
ResetValues bool
- // ReuseValues will re-use the user's last supplied values.
+ // ReuseValues will reuse the user's last supplied values.
ReuseValues bool
- // Recreate will (if true) recreate pods after a rollback.
- Recreate bool
+ // ResetThenReuseValues will reset the values to the chart's built-ins then merge with user's last supplied values.
+ ResetThenReuseValues bool
// MaxHistory limits the maximum number of revisions saved per release
MaxHistory int
- // Atomic, if true, will roll back on failure.
- Atomic bool
+ // RollbackOnFailure enables rolling back the upgraded release on failure
+ RollbackOnFailure bool
// CleanupOnFail will, if true, cause the upgrade to delete newly-created resources on a failed update.
CleanupOnFail bool
// SubNotes determines whether sub-notes are rendered in the chart.
SubNotes bool
+ // HideNotes determines whether notes are output during upgrade
+ HideNotes bool
+ // SkipSchemaValidation determines if JSON schema validation is disabled.
+ SkipSchemaValidation bool
// Description is the description of this operation
Description string
Labels map[string]string
- // PostRender is an optional post-renderer
+ // PostRenderer is an optional post-renderer
//
// 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.
@@ -108,6 +115,8 @@ type Upgrade struct {
Lock sync.Mutex
// Enable DNS lookups when rendering templates
EnableDNS bool
+ // TakeOwnership will skip the check for helm annotations and adopt all existing resources.
+ TakeOwnership bool
}
type resultMessage struct {
@@ -120,14 +129,14 @@ func NewUpgrade(cfg *Configuration) *Upgrade {
up := &Upgrade{
cfg: cfg,
}
- up.ChartPathOptions.registryClient = cfg.RegistryClient
+ up.registryClient = cfg.RegistryClient
return up
}
// SetRegistryClient sets the registry client to use when fetching charts.
func (u *Upgrade) SetRegistryClient(client *registry.Client) {
- u.ChartPathOptions.registryClient = client
+ u.registryClient = client
}
// Run executes the upgrade on the given release.
@@ -142,15 +151,17 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
return nil, err
}
- // Make sure if Atomic is set, that wait is set as well. This makes it so
+ // Make sure wait is set if RollbackOnFailure. This makes it so
// the user doesn't have to specify both
- u.Wait = u.Wait || u.Atomic
+ if u.WaitStrategy == kube.HookOnlyStrategy && u.RollbackOnFailure {
+ u.WaitStrategy = kube.StatusWatcherStrategy
+ }
if err := chartutil.ValidateReleaseName(name); err != nil {
- return nil, errors.Errorf("release name is invalid: %s", name)
+ return nil, fmt.Errorf("release name is invalid: %s", name)
}
- u.cfg.Log("preparing upgrade for %s", name)
+ slog.Debug("preparing upgrade", "name", name)
currentRelease, upgradedRelease, err := u.prepareUpgrade(name, chart, vals)
if err != nil {
return nil, err
@@ -158,7 +169,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
u.cfg.Releases.MaxHistory = u.MaxHistory
- u.cfg.Log("performing update for %s", name)
+ slog.Debug("performing update", "name", name)
res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease)
if err != nil {
return res, err
@@ -166,7 +177,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
// Do not update for dry runs
if !u.isDryRun() {
- u.cfg.Log("updating status for upgraded release for %s", name)
+ slog.Debug("updating status for upgraded release", "name", name)
if err := u.cfg.Releases.Update(upgradedRelease); err != nil {
return res, err
}
@@ -189,6 +200,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
return nil, nil, errMissingChart
}
+ // HideSecret must be used with dry run. Otherwise, return an error.
+ if !u.isDryRun() && u.HideSecret {
+ return nil, nil, errors.New("hiding Kubernetes secrets requires a dry-run mode")
+ }
+
// finds the last non-deleted release with the given name
lastRelease, err := u.cfg.Releases.Last(name)
if err != nil {
@@ -227,7 +243,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
return nil, nil, err
}
- if err := chartutil.ProcessDependenciesWithMerge(chart, vals); err != nil {
+ if err := chartutil.ProcessDependencies(chart, vals); err != nil {
return nil, nil, err
}
@@ -246,7 +262,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
if err != nil {
return nil, nil, err
}
- valuesToRender, err := chartutil.ToRenderValues(chart, vals, options, caps)
+ valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation)
if err != nil {
return nil, nil, err
}
@@ -257,13 +273,13 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
interactWithRemote = true
}
- hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS)
+ hooks, manifestDoc, notesTxt, err := u.cfg.renderResources(chart, valuesToRender, "", "", u.SubNotes, false, false, u.PostRenderer, interactWithRemote, u.EnableDNS, u.HideSecret)
if err != nil {
return nil, nil, err
}
if driver.ContainsSystemLabels(u.Labels) {
- return nil, nil, fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
+ return nil, nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
}
// Store an upgraded release.
@@ -297,15 +313,15 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
// Checking for removed Kubernetes API error so can provide a more informative error message to the user
// Ref: https://github.com/helm/helm/issues/7219
if strings.Contains(err.Error(), "unable to recognize \"\": no matches for kind") {
- return upgradedRelease, errors.Wrap(err, "current release manifest contains removed kubernetes api(s) for this "+
+ return upgradedRelease, fmt.Errorf("current release manifest contains removed kubernetes api(s) for this "+
"kubernetes version and it is therefore unable to build the kubernetes "+
- "objects for performing the diff. error from kubernetes")
+ "objects for performing the diff. error from kubernetes: %w", err)
}
- return upgradedRelease, errors.Wrap(err, "unable to build kubernetes objects from current release manifest")
+ return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from current release manifest: %w", err)
}
target, err := u.cfg.KubeClient.Build(bytes.NewBufferString(upgradedRelease.Manifest), !u.DisableOpenAPIValidation)
if err != nil {
- return upgradedRelease, errors.Wrap(err, "unable to build kubernetes objects from new release manifest")
+ return upgradedRelease, fmt.Errorf("unable to build kubernetes objects from new release manifest: %w", err)
}
// It is safe to use force only on target because these are resources currently rendered by the chart.
@@ -327,9 +343,14 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
}
}
- toBeUpdated, err := existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace)
+ var toBeUpdated kube.ResourceList
+ if u.TakeOwnership {
+ toBeUpdated, err = requireAdoption(toBeCreated)
+ } else {
+ toBeUpdated, err = existingResourceConflict(toBeCreated, upgradedRelease.Name, upgradedRelease.Namespace)
+ }
if err != nil {
- return nil, errors.Wrap(err, "Unable to continue with update")
+ return nil, fmt.Errorf("unable to continue with update: %w", err)
}
toBeUpdated.Visit(func(r *resource.Info, err error) error {
@@ -342,7 +363,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
// Run if it is a dry run
if u.isDryRun() {
- u.cfg.Log("dry run for %s", upgradedRelease.Name)
+ slog.Debug("dry run for release", "name", upgradedRelease.Name)
if len(u.Description) > 0 {
upgradedRelease.Info.Description = u.Description
} else {
@@ -351,7 +372,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
return upgradedRelease, nil
}
- u.cfg.Log("creating upgraded release for %s", upgradedRelease.Name)
+ slog.Debug("creating upgraded release", "name", upgradedRelease.Name)
if err := u.cfg.Releases.Create(upgradedRelease); err != nil {
return nil, err
}
@@ -369,7 +390,7 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
}
}
-// Function used to lock the Mutex, this is important for the case when the atomic flag is set.
+// Function used to lock the Mutex, this is important for the case when RollbackOnFailure is set.
// In that case the upgrade will finish before the rollback is finished so it is necessary to wait for the rollback to finish.
// The rollback will be trigger by the function failRelease
func (u *Upgrade) reportToPerformUpgrade(c chan<- resultMessage, rel *release.Release, created kube.ResourceList, err error) {
@@ -387,7 +408,7 @@ func (u *Upgrade) handleContext(ctx context.Context, done chan interface{}, c ch
case <-ctx.Done():
err := ctx.Err()
- // when the atomic flag is set the ongoing release finish first and doesn't give time for the rollback happens.
+ // when RollbackOnFailure is set, the ongoing release finish first and doesn't give time for the rollback happens.
u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, err)
case <-done:
return
@@ -397,53 +418,48 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
// pre-upgrade hooks
if !u.DisableHooks {
- if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.Timeout); err != nil {
+ if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.Timeout); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %s", err))
return
}
} else {
- u.cfg.Log("upgrade hooks disabled for %s", upgradedRelease.Name)
+ slog.Debug("upgrade hooks disabled", "name", upgradedRelease.Name)
}
- results, err := u.cfg.KubeClient.Update(current, target, u.Force)
+ results, err := u.cfg.KubeClient.Update(
+ current,
+ target,
+ kube.ClientUpdateOptionServerSideApply(false, false),
+ kube.ClientUpdateOptionForceReplace(u.ForceReplace))
if err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
return
}
- if u.Recreate {
- // NOTE: Because this is not critical for a release to succeed, we just
- // log if an error occurs and continue onward. If we ever introduce log
- // levels, we should make these error level logs so users are notified
- // that they'll need to go do the cleanup on their own
- if err := recreate(u.cfg, results.Updated); err != nil {
- u.cfg.Log(err.Error())
- }
+ waiter, err := u.cfg.KubeClient.GetWaiter(u.WaitStrategy)
+ if err != nil {
+ u.cfg.recordRelease(originalRelease)
+ u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
+ return
}
-
- if u.Wait {
- u.cfg.Log(
- "waiting for release %s resources (created: %d updated: %d deleted: %d)",
- upgradedRelease.Name, len(results.Created), len(results.Updated), len(results.Deleted))
- if u.WaitForJobs {
- if err := u.cfg.KubeClient.WaitWithJobs(target, u.Timeout); err != nil {
- u.cfg.recordRelease(originalRelease)
- u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
- return
- }
- } else {
- if err := u.cfg.KubeClient.Wait(target, u.Timeout); err != nil {
- u.cfg.recordRelease(originalRelease)
- u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
- return
- }
+ if u.WaitForJobs {
+ if err := waiter.WaitWithJobs(target, u.Timeout); err != nil {
+ u.cfg.recordRelease(originalRelease)
+ u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
+ return
+ }
+ } else {
+ if err := waiter.Wait(target, u.Timeout); err != nil {
+ u.cfg.recordRelease(originalRelease)
+ u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
+ return
}
}
// post-upgrade hooks
if !u.DisableHooks {
- if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.Timeout); err != nil {
+ if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.Timeout); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %s", err))
return
}
@@ -463,32 +479,36 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, err error) (*release.Release, error) {
msg := fmt.Sprintf("Upgrade %q failed: %s", rel.Name, err)
- u.cfg.Log("warning: %s", msg)
+ slog.Warn("upgrade failed", "name", rel.Name, slog.Any("error", err))
rel.Info.Status = release.StatusFailed
rel.Info.Description = msg
u.cfg.recordRelease(rel)
if u.CleanupOnFail && len(created) > 0 {
- u.cfg.Log("Cleanup on fail set, cleaning up %d resources", len(created))
+ slog.Debug("cleanup on fail set", "cleaning_resources", len(created))
_, errs := u.cfg.KubeClient.Delete(created)
if errs != nil {
- var errorList []string
- for _, e := range errs {
- errorList = append(errorList, e.Error())
- }
- return rel, errors.Wrapf(fmt.Errorf("unable to cleanup resources: %s", strings.Join(errorList, ", ")), "an error occurred while cleaning up resources. original upgrade error: %s", err)
+ return rel, fmt.Errorf(
+ "an error occurred while cleaning up resources. original upgrade error: %w: %w",
+ err,
+ fmt.Errorf(
+ "unable to cleanup resources: %w",
+ joinErrors(errs, ", "),
+ ),
+ )
}
- u.cfg.Log("Resource cleanup complete")
+ slog.Debug("resource cleanup complete")
}
- if u.Atomic {
- u.cfg.Log("Upgrade failed and atomic is set, rolling back to last successful release")
+
+ if u.RollbackOnFailure {
+ slog.Debug("Upgrade failed and rollback-on-failure is set, rolling back to previous successful release")
// As a protection, get the last successful release before rollback.
// If there are no successful releases, bail out
hist := NewHistory(u.cfg)
fullHistory, herr := hist.Run(rel.Name)
if herr != nil {
- return rel, errors.Wrapf(herr, "an error occurred while finding last successful release. original upgrade error: %s", err)
+ return rel, fmt.Errorf("an error occurred while finding last successful release. original upgrade error: %w: %w", err, herr)
}
// There isn't a way to tell if a previous release was successful, but
@@ -498,23 +518,24 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
return r.Info.Status == release.StatusSuperseded || r.Info.Status == release.StatusDeployed
}).Filter(fullHistory)
if len(filteredHistory) == 0 {
- return rel, errors.Wrap(err, "unable to find a previously successful release when attempting to rollback. original upgrade error")
+ return rel, fmt.Errorf("unable to find a previously successful release when attempting to rollback. original upgrade error: %w", err)
}
releaseutil.Reverse(filteredHistory, releaseutil.SortByRevision)
rollin := NewRollback(u.cfg)
rollin.Version = filteredHistory[0].Version
- rollin.Wait = true
+ if u.WaitStrategy == kube.HookOnlyStrategy {
+ rollin.WaitStrategy = kube.StatusWatcherStrategy
+ }
rollin.WaitForJobs = u.WaitForJobs
rollin.DisableHooks = u.DisableHooks
- rollin.Recreate = u.Recreate
- rollin.Force = u.Force
+ rollin.ForceReplace = u.ForceReplace
rollin.Timeout = u.Timeout
if rollErr := rollin.Run(rel.Name); rollErr != nil {
- return rel, errors.Wrapf(rollErr, "an error occurred while rolling back the release. original upgrade error: %s", err)
+ return rel, fmt.Errorf("an error occurred while rolling back the release. original upgrade error: %w: %w", err, rollErr)
}
- return rel, errors.Wrapf(err, "release %s failed, and has been rolled back due to atomic being set", rel.Name)
+ return rel, fmt.Errorf("release %s failed, and has been rolled back due to rollback-on-failure being set: %w", rel.Name, err)
}
return rel, err
@@ -531,18 +552,18 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newVals map[string]interface{}) (map[string]interface{}, error) {
if u.ResetValues {
// If ResetValues is set, we completely ignore current.Config.
- u.cfg.Log("resetting values to the chart's original version")
+ slog.Debug("resetting values to the chart's original version")
return newVals, nil
}
// If the ReuseValues flag is set, we always copy the old values over the new config's values.
if u.ReuseValues {
- u.cfg.Log("reusing the old release's values")
+ slog.Debug("reusing the old release's values")
// We have to regenerate the old coalesced values:
oldVals, err := chartutil.CoalesceValues(current.Chart, current.Config)
if err != nil {
- return nil, errors.Wrap(err, "failed to rebuild old values")
+ return nil, fmt.Errorf("failed to rebuild old values: %w", err)
}
newVals = chartutil.CoalesceTables(newVals, current.Config)
@@ -552,8 +573,17 @@ func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newV
return newVals, nil
}
+ // If the ResetThenReuseValues flag is set, we use the new chart's values, but we copy the old config's values over the new config's values.
+ if u.ResetThenReuseValues {
+ slog.Debug("merging values from old release to new values")
+
+ newVals = chartutil.CoalesceTables(newVals, current.Config)
+
+ return newVals, nil
+ }
+
if len(newVals) == 0 && len(current.Config) > 0 {
- u.cfg.Log("copying values from %s (v%d) to new release.", current.Name, current.Version)
+ slog.Debug("copying values from old release", "name", current.Name, "version", current.Version)
newVals = current.Config
}
return newVals, nil
@@ -564,42 +594,6 @@ func validateManifest(c kube.Interface, manifest []byte, openAPIValidation bool)
return err
}
-// recreate captures all the logic for recreating pods for both upgrade and
-// rollback. If we end up refactoring rollback to use upgrade, this can just be
-// made an unexported method on the upgrade action.
-func recreate(cfg *Configuration, resources kube.ResourceList) error {
- for _, res := range resources {
- versioned := kube.AsVersioned(res)
- selector, err := kube.SelectorsForObject(versioned)
- if err != nil {
- // If no selector is returned, it means this object is
- // definitely not a pod, so continue onward
- continue
- }
-
- client, err := cfg.KubernetesClientSet()
- if err != nil {
- return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name)
- }
-
- pods, err := client.CoreV1().Pods(res.Namespace).List(context.Background(), metav1.ListOptions{
- LabelSelector: selector.String(),
- })
- if err != nil {
- return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name)
- }
-
- // Restart pods
- for _, pod := range pods.Items {
- // Delete each pod for get them restarted with changed spec.
- if err := client.CoreV1().Pods(pod.Namespace).Delete(context.Background(), pod.Name, *metav1.NewPreconditionDeleteOptions(string(pod.UID))); err != nil {
- return errors.Wrapf(err, "unable to recreate pods for object %s/%s because an error occurred", res.Namespace, res.Name)
- }
- }
- }
- return nil
-}
-
func objectKey(r *resource.Info) string {
gvk := r.Object.GetObjectKind().GroupVersionKind()
return fmt.Sprintf("%s/%s/%s/%s", gvk.GroupVersion().String(), gvk.Kind, r.Namespace, r.Name)
diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go
index 77656e1c5..8ec727671 100644
--- a/pkg/action/upgrade_test.go
+++ b/pkg/action/upgrade_test.go
@@ -23,18 +23,20 @@ import (
"testing"
"time"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/storage/driver"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/kube"
+ "helm.sh/helm/v4/pkg/storage/driver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- kubefake "helm.sh/helm/v3/pkg/kube/fake"
- "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
func upgradeAction(t *testing.T) *Upgrade {
+ t.Helper()
config := actionConfigFixture(t)
upAction := NewUpgrade(config)
upAction.Namespace = "spaced"
@@ -52,10 +54,10 @@ func TestUpgradeRelease_Success(t *testing.T) {
rel.Info.Status = release.StatusDeployed
req.NoError(upAction.cfg.Releases.Create(rel))
- upAction.Wait = true
+ upAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{}
- ctx, done := context.WithCancel(context.Background())
+ ctx, done := context.WithCancel(t.Context())
res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals)
done()
req.NoError(err)
@@ -82,7 +84,7 @@ func TestUpgradeRelease_Wait(t *testing.T) {
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitError = fmt.Errorf("I timed out")
upAction.cfg.KubeClient = failer
- upAction.Wait = true
+ upAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals)
@@ -104,7 +106,7 @@ func TestUpgradeRelease_WaitForJobs(t *testing.T) {
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitError = fmt.Errorf("I timed out")
upAction.cfg.KubeClient = failer
- upAction.Wait = true
+ upAction.WaitStrategy = kube.StatusWatcherStrategy
upAction.WaitForJobs = true
vals := map[string]interface{}{}
@@ -128,7 +130,7 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) {
failer.WaitError = fmt.Errorf("I timed out")
failer.DeleteError = fmt.Errorf("I tried to delete nil")
upAction.cfg.KubeClient = failer
- upAction.Wait = true
+ upAction.WaitStrategy = kube.StatusWatcherStrategy
upAction.CleanupOnFail = true
vals := map[string]interface{}{}
@@ -139,11 +141,11 @@ func TestUpgradeRelease_CleanupOnFail(t *testing.T) {
is.Equal(res.Info.Status, release.StatusFailed)
}
-func TestUpgradeRelease_Atomic(t *testing.T) {
+func TestUpgradeRelease_RollbackOnFailure(t *testing.T) {
is := assert.New(t)
req := require.New(t)
- t.Run("atomic rollback succeeds", func(t *testing.T) {
+ t.Run("rollback-on-failure rollback succeeds", func(t *testing.T) {
upAction := upgradeAction(t)
rel := releaseStub()
@@ -155,13 +157,13 @@ func TestUpgradeRelease_Atomic(t *testing.T) {
// We can't make Update error because then the rollback won't work
failer.WatchUntilReadyError = fmt.Errorf("arming key removed")
upAction.cfg.KubeClient = failer
- upAction.Atomic = true
+ upAction.RollbackOnFailure = true
vals := map[string]interface{}{}
res, err := upAction.Run(rel.Name, buildChart(), vals)
req.Error(err)
is.Contains(err.Error(), "arming key removed")
- is.Contains(err.Error(), "atomic")
+ is.Contains(err.Error(), "rollback-on-failure")
// Now make sure it is actually upgraded
updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3)
@@ -170,7 +172,7 @@ func TestUpgradeRelease_Atomic(t *testing.T) {
is.Equal(updatedRes.Info.Status, release.StatusDeployed)
})
- t.Run("atomic uninstall fails", func(t *testing.T) {
+ t.Run("rollback-on-failure uninstall fails", func(t *testing.T) {
upAction := upgradeAction(t)
rel := releaseStub()
rel.Name = "fallout"
@@ -180,7 +182,7 @@ func TestUpgradeRelease_Atomic(t *testing.T) {
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.UpdateError = fmt.Errorf("update fail")
upAction.cfg.KubeClient = failer
- upAction.Atomic = true
+ upAction.RollbackOnFailure = true
vals := map[string]interface{}{}
_, err := upAction.Run(rel.Name, buildChart(), vals)
@@ -308,6 +310,59 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) {
})
}
+func TestUpgradeRelease_ResetThenReuseValues(t *testing.T) {
+ is := assert.New(t)
+
+ t.Run("reset then reuse values should work with values", func(t *testing.T) {
+ upAction := upgradeAction(t)
+
+ existingValues := map[string]interface{}{
+ "name": "value",
+ "maxHeapSize": "128m",
+ "replicas": 2,
+ }
+ newValues := map[string]interface{}{
+ "name": "newValue",
+ "maxHeapSize": "512m",
+ "cpu": "12m",
+ }
+ newChartValues := map[string]interface{}{
+ "memory": "256m",
+ }
+ expectedValues := map[string]interface{}{
+ "name": "newValue",
+ "maxHeapSize": "512m",
+ "cpu": "12m",
+ "replicas": 2,
+ }
+
+ rel := releaseStub()
+ rel.Name = "nuketown"
+ rel.Info.Status = release.StatusDeployed
+ rel.Config = existingValues
+
+ err := upAction.cfg.Releases.Create(rel)
+ is.NoError(err)
+
+ upAction.ResetThenReuseValues = true
+ // setting newValues and upgrading
+ res, err := upAction.Run(rel.Name, buildChart(withValues(newChartValues)), newValues)
+ is.NoError(err)
+
+ // Now make sure it is actually upgraded
+ updatedRes, err := upAction.cfg.Releases.Get(res.Name, 2)
+ is.NoError(err)
+
+ if updatedRes == nil {
+ is.Fail("Updated Release is nil")
+ return
+ }
+ is.Equal(release.StatusDeployed, updatedRes.Info.Status)
+ is.Equal(expectedValues, updatedRes.Config)
+ is.Equal(newChartValues, updatedRes.Chart.Values)
+ })
+}
+
func TestUpgradeRelease_Pending(t *testing.T) {
req := require.New(t)
@@ -329,7 +384,6 @@ func TestUpgradeRelease_Pending(t *testing.T) {
}
func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
-
is := assert.New(t)
req := require.New(t)
@@ -342,11 +396,10 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitDuration = 10 * time.Second
upAction.cfg.KubeClient = failer
- upAction.Wait = true
+ upAction.WaitStrategy = kube.StatusWatcherStrategy
vals := map[string]interface{}{}
- ctx := context.Background()
- ctx, cancel := context.WithCancel(ctx)
+ ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel)
res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals)
@@ -354,10 +407,9 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
req.Error(err)
is.Contains(res.Info.Description, "Upgrade \"interrupted-release\" failed: context canceled")
is.Equal(res.Info.Status, release.StatusFailed)
-
}
-func TestUpgradeRelease_Interrupted_Atomic(t *testing.T) {
+func TestUpgradeRelease_Interrupted_RollbackOnFailure(t *testing.T) {
is := assert.New(t)
req := require.New(t)
@@ -371,17 +423,16 @@ func TestUpgradeRelease_Interrupted_Atomic(t *testing.T) {
failer := upAction.cfg.KubeClient.(*kubefake.FailingKubeClient)
failer.WaitDuration = 5 * time.Second
upAction.cfg.KubeClient = failer
- upAction.Atomic = true
+ upAction.RollbackOnFailure = true
vals := map[string]interface{}{}
- ctx := context.Background()
- ctx, cancel := context.WithCancel(ctx)
+ ctx, cancel := context.WithCancel(t.Context())
time.AfterFunc(time.Second, cancel)
res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(), vals)
req.Error(err)
- is.Contains(err.Error(), "release interrupted-release failed, and has been rolled back due to atomic being set: context canceled")
+ is.Contains(err.Error(), "release interrupted-release failed, and has been rolled back due to rollback-on-failure being set: context canceled")
// Now make sure it is actually upgraded
updatedRes, err := upAction.cfg.Releases.Get(res.Name, 3)
@@ -391,7 +442,7 @@ func TestUpgradeRelease_Interrupted_Atomic(t *testing.T) {
}
func TestMergeCustomLabels(t *testing.T) {
- var tests = [][3]map[string]string{
+ tests := [][3]map[string]string{
{nil, nil, map[string]string{}},
{map[string]string{}, map[string]string{}, map[string]string{}},
{map[string]string{"k1": "v1", "k2": "v2"}, nil, map[string]string{"k1": "v1", "k2": "v2"}},
@@ -480,5 +531,56 @@ func TestUpgradeRelease_SystemLabels(t *testing.T) {
t.Fatal("expected an error")
}
- is.Equal(fmt.Errorf("user suplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err)
+ is.Equal(fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels()), err)
+}
+
+func TestUpgradeRelease_DryRun(t *testing.T) {
+ is := assert.New(t)
+ req := require.New(t)
+
+ upAction := upgradeAction(t)
+ rel := releaseStub()
+ rel.Name = "previous-release"
+ rel.Info.Status = release.StatusDeployed
+ req.NoError(upAction.cfg.Releases.Create(rel))
+
+ upAction.DryRun = true
+ vals := map[string]interface{}{}
+
+ ctx, done := context.WithCancel(t.Context())
+ res, err := upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals)
+ done()
+ req.NoError(err)
+ is.Equal(release.StatusPendingUpgrade, res.Info.Status)
+ is.Contains(res.Manifest, "kind: Secret")
+
+ lastRelease, err := upAction.cfg.Releases.Last(rel.Name)
+ req.NoError(err)
+ is.Equal(lastRelease.Info.Status, release.StatusDeployed)
+ is.Equal(1, lastRelease.Version)
+
+ // Test the case for hiding the secret to ensure it is not displayed
+ upAction.HideSecret = true
+ vals = map[string]interface{}{}
+
+ ctx, done = context.WithCancel(t.Context())
+ res, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals)
+ done()
+ req.NoError(err)
+ is.Equal(release.StatusPendingUpgrade, res.Info.Status)
+ is.NotContains(res.Manifest, "kind: Secret")
+
+ lastRelease, err = upAction.cfg.Releases.Last(rel.Name)
+ req.NoError(err)
+ is.Equal(lastRelease.Info.Status, release.StatusDeployed)
+ is.Equal(1, lastRelease.Version)
+
+ // Ensure in a dry run mode when using HideSecret
+ upAction.DryRun = false
+ vals = map[string]interface{}{}
+
+ ctx, done = context.WithCancel(t.Context())
+ _, err = upAction.RunWithContext(ctx, rel.Name, buildChart(withSampleSecret()), vals)
+ done()
+ req.Error(err)
}
diff --git a/pkg/action/validate.go b/pkg/action/validate.go
index 73eb1937b..761ccba47 100644
--- a/pkg/action/validate.go
+++ b/pkg/action/validate.go
@@ -18,14 +18,14 @@ package action
import (
"fmt"
+ "maps"
- "github.com/pkg/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/resource"
- "helm.sh/helm/v3/pkg/kube"
+ "helm.sh/helm/v4/pkg/kube"
)
var accessor = meta.NewAccessor()
@@ -37,6 +37,31 @@ const (
helmReleaseNamespaceAnnotation = "meta.helm.sh/release-namespace"
)
+// requireAdoption returns the subset of resources that already exist in the cluster.
+func requireAdoption(resources kube.ResourceList) (kube.ResourceList, error) {
+ var requireUpdate kube.ResourceList
+
+ err := resources.Visit(func(info *resource.Info, err error) error {
+ if err != nil {
+ return err
+ }
+
+ helper := resource.NewHelper(info.Client, info.Mapping)
+ _, err = helper.Get(info.Namespace, info.Name)
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return fmt.Errorf("could not get information about the resource %s: %w", resourceString(info), err)
+ }
+
+ requireUpdate.Append(info)
+ return nil
+ })
+
+ return requireUpdate, err
+}
+
func existingResourceConflict(resources kube.ResourceList, releaseName, releaseNamespace string) (kube.ResourceList, error) {
var requireUpdate kube.ResourceList
@@ -51,7 +76,7 @@ func existingResourceConflict(resources kube.ResourceList, releaseName, releaseN
if apierrors.IsNotFound(err) {
return nil
}
- return errors.Wrapf(err, "could not get information about the resource %s", resourceString(info))
+ return fmt.Errorf("could not get information about the resource %s: %w", resourceString(info), err)
}
// Allow adoption of the resource if it is managed by Helm and is annotated with correct release name and namespace.
@@ -88,11 +113,7 @@ func checkOwnership(obj runtime.Object, releaseName, releaseNamespace string) er
}
if len(errs) > 0 {
- err := errors.New("invalid ownership metadata")
- for _, e := range errs {
- err = fmt.Errorf("%w; %s", err, e)
- }
- return err
+ return fmt.Errorf("invalid ownership metadata; %w", joinErrors(errs, "; "))
}
return nil
@@ -109,16 +130,16 @@ func requireValue(meta map[string]string, k, v string) error {
return nil
}
-// setMetadataVisitor adds release tracking metadata to all resources. If force is enabled, existing
+// setMetadataVisitor adds release tracking metadata to all resources. If forceOwnership is enabled, existing
// ownership metadata will be overwritten. Otherwise an error will be returned if any resource has an
// existing and conflicting value for the managed by label or Helm release/namespace annotations.
-func setMetadataVisitor(releaseName, releaseNamespace string, force bool) resource.VisitorFunc {
+func setMetadataVisitor(releaseName, releaseNamespace string, forceOwnership bool) resource.VisitorFunc {
return func(info *resource.Info, err error) error {
if err != nil {
return err
}
- if !force {
+ if !forceOwnership {
if err := checkOwnership(info.Object, releaseName, releaseNamespace); err != nil {
return fmt.Errorf("%s cannot be owned: %s", resourceString(info), err)
}
@@ -174,11 +195,7 @@ func mergeAnnotations(obj runtime.Object, annotations map[string]string) error {
// merge two maps, always taking the value on the right
func mergeStrStrMaps(current, desired map[string]string) map[string]string {
result := make(map[string]string)
- for k, v := range current {
- result[k] = v
- }
- for k, desiredVal := range desired {
- result[k] = desiredVal
- }
+ maps.Copy(result, current)
+ maps.Copy(result, desired)
return result
}
diff --git a/pkg/action/validate_test.go b/pkg/action/validate_test.go
index a9c1cb49c..3efecd6ff 100644
--- a/pkg/action/validate_test.go
+++ b/pkg/action/validate_test.go
@@ -17,17 +17,23 @@ limitations under the License.
package action
import (
+ "bytes"
+ "io"
+ "net/http"
"testing"
- "helm.sh/helm/v3/pkg/kube"
-
- appsv1 "k8s.io/api/apps/v1"
+ "helm.sh/helm/v4/pkg/kube"
"github.com/stretchr/testify/assert"
+
+ appsv1 "k8s.io/api/apps/v1"
"k8s.io/apimachinery/pkg/api/meta"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/kubernetes/scheme"
+ "k8s.io/client-go/rest/fake"
)
func newDeploymentResource(name, namespace string) *resource.Info {
@@ -46,6 +52,117 @@ func newDeploymentResource(name, namespace string) *resource.Info {
}
}
+func newMissingDeployment(name, namespace string) *resource.Info {
+ info := &resource.Info{
+ Name: name,
+ Namespace: namespace,
+ Mapping: &meta.RESTMapping{
+ Resource: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+ GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ Scope: meta.RESTScopeNamespace,
+ },
+ Object: &appsv1.Deployment{
+ ObjectMeta: v1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ },
+ },
+ Client: fakeClientWith(http.StatusNotFound, appsV1GV, ""),
+ }
+
+ return info
+}
+
+func newDeploymentWithOwner(name, namespace string, labels map[string]string, annotations map[string]string) *resource.Info {
+ obj := &appsv1.Deployment{
+ ObjectMeta: v1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ Labels: labels,
+ Annotations: annotations,
+ },
+ }
+ return &resource.Info{
+ Name: name,
+ Namespace: namespace,
+ Mapping: &meta.RESTMapping{
+ Resource: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployment"},
+ GroupVersionKind: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
+ Scope: meta.RESTScopeNamespace,
+ },
+ Object: obj,
+ Client: fakeClientWith(http.StatusOK, appsV1GV, runtime.EncodeOrDie(appsv1Codec, obj)),
+ }
+}
+
+var (
+ appsV1GV = schema.GroupVersion{Group: "apps", Version: "v1"}
+ appsv1Codec = scheme.Codecs.CodecForVersions(scheme.Codecs.LegacyCodec(appsV1GV), scheme.Codecs.UniversalDecoder(appsV1GV), appsV1GV, appsV1GV)
+)
+
+func stringBody(body string) io.ReadCloser {
+ return io.NopCloser(bytes.NewReader([]byte(body)))
+}
+
+func fakeClientWith(code int, gv schema.GroupVersion, body string) *fake.RESTClient {
+ return &fake.RESTClient{
+ GroupVersion: gv,
+ NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
+ Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) {
+ header := http.Header{}
+ header.Set("Content-Type", runtime.ContentTypeJSON)
+ return &http.Response{
+ StatusCode: code,
+ Header: header,
+ Body: stringBody(body),
+ }, nil
+ }),
+ }
+}
+
+func TestRequireAdoption(t *testing.T) {
+ var (
+ missing = newMissingDeployment("missing", "ns-a")
+ existing = newDeploymentWithOwner("existing", "ns-a", nil, nil)
+ resources = kube.ResourceList{missing, existing}
+ )
+
+ // Verify that a resource that lacks labels/annotations can be adopted
+ found, err := requireAdoption(resources)
+ assert.NoError(t, err)
+ assert.Len(t, found, 1)
+ assert.Equal(t, found[0], existing)
+}
+
+func TestExistingResourceConflict(t *testing.T) {
+ var (
+ releaseName = "rel-name"
+ releaseNamespace = "rel-namespace"
+ labels = map[string]string{
+ appManagedByLabel: appManagedByHelm,
+ }
+ annotations = map[string]string{
+ helmReleaseNameAnnotation: releaseName,
+ helmReleaseNamespaceAnnotation: releaseNamespace,
+ }
+ missing = newMissingDeployment("missing", "ns-a")
+ existing = newDeploymentWithOwner("existing", "ns-a", labels, annotations)
+ conflict = newDeploymentWithOwner("conflict", "ns-a", nil, nil)
+ resources = kube.ResourceList{missing, existing}
+ )
+
+ // Verify only existing resources are returned
+ found, err := existingResourceConflict(resources, releaseName, releaseNamespace)
+ assert.NoError(t, err)
+ assert.Len(t, found, 1)
+ assert.Equal(t, found[0], existing)
+
+ // Verify that an existing resource that lacks labels/annotations results in an error
+ resources = append(resources, conflict)
+ _, err = existingResourceConflict(resources, releaseName, releaseNamespace)
+ assert.Error(t, err)
+}
+
func TestCheckOwnership(t *testing.T) {
deployFoo := newDeploymentResource("foo", "ns-a")
diff --git a/pkg/action/verify.go b/pkg/action/verify.go
index f36239496..ca2f4fa63 100644
--- a/pkg/action/verify.go
+++ b/pkg/action/verify.go
@@ -20,7 +20,7 @@ import (
"fmt"
"strings"
- "helm.sh/helm/v3/pkg/downloader"
+ "helm.sh/helm/v4/pkg/downloader"
)
// Verify is the action for building a given chart's Verify tree.
@@ -39,7 +39,7 @@ func NewVerify() *Verify {
// Run executes 'helm verify'.
func (v *Verify) Run(chartfile string) error {
var out strings.Builder
- p, err := downloader.VerifyChart(chartfile, v.Keyring)
+ p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring)
if err != nil {
return err
}
diff --git a/pkg/chart/chart.go b/pkg/chart/v2/chart.go
similarity index 96%
rename from pkg/chart/chart.go
rename to pkg/chart/v2/chart.go
index a3bed63a3..66ddf98a5 100644
--- a/pkg/chart/chart.go
+++ b/pkg/chart/v2/chart.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v2
import (
"path/filepath"
@@ -113,6 +113,8 @@ func (ch *Chart) ChartPath() string {
}
// ChartFullPath returns the full path to this chart.
+// Note that the path may not correspond to the path where the file can be found on the file system if the path
+// points to an aliased subchart.
func (ch *Chart) ChartFullPath() string {
if !ch.IsRoot() {
return ch.Parent().ChartFullPath() + "/charts/" + ch.Name()
diff --git a/pkg/chart/chart_test.go b/pkg/chart/v2/chart_test.go
similarity index 99%
rename from pkg/chart/chart_test.go
rename to pkg/chart/v2/chart_test.go
index 62d60765c..d6311085b 100644
--- a/pkg/chart/chart_test.go
+++ b/pkg/chart/v2/chart_test.go
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v2
import (
"encoding/json"
diff --git a/pkg/chart/v2/dependency.go b/pkg/chart/v2/dependency.go
new file mode 100644
index 000000000..8a590a036
--- /dev/null
+++ b/pkg/chart/v2/dependency.go
@@ -0,0 +1,82 @@
+/*
+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 v2
+
+import "time"
+
+// Dependency describes a chart upon which another chart depends.
+//
+// Dependencies can be used to express developer intent, or to capture the state
+// of a chart.
+type Dependency struct {
+ // Name is the name of the dependency.
+ //
+ // This must mach the name in the dependency's Chart.yaml.
+ Name string `json:"name" yaml:"name"`
+ // Version is the version (range) of this chart.
+ //
+ // A lock file will always produce a single version, while a dependency
+ // may contain a semantic version range.
+ Version string `json:"version,omitempty" yaml:"version,omitempty"`
+ // The URL to the repository.
+ //
+ // Appending `index.yaml` to this string should result in a URL that can be
+ // used to fetch the repository index.
+ Repository string `json:"repository" yaml:"repository"`
+ // A yaml path that resolves to a boolean, used for enabling/disabling charts (e.g. subchart1.enabled )
+ Condition string `json:"condition,omitempty" yaml:"condition,omitempty"`
+ // Tags can be used to group charts for enabling/disabling together
+ Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
+ // Enabled bool determines if chart should be loaded
+ 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"`
+ // Alias usable alias to be used for the chart
+ Alias string `json:"alias,omitempty" yaml:"alias,omitempty"`
+}
+
+// Validate checks for common problems with the dependency datastructure in
+// the chart. This check must be done at load time before the dependency's charts are
+// loaded.
+func (d *Dependency) Validate() error {
+ if d == nil {
+ return ValidationError("dependencies must not contain empty or null nodes")
+ }
+ d.Name = sanitizeString(d.Name)
+ d.Version = sanitizeString(d.Version)
+ d.Repository = sanitizeString(d.Repository)
+ d.Condition = sanitizeString(d.Condition)
+ for i := range d.Tags {
+ d.Tags[i] = sanitizeString(d.Tags[i])
+ }
+ if d.Alias != "" && !aliasNameFormat.MatchString(d.Alias) {
+ return ValidationErrorf("dependency %q has disallowed characters in the alias", d.Name)
+ }
+ return nil
+}
+
+// Lock is a lock file for dependencies.
+//
+// It represents the state that the dependencies should be in.
+type Lock struct {
+ // Generated is the date the lock file was last generated.
+ Generated time.Time `json:"generated"`
+ // Digest is a hash of the dependencies in Chart.yaml.
+ Digest string `json:"digest"`
+ // Dependencies is the list of dependencies that this lock file has locked.
+ Dependencies []*Dependency `json:"dependencies"`
+}
diff --git a/pkg/chart/v2/dependency_test.go b/pkg/chart/v2/dependency_test.go
new file mode 100644
index 000000000..35919bd7a
--- /dev/null
+++ b/pkg/chart/v2/dependency_test.go
@@ -0,0 +1,44 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package v2
+
+import (
+ "testing"
+)
+
+func TestValidateDependency(t *testing.T) {
+ dep := &Dependency{
+ Name: "example",
+ }
+ for value, shouldFail := range map[string]bool{
+ "abcdefghijklmenopQRSTUVWXYZ-0123456780_": false,
+ "-okay": false,
+ "_okay": false,
+ "- bad": true,
+ " bad": true,
+ "bad\nvalue": true,
+ "bad ": true,
+ "bad$": true,
+ } {
+ dep.Alias = value
+ res := dep.Validate()
+ if res != nil && !shouldFail {
+ t.Errorf("Failed on case %q", dep.Alias)
+ } else if res == nil && shouldFail {
+ t.Errorf("Expected failure for %q", dep.Alias)
+ }
+ }
+}
diff --git a/pkg/chart/v2/doc.go b/pkg/chart/v2/doc.go
new file mode 100644
index 000000000..d36ca3ec4
--- /dev/null
+++ b/pkg/chart/v2/doc.go
@@ -0,0 +1,23 @@
+/*
+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 v2 provides chart handling for apiVersion v1 and v2 charts
+
+This package and its sub-packages provide handling for apiVersion v1 and v2 charts.
+The changes from v1 to v2 charts are minor and were able to be handled with minor
+switches based on characteristics.
+*/
+package v2
diff --git a/pkg/chart/v2/errors.go b/pkg/chart/v2/errors.go
new file mode 100644
index 000000000..eeef75315
--- /dev/null
+++ b/pkg/chart/v2/errors.go
@@ -0,0 +1,30 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2
+
+import "fmt"
+
+// ValidationError represents a data validation error.
+type ValidationError string
+
+func (v ValidationError) Error() string {
+ return "validation: " + string(v)
+}
+
+// ValidationErrorf takes a message and formatting options and creates a ValidationError
+func ValidationErrorf(msg string, args ...interface{}) ValidationError {
+ return ValidationError(fmt.Sprintf(msg, args...))
+}
diff --git a/pkg/chart/v2/file.go b/pkg/chart/v2/file.go
new file mode 100644
index 000000000..a2eeb0fcd
--- /dev/null
+++ b/pkg/chart/v2/file.go
@@ -0,0 +1,27 @@
+/*
+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 v2
+
+// File represents a file as a name/value pair.
+//
+// By convention, name is a relative path within the scope of the chart's
+// base directory.
+type File struct {
+ // Name is the path-like name of the template.
+ Name string `json:"name"`
+ // Data is the template as byte data.
+ Data []byte `json:"data"`
+}
diff --git a/pkg/chart/v2/fuzz_test.go b/pkg/chart/v2/fuzz_test.go
new file mode 100644
index 000000000..a897ef7b9
--- /dev/null
+++ b/pkg/chart/v2/fuzz_test.go
@@ -0,0 +1,48 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v2
+
+import (
+ "testing"
+
+ fuzz "github.com/AdaLogics/go-fuzz-headers"
+)
+
+func FuzzMetadataValidate(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ fdp := fuzz.NewConsumer(data)
+ // Add random values to the metadata
+ md := &Metadata{}
+ err := fdp.GenerateStruct(md)
+ if err != nil {
+ t.Skip()
+ }
+ md.Validate()
+ })
+}
+
+func FuzzDependencyValidate(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ f := fuzz.NewConsumer(data)
+ // Add random values to the dependenci
+ d := &Dependency{}
+ err := f.GenerateStruct(d)
+ if err != nil {
+ t.Skip()
+ }
+ d.Validate()
+ })
+}
diff --git a/pkg/chart/loader/archive.go b/pkg/chart/v2/loader/archive.go
similarity index 76%
rename from pkg/chart/loader/archive.go
rename to pkg/chart/v2/loader/archive.go
index 196e5f81d..b9f370f56 100644
--- a/pkg/chart/loader/archive.go
+++ b/pkg/chart/v2/loader/archive.go
@@ -20,6 +20,7 @@ import (
"archive/tar"
"bytes"
"compress/gzip"
+ "errors"
"fmt"
"io"
"net/http"
@@ -28,11 +29,18 @@ import (
"regexp"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
+// MaxDecompressedChartSize is the maximum size of a chart archive that will be
+// decompressed. This is the decompressed size of all the files.
+// The default value is 100 MiB.
+var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB
+
+// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load.
+// The size of the file is the decompressed version of it when it is stored in an archive.
+var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB
+
var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`)
// FileLoader loads a chart from a file
@@ -101,7 +109,7 @@ func ensureArchive(name string, raw *os.File) error {
return nil
}
-// isGZipApplication checks whether the achieve is of the application/x-gzip type.
+// isGZipApplication checks whether the archive is of the application/x-gzip type.
func isGZipApplication(data []byte) bool {
sig := []byte("\x1F\x8B\x08")
return bytes.HasPrefix(data, sig)
@@ -119,6 +127,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
files := []*BufferedFile{}
tr := tar.NewReader(unzipped)
+ remainingSize := MaxDecompressedChartSize
for {
b := bytes.NewBuffer(nil)
hd, err := tr.Next()
@@ -160,7 +169,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
n = path.Clean(n)
if n == "." {
// In this case, the original path was relative when it should have been absolute.
- return nil, errors.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
+ return nil, fmt.Errorf("chart illegally contains content outside the base directory: %q", hd.Name)
}
if strings.HasPrefix(n, "..") {
return nil, errors.New("chart illegally references parent directory")
@@ -178,10 +187,30 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) {
return nil, errors.New("chart yaml not in base directory")
}
- if _, err := io.Copy(b, tr); err != nil {
+ if hd.Size > remainingSize {
+ return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
+ }
+
+ if hd.Size > MaxDecompressedFileSize {
+ return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize)
+ }
+
+ limitedReader := io.LimitReader(tr, remainingSize)
+
+ bytesWritten, err := io.Copy(b, limitedReader)
+ if err != nil {
return nil, err
}
+ remainingSize -= bytesWritten
+ // When the bytesWritten are less than the file size it means the limit reader ended
+ // copying early. Here we report that error. This is important if the last file extracted
+ // is the one that goes over the limit. It assumes the Size stored in the tar header
+ // is correct, something many applications do.
+ if bytesWritten < hd.Size || remainingSize <= 0 {
+ return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize)
+ }
+
data := bytes.TrimPrefix(b.Bytes(), utf8bom)
files = append(files, &BufferedFile{Name: n, Data: data})
diff --git a/pkg/chart/v2/loader/archive_test.go b/pkg/chart/v2/loader/archive_test.go
new file mode 100644
index 000000000..d16c47563
--- /dev/null
+++ b/pkg/chart/v2/loader/archive_test.go
@@ -0,0 +1,92 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package loader
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "testing"
+)
+
+func TestLoadArchiveFiles(t *testing.T) {
+ tcs := []struct {
+ name string
+ generate func(w *tar.Writer)
+ check func(t *testing.T, files []*BufferedFile, err error)
+ }{
+ {
+ name: "empty input should return no files",
+ generate: func(_ *tar.Writer) {},
+ check: func(t *testing.T, _ []*BufferedFile, err error) {
+ t.Helper()
+ if err.Error() != "no files in chart archive" {
+ t.Fatalf(`expected "no files in chart archive", got [%#v]`, err)
+ }
+ },
+ },
+ {
+ name: "should ignore files with XGlobalHeader type",
+ generate: func(w *tar.Writer) {
+ // simulate the presence of a `pax_global_header` file like you would get when
+ // processing a GitHub release archive.
+ err := w.WriteHeader(&tar.Header{
+ Typeflag: tar.TypeXGlobalHeader,
+ Name: "pax_global_header",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // we need to have at least one file, otherwise we'll get the "no files in chart archive" error
+ err = w.WriteHeader(&tar.Header{
+ Typeflag: tar.TypeReg,
+ Name: "dir/empty",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ },
+ check: func(t *testing.T, files []*BufferedFile, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatalf(`got unwanted error [%#v] for tar file with pax_global_header content`, err)
+ }
+
+ if len(files) != 1 {
+ t.Fatalf(`expected to get one file but got [%v]`, files)
+ }
+ },
+ },
+ }
+
+ for _, tc := range tcs {
+ t.Run(tc.name, func(t *testing.T) {
+ buf := &bytes.Buffer{}
+ gzw := gzip.NewWriter(buf)
+ tw := tar.NewWriter(gzw)
+
+ tc.generate(tw)
+
+ _ = tw.Close()
+ _ = gzw.Close()
+
+ files, err := LoadArchiveFiles(buf)
+ tc.check(t, files, err)
+ })
+ }
+}
diff --git a/pkg/chart/loader/directory.go b/pkg/chart/v2/loader/directory.go
similarity index 89%
rename from pkg/chart/loader/directory.go
rename to pkg/chart/v2/loader/directory.go
index 489eea93c..4f72925dc 100644
--- a/pkg/chart/loader/directory.go
+++ b/pkg/chart/v2/loader/directory.go
@@ -23,11 +23,9 @@ import (
"path/filepath"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/ignore"
- "helm.sh/helm/v3/internal/sympath"
- "helm.sh/helm/v3/pkg/chart"
+ "helm.sh/helm/v4/internal/sympath"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/ignore"
)
var utf8bom = []byte{0xEF, 0xBB, 0xBF}
@@ -101,9 +99,13 @@ func LoadDir(dir string) (*chart.Chart, error) {
return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name)
}
+ if fi.Size() > MaxDecompressedFileSize {
+ return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), MaxDecompressedFileSize)
+ }
+
data, err := os.ReadFile(name)
if err != nil {
- return errors.Wrapf(err, "error reading %s", n)
+ return fmt.Errorf("error reading %s: %w", n, err)
}
data = bytes.TrimPrefix(data, utf8bom)
diff --git a/pkg/chart/loader/load.go b/pkg/chart/v2/loader/load.go
similarity index 70%
rename from pkg/chart/loader/load.go
rename to pkg/chart/v2/loader/load.go
index 7cc8878a8..75c73e959 100644
--- a/pkg/chart/loader/load.go
+++ b/pkg/chart/v2/loader/load.go
@@ -17,16 +17,21 @@ limitations under the License.
package loader
import (
+ "bufio"
"bytes"
+ "errors"
+ "fmt"
+ "io"
"log"
+ "maps"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
+ utilyaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// ChartLoader loads a chart.
@@ -44,7 +49,6 @@ func Loader(name string) (ChartLoader, error) {
return DirLoader(name), nil
}
return FileLoader(name), nil
-
}
// Load takes a string name, tries to resolve it to a file or directory, and then loads it.
@@ -82,7 +86,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
c.Metadata = new(chart.Metadata)
}
if err := yaml.Unmarshal(f.Data, c.Metadata); err != nil {
- return c, errors.Wrap(err, "cannot load Chart.yaml")
+ return c, fmt.Errorf("cannot load Chart.yaml: %w", err)
}
// NOTE(bacongobbler): while the chart specification says that APIVersion must be set,
// Helm 2 accepted charts that did not provide an APIVersion in their chart metadata.
@@ -100,13 +104,14 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
case f.Name == "Chart.lock":
c.Lock = new(chart.Lock)
if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil {
- return c, errors.Wrap(err, "cannot load Chart.lock")
+ return c, fmt.Errorf("cannot load Chart.lock: %w", err)
}
case f.Name == "values.yaml":
- c.Values = make(map[string]interface{})
- if err := yaml.Unmarshal(f.Data, &c.Values); err != nil {
- return c, errors.Wrap(err, "cannot load values.yaml")
+ values, err := LoadValues(bytes.NewReader(f.Data))
+ if err != nil {
+ return c, fmt.Errorf("cannot load values.yaml: %w", err)
}
+ c.Values = values
case f.Name == "values.schema.json":
c.Schema = f.Data
@@ -120,7 +125,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
log.Printf("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, errors.Wrap(err, "cannot load requirements.yaml")
+ return c, fmt.Errorf("cannot load requirements.yaml: %w", err)
}
if c.Metadata.APIVersion == chart.APIVersionV1 {
c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data})
@@ -129,11 +134,14 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
case f.Name == "requirements.lock":
c.Lock = new(chart.Lock)
if err := yaml.Unmarshal(f.Data, &c.Lock); err != nil {
- return c, errors.Wrap(err, "cannot load requirements.lock")
+ return c, fmt.Errorf("cannot load requirements.lock: %w", err)
}
if c.Metadata == nil {
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.")
+ }
if c.Metadata.APIVersion == chart.APIVersionV1 {
c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data})
}
@@ -155,7 +163,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
}
if c.Metadata == nil {
- return c, errors.New("Chart.yaml file is missing")
+ return c, errors.New("Chart.yaml file is missing") //nolint:staticcheck
}
if err := c.Validate(); err != nil {
@@ -171,7 +179,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
case filepath.Ext(n) == ".tgz":
file := files[0]
if file.Name != n {
- return c, errors.Errorf("error unpacking tar in %s: expected %s, got %s", c.Name(), n, file.Name)
+ return c, fmt.Errorf("error unpacking subchart tar in %s: expected %s, got %s", c.Name(), n, file.Name)
}
// Untar the chart and add to c.Dependencies
sc, err = LoadArchive(bytes.NewBuffer(file.Data))
@@ -191,10 +199,53 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) {
}
if err != nil {
- return c, errors.Wrapf(err, "error unpacking %s in %s", n, c.Name())
+ return c, fmt.Errorf("error unpacking subchart %s in %s: %w", n, c.Name(), err)
}
c.AddDependency(sc)
}
return c, nil
}
+
+// LoadValues loads values from a reader.
+//
+// The reader is expected to contain one or more YAML documents, the values of which are merged.
+// And the values can be either a chart's default values or a user-supplied values.
+func LoadValues(data io.Reader) (map[string]interface{}, error) {
+ values := map[string]interface{}{}
+ reader := utilyaml.NewYAMLReader(bufio.NewReader(data))
+ for {
+ currentMap := map[string]interface{}{}
+ raw, err := reader.Read()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return nil, fmt.Errorf("error reading yaml document: %w", err)
+ }
+ if err := yaml.Unmarshal(raw, ¤tMap); err != nil {
+ return nil, fmt.Errorf("cannot unmarshal yaml document: %w", err)
+ }
+ values = MergeMaps(values, currentMap)
+ }
+ return values, nil
+}
+
+// MergeMaps merges two maps. If a key exists in both maps, the value from b will be used.
+// If the value is a map, the maps will be merged recursively.
+func MergeMaps(a, b map[string]interface{}) map[string]interface{} {
+ out := make(map[string]interface{}, len(a))
+ maps.Copy(out, a)
+ for k, v := range b {
+ if v, ok := v.(map[string]interface{}); ok {
+ if bv, ok := out[k]; ok {
+ if bv, ok := bv.(map[string]interface{}); ok {
+ out[k] = MergeMaps(bv, v)
+ continue
+ }
+ }
+ }
+ out[k] = v
+ }
+ return out
+}
diff --git a/pkg/chart/loader/load_test.go b/pkg/chart/v2/loader/load_test.go
similarity index 86%
rename from pkg/chart/loader/load_test.go
rename to pkg/chart/v2/loader/load_test.go
index 098e6155f..41154421c 100644
--- a/pkg/chart/loader/load_test.go
+++ b/pkg/chart/v2/loader/load_test.go
@@ -24,12 +24,13 @@ import (
"log"
"os"
"path/filepath"
+ "reflect"
"runtime"
"strings"
"testing"
"time"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
func TestLoadDir(t *testing.T) {
@@ -488,6 +489,115 @@ func TestLoadInvalidArchive(t *testing.T) {
}
}
+func TestLoadValues(t *testing.T) {
+ testCases := map[string]struct {
+ data []byte
+ expctedValues map[string]interface{}
+ }{
+ "It should load values correctly": {
+ data: []byte(`
+foo:
+ image: foo:v1
+bar:
+ version: v2
+`),
+ expctedValues: map[string]interface{}{
+ "foo": map[string]interface{}{
+ "image": "foo:v1",
+ },
+ "bar": map[string]interface{}{
+ "version": "v2",
+ },
+ },
+ },
+ "It should load values correctly with multiple documents in one file": {
+ data: []byte(`
+foo:
+ image: foo:v1
+bar:
+ version: v2
+---
+foo:
+ image: foo:v2
+`),
+ expctedValues: map[string]interface{}{
+ "foo": map[string]interface{}{
+ "image": "foo:v2",
+ },
+ "bar": map[string]interface{}{
+ "version": "v2",
+ },
+ },
+ },
+ }
+ for testName, testCase := range testCases {
+ t.Run(testName, func(tt *testing.T) {
+ values, err := LoadValues(bytes.NewReader(testCase.data))
+ if err != nil {
+ tt.Fatal(err)
+ }
+ if !reflect.DeepEqual(values, testCase.expctedValues) {
+ tt.Errorf("Expected values: %v, got %v", testCase.expctedValues, values)
+ }
+ })
+ }
+}
+
+func TestMergeValues(t *testing.T) {
+ nestedMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "stuff",
+ },
+ }
+ anotherNestedMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "things",
+ "awesome": "stuff",
+ },
+ }
+ flatMap := map[string]interface{}{
+ "foo": "bar",
+ "baz": "stuff",
+ }
+ anotherFlatMap := map[string]interface{}{
+ "testing": "fun",
+ }
+
+ testMap := MergeMaps(flatMap, nestedMap)
+ equal := reflect.DeepEqual(testMap, nestedMap)
+ if !equal {
+ t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap)
+ }
+
+ testMap = MergeMaps(nestedMap, flatMap)
+ equal = reflect.DeepEqual(testMap, flatMap)
+ if !equal {
+ t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap)
+ }
+
+ testMap = MergeMaps(nestedMap, anotherNestedMap)
+ equal = reflect.DeepEqual(testMap, anotherNestedMap)
+ if !equal {
+ t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap)
+ }
+
+ testMap = MergeMaps(anotherFlatMap, anotherNestedMap)
+ expectedMap := map[string]interface{}{
+ "testing": "fun",
+ "foo": "bar",
+ "baz": map[string]string{
+ "cool": "things",
+ "awesome": "stuff",
+ },
+ }
+ equal = reflect.DeepEqual(testMap, expectedMap)
+ if !equal {
+ t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap)
+ }
+}
+
func verifyChart(t *testing.T, c *chart.Chart) {
t.Helper()
if c.Name() == "" {
@@ -538,6 +648,7 @@ func verifyChart(t *testing.T, c *chart.Chart) {
}
func verifyDependencies(t *testing.T, c *chart.Chart) {
+ t.Helper()
if len(c.Metadata.Dependencies) != 2 {
t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies))
}
@@ -560,6 +671,7 @@ func verifyDependencies(t *testing.T, c *chart.Chart) {
}
func verifyDependenciesLock(t *testing.T, c *chart.Chart) {
+ t.Helper()
if len(c.Metadata.Dependencies) != 2 {
t.Errorf("Expected 2 dependencies, got %d", len(c.Metadata.Dependencies))
}
@@ -582,10 +694,12 @@ func verifyDependenciesLock(t *testing.T, c *chart.Chart) {
}
func verifyFrobnitz(t *testing.T, c *chart.Chart) {
+ t.Helper()
verifyChartFileAndTemplate(t, c, "frobnitz")
}
func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) {
+ t.Helper()
if c.Metadata == nil {
t.Fatal("Metadata is nil")
}
@@ -640,6 +754,7 @@ func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) {
}
func verifyBomStripped(t *testing.T, files []*chart.File) {
+ t.Helper()
for _, file := range files {
if bytes.HasPrefix(file.Data, utf8bom) {
t.Errorf("Byte Order Mark still present in processed file %s", file.Name)
diff --git a/pkg/chartutil/testdata/frobnitz/LICENSE b/pkg/chart/v2/loader/testdata/LICENSE
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/LICENSE
rename to pkg/chart/v2/loader/testdata/LICENSE
diff --git a/pkg/chart/v2/loader/testdata/albatross/Chart.yaml b/pkg/chart/v2/loader/testdata/albatross/Chart.yaml
new file mode 100644
index 000000000..eeef737ff
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/albatross/Chart.yaml
@@ -0,0 +1,4 @@
+name: albatross
+description: A Helm chart for Kubernetes
+version: 0.1.0
+home: ""
diff --git a/pkg/chart/v2/loader/testdata/albatross/values.yaml b/pkg/chart/v2/loader/testdata/albatross/values.yaml
new file mode 100644
index 000000000..3121cd7ce
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/albatross/values.yaml
@@ -0,0 +1,4 @@
+albatross: "true"
+
+global:
+ author: Coleridge
diff --git a/pkg/chart/loader/testdata/frobnitz-1.2.3.tgz b/pkg/chart/v2/loader/testdata/frobnitz-1.2.3.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz-1.2.3.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz-1.2.3.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz.v1.tgz b/pkg/chart/v2/loader/testdata/frobnitz.v1.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz.v1.tgz
diff --git a/pkg/chartutil/testdata/frobnitz/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz.v1/.helmignore
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/.helmignore
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/.helmignore
diff --git a/pkg/chartutil/testdata/frobnitz/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.lock
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/Chart.lock
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.lock
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/Chart.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz.v1/INSTALL.txt
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/INSTALL.txt
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/INSTALL.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v1/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chartutil/testdata/frobnitz/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v1/README.md
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/README.md
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/README.md
diff --git a/pkg/chartutil/testdata/frobnitz/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/_ignore_me
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/_ignore_me
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/_ignore_me
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/Chart.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/README.md
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/README.md
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/README.md
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast1/values.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/charts/mast2-0.1.0.tgz
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/values.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/alpine/values.yaml
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/charts/mariner-4.3.2.tgz
diff --git a/pkg/chartutil/testdata/frobnitz/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v1/docs/README.md
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/docs/README.md
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/docs/README.md
diff --git a/pkg/chartutil/testdata/frobnitz/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz.v1/icon.svg
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/icon.svg
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/icon.svg
diff --git a/pkg/chartutil/testdata/frobnitz/ignore/me.txt b/pkg/chart/v2/loader/testdata/frobnitz.v1/ignore/me.txt
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/ignore/me.txt
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/requirements.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v1/requirements.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/requirements.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz.v1/templates/template.tpl
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/templates/template.tpl
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/templates/template.tpl
diff --git a/pkg/chartutil/testdata/frobnitz/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v1/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/values.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v1/values.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..21ae20aad
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/repository/test-name-charts.txt
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/ignore/me.txt
diff --git a/pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/requirements.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz.v2.reqs/requirements.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/requirements.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz.v2.reqs/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock
new file mode 100644
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chart/loader/testdata/frobnitz/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/README.md b/pkg/chart/v2/loader/testdata/frobnitz/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..21ae20aad
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml b/pkg/chart/v2/loader/testdata/frobnitz/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/echo/completion.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz/ignore/me.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/chart/v2/loader/testdata/frobnitz_backslash-1.2.3.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash-1.2.3.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash-1.2.3.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore
new file mode 100755
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock
new file mode 100755
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt
new file mode 100755
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE
new file mode 100755
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md b/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md
new file mode 100755
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me
new file mode 100755
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md
new file mode 100755
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml
new file mode 100755
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100755
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml
new file mode 100755
index 000000000..0ac5ca6a8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service | quote }}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml
new file mode 100755
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz
old mode 100644
new mode 100755
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md
new file mode 100755
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg
new file mode 100755
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt b/pkg/chart/v2/loader/testdata/frobnitz_backslash/ignore/me.txt
old mode 100644
new mode 100755
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/repository/test-name-charts.txt
rename to pkg/chart/v2/loader/testdata/frobnitz_backslash/ignore/me.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl
new file mode 100755
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml
new file mode 100755
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_backslash/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_bom.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore
new file mode 100644
index 000000000..7a4b92da2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock
new file mode 100644
index 000000000..ed43b227f
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt
new file mode 100644
index 000000000..77c4e724a
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE
new file mode 100644
index 000000000..c27b00bf2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md
new file mode 100644
index 000000000..e9c40031b
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me
new file mode 100644
index 000000000..a7e3a38b7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md
new file mode 100644
index 000000000..ea7526bee
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..f690d53c4
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..f3e662a28
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml
new file mode 100644
index 000000000..6b7cb2596
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md
new file mode 100644
index 000000000..816c3e431
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/output/lint-quiet-with-warning.txt b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/output/lint-quiet-with-warning.txt
rename to pkg/chart/v2/loader/testdata/frobnitz_with_bom/ignore/me.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl
new file mode 100644
index 000000000..bb29c5491
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml
new file mode 100644
index 000000000..c24ceadf9
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_bom/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock
new file mode 100644
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..21ae20aad
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz
rename to pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/charts/mariner-4.3.2.tgz
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/output/lint-quiet.txt b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/output/lint-quiet.txt
rename to pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/ignore/me.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null
new file mode 120000
index 000000000..dc1dc0cde
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/null
@@ -0,0 +1 @@
+/dev/null
\ No newline at end of file
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_dev_null/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock
new file mode 100644
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_symlink/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..21ae20aad
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ app.kubernetes.io/name: {{.Chart.Name}}
+ helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}"
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.9"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..3190136b0
Binary files /dev/null and b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml
rename to pkg/chart/v2/loader/testdata/frobnitz_with_symlink/ignore/me.txt
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/frobnitz_with_symlink/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chartutil/testdata/genfrob.sh b/pkg/chart/v2/loader/testdata/genfrob.sh
similarity index 100%
rename from pkg/chartutil/testdata/genfrob.sh
rename to pkg/chart/v2/loader/testdata/genfrob.sh
diff --git a/pkg/chart/loader/testdata/mariner/Chart.yaml b/pkg/chart/v2/loader/testdata/mariner/Chart.yaml
similarity index 100%
rename from pkg/chart/loader/testdata/mariner/Chart.yaml
rename to pkg/chart/v2/loader/testdata/mariner/Chart.yaml
diff --git a/pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz b/pkg/chart/v2/loader/testdata/mariner/charts/albatross-0.1.0.tgz
similarity index 100%
rename from pkg/chart/loader/testdata/mariner/charts/albatross-0.1.0.tgz
rename to pkg/chart/v2/loader/testdata/mariner/charts/albatross-0.1.0.tgz
diff --git a/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl b/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl
new file mode 100644
index 000000000..29c11843a
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/mariner/templates/placeholder.tpl
@@ -0,0 +1 @@
+# This is a placeholder.
diff --git a/pkg/chart/v2/loader/testdata/mariner/values.yaml b/pkg/chart/v2/loader/testdata/mariner/values.yaml
new file mode 100644
index 000000000..b0ccb0086
--- /dev/null
+++ b/pkg/chart/v2/loader/testdata/mariner/values.yaml
@@ -0,0 +1,7 @@
+# Default values for .
+# This is a YAML-formatted file. https://github.com/toml-lang/toml
+# Declare name/value pairs to be passed into your templates.
+# name: "value"
+
+:
+ test: true
diff --git a/pkg/chart/v2/metadata.go b/pkg/chart/v2/metadata.go
new file mode 100644
index 000000000..d213a3491
--- /dev/null
+++ b/pkg/chart/v2/metadata.go
@@ -0,0 +1,178 @@
+/*
+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 v2
+
+import (
+ "path/filepath"
+ "strings"
+ "unicode"
+
+ "github.com/Masterminds/semver/v3"
+)
+
+// Maintainer describes a Chart maintainer.
+type Maintainer struct {
+ // Name is a user name or organization name
+ Name string `json:"name,omitempty"`
+ // Email is an optional email address to contact the named maintainer
+ Email string `json:"email,omitempty"`
+ // URL is an optional URL to an address for the named maintainer
+ URL string `json:"url,omitempty"`
+}
+
+// Validate checks valid data and sanitizes string characters.
+func (m *Maintainer) Validate() error {
+ if m == nil {
+ return ValidationError("maintainers must not contain empty or null nodes")
+ }
+ m.Name = sanitizeString(m.Name)
+ m.Email = sanitizeString(m.Email)
+ m.URL = sanitizeString(m.URL)
+ return nil
+}
+
+// Metadata for a Chart file. This models the structure of a Chart.yaml file.
+type Metadata struct {
+ // The name of the chart. Required.
+ Name string `json:"name,omitempty"`
+ // The URL to a relevant project page, git repo, or contact person
+ Home string `json:"home,omitempty"`
+ // Source is the URL to the source code of this chart
+ Sources []string `json:"sources,omitempty"`
+ // A SemVer 2 conformant version string of the chart. Required.
+ Version string `json:"version,omitempty"`
+ // A one-sentence description of the chart
+ Description string `json:"description,omitempty"`
+ // A list of string keywords
+ Keywords []string `json:"keywords,omitempty"`
+ // A list of name and URL/email address combinations for the maintainer(s)
+ Maintainers []*Maintainer `json:"maintainers,omitempty"`
+ // The URL to an icon file.
+ Icon string `json:"icon,omitempty"`
+ // The API Version of this chart. Required.
+ APIVersion string `json:"apiVersion,omitempty"`
+ // The condition to check to enable chart
+ Condition string `json:"condition,omitempty"`
+ // The tags to check to enable chart
+ Tags string `json:"tags,omitempty"`
+ // The version of the application enclosed inside of this chart.
+ AppVersion string `json:"appVersion,omitempty"`
+ // Whether or not this chart is deprecated
+ Deprecated bool `json:"deprecated,omitempty"`
+ // Annotations are additional mappings uninterpreted by Helm,
+ // made available for inspection by other applications.
+ Annotations map[string]string `json:"annotations,omitempty"`
+ // KubeVersion is a SemVer constraint specifying the version of Kubernetes required.
+ KubeVersion string `json:"kubeVersion,omitempty"`
+ // Dependencies are a list of dependencies for a chart.
+ Dependencies []*Dependency `json:"dependencies,omitempty"`
+ // Specifies the chart type: application or library
+ Type string `json:"type,omitempty"`
+}
+
+// Validate checks the metadata for known issues and sanitizes string
+// characters.
+func (md *Metadata) Validate() error {
+ if md == nil {
+ return ValidationError("chart.metadata is required")
+ }
+
+ md.Name = sanitizeString(md.Name)
+ md.Description = sanitizeString(md.Description)
+ md.Home = sanitizeString(md.Home)
+ md.Icon = sanitizeString(md.Icon)
+ md.Condition = sanitizeString(md.Condition)
+ md.Tags = sanitizeString(md.Tags)
+ md.AppVersion = sanitizeString(md.AppVersion)
+ md.KubeVersion = sanitizeString(md.KubeVersion)
+ for i := range md.Sources {
+ md.Sources[i] = sanitizeString(md.Sources[i])
+ }
+ for i := range md.Keywords {
+ md.Keywords[i] = sanitizeString(md.Keywords[i])
+ }
+
+ if md.APIVersion == "" {
+ return ValidationError("chart.metadata.apiVersion is required")
+ }
+ if md.Name == "" {
+ return ValidationError("chart.metadata.name is required")
+ }
+
+ if md.Name != filepath.Base(md.Name) {
+ return ValidationErrorf("chart.metadata.name %q is invalid", md.Name)
+ }
+
+ if md.Version == "" {
+ return ValidationError("chart.metadata.version is required")
+ }
+ if !isValidSemver(md.Version) {
+ return ValidationErrorf("chart.metadata.version %q is invalid", md.Version)
+ }
+ if !isValidChartType(md.Type) {
+ return ValidationError("chart.metadata.type must be application or library")
+ }
+
+ for _, m := range md.Maintainers {
+ if err := m.Validate(); err != nil {
+ return err
+ }
+ }
+
+ // Aliases need to be validated here to make sure that the alias name does
+ // not contain any illegal characters.
+ dependencies := map[string]*Dependency{}
+ for _, dependency := range md.Dependencies {
+ if err := dependency.Validate(); err != nil {
+ return err
+ }
+ key := dependency.Name
+ if dependency.Alias != "" {
+ key = dependency.Alias
+ }
+ if dependencies[key] != nil {
+ return ValidationErrorf("more than one dependency with name or alias %q", key)
+ }
+ dependencies[key] = dependency
+ }
+ return nil
+}
+
+func isValidChartType(in string) bool {
+ switch in {
+ case "", "application", "library":
+ return true
+ }
+ return false
+}
+
+func isValidSemver(v string) bool {
+ _, err := semver.NewVersion(v)
+ return err == nil
+}
+
+// sanitizeString normalize spaces and removes non-printable characters.
+func sanitizeString(str string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsSpace(r) {
+ return ' '
+ }
+ if unicode.IsPrint(r) {
+ return r
+ }
+ return -1
+ }, str)
+}
diff --git a/pkg/chart/metadata_test.go b/pkg/chart/v2/metadata_test.go
similarity index 55%
rename from pkg/chart/metadata_test.go
rename to pkg/chart/v2/metadata_test.go
index cc04f095b..7892f0209 100644
--- a/pkg/chart/metadata_test.go
+++ b/pkg/chart/v2/metadata_test.go
@@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
-package chart
+package v2
import (
"testing"
@@ -21,34 +21,47 @@ import (
func TestValidate(t *testing.T) {
tests := []struct {
- md *Metadata
- err error
+ name string
+ md *Metadata
+ err error
}{
{
+ "chart without metadata",
nil,
ValidationError("chart.metadata is required"),
},
{
+ "chart without apiVersion",
&Metadata{Name: "test", Version: "1.0"},
ValidationError("chart.metadata.apiVersion is required"),
},
{
+ "chart without name",
&Metadata{APIVersion: "v2", Version: "1.0"},
ValidationError("chart.metadata.name is required"),
},
{
+ "chart without name",
+ &Metadata{Name: "../../test", APIVersion: "v2", Version: "1.0"},
+ ValidationError("chart.metadata.name \"../../test\" is invalid"),
+ },
+ {
+ "chart without version",
&Metadata{Name: "test", APIVersion: "v2"},
ValidationError("chart.metadata.version is required"),
},
{
+ "chart with bad type",
&Metadata{Name: "test", APIVersion: "v2", Version: "1.0", Type: "test"},
ValidationError("chart.metadata.type must be application or library"),
},
{
+ "chart without dependency",
&Metadata{Name: "test", APIVersion: "v2", Version: "1.0", Type: "application"},
nil,
},
{
+ "dependency with valid alias",
&Metadata{
Name: "test",
APIVersion: "v2",
@@ -61,6 +74,7 @@ func TestValidate(t *testing.T) {
nil,
},
{
+ "dependency with bad characters in alias",
&Metadata{
Name: "test",
APIVersion: "v2",
@@ -73,6 +87,67 @@ func TestValidate(t *testing.T) {
ValidationError("dependency \"bad\" has disallowed characters in the alias"),
},
{
+ "same dependency twice",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v2",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: ""},
+ {Name: "foo", Alias: ""},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ "two dependencies with alias from second dependency shadowing first one",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v2",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: ""},
+ {Name: "bar", Alias: "foo"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ // this case would make sense and could work in future versions of Helm, currently template rendering would
+ // result in undefined behaviour
+ "same dependency twice with different version",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v2",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Alias: "", Version: "1.2.3"},
+ {Name: "foo", Alias: "", Version: "1.0.0"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ // this case would make sense and could work in future versions of Helm, currently template rendering would
+ // result in undefined behaviour
+ "two dependencies with same name but different repos",
+ &Metadata{
+ Name: "test",
+ APIVersion: "v2",
+ Version: "1.0",
+ Type: "application",
+ Dependencies: []*Dependency{
+ {Name: "foo", Repository: "repo-0"},
+ {Name: "foo", Repository: "repo-1"},
+ },
+ },
+ ValidationError("more than one dependency with name or alias \"foo\""),
+ },
+ {
+ "dependencies has nil",
&Metadata{
Name: "test",
APIVersion: "v2",
@@ -85,6 +160,7 @@ func TestValidate(t *testing.T) {
ValidationError("dependencies must not contain empty or null nodes"),
},
{
+ "maintainer not empty",
&Metadata{
Name: "test",
APIVersion: "v2",
@@ -97,6 +173,7 @@ func TestValidate(t *testing.T) {
ValidationError("maintainers must not contain empty or null nodes"),
},
{
+ "version invalid",
&Metadata{APIVersion: "v2", Name: "test", Version: "1.2.3.4"},
ValidationError("chart.metadata.version \"1.2.3.4\" is invalid"),
},
@@ -105,7 +182,7 @@ func TestValidate(t *testing.T) {
for _, tt := range tests {
result := tt.md.Validate()
if result != tt.err {
- t.Errorf("expected '%s', got '%s'", tt.err, result)
+ t.Errorf("expected %q, got %q in test %q", tt.err, result, tt.name)
}
}
}
diff --git a/pkg/chart/v2/util/capabilities.go b/pkg/chart/v2/util/capabilities.go
new file mode 100644
index 000000000..19d62c5e3
--- /dev/null
+++ b/pkg/chart/v2/util/capabilities.go
@@ -0,0 +1,124 @@
+/*
+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 util
+
+import (
+ "fmt"
+ "slices"
+ "strconv"
+
+ "k8s.io/client-go/kubernetes/scheme"
+
+ apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+ apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
+ k8sversion "k8s.io/apimachinery/pkg/util/version"
+
+ helmversion "helm.sh/helm/v4/internal/version"
+)
+
+var (
+ // The Kubernetes version can be set by LDFLAGS. In order to do that the value
+ // must be a string.
+ k8sVersionMajor = "1"
+ k8sVersionMinor = "20"
+
+ // DefaultVersionSet is the default version set, which includes only Core V1 ("v1").
+ DefaultVersionSet = allKnownVersions()
+
+ // DefaultCapabilities is the default set of capabilities.
+ DefaultCapabilities = &Capabilities{
+ KubeVersion: KubeVersion{
+ Version: fmt.Sprintf("v%s.%s.0", k8sVersionMajor, k8sVersionMinor),
+ Major: k8sVersionMajor,
+ Minor: k8sVersionMinor,
+ },
+ APIVersions: DefaultVersionSet,
+ HelmVersion: helmversion.Get(),
+ }
+)
+
+// Capabilities describes the capabilities of the Kubernetes cluster.
+type Capabilities struct {
+ // KubeVersion is the Kubernetes version.
+ KubeVersion KubeVersion
+ // APIVersions are supported Kubernetes API versions.
+ APIVersions VersionSet
+ // HelmVersion is the build information for this helm version
+ HelmVersion helmversion.BuildInfo
+}
+
+func (capabilities *Capabilities) Copy() *Capabilities {
+ return &Capabilities{
+ KubeVersion: capabilities.KubeVersion,
+ APIVersions: capabilities.APIVersions,
+ HelmVersion: capabilities.HelmVersion,
+ }
+}
+
+// KubeVersion is the Kubernetes version.
+type KubeVersion struct {
+ Version string // Kubernetes version
+ Major string // Kubernetes major version
+ Minor string // Kubernetes minor version
+}
+
+// String implements fmt.Stringer
+func (kv *KubeVersion) String() string { return kv.Version }
+
+// GitVersion returns the Kubernetes version string.
+//
+// Deprecated: use KubeVersion.Version.
+func (kv *KubeVersion) GitVersion() string { return kv.Version }
+
+// ParseKubeVersion parses kubernetes version from string
+func ParseKubeVersion(version string) (*KubeVersion, error) {
+ // Based on the original k8s version parser.
+ // https://github.com/kubernetes/kubernetes/blob/b266ac2c3e42c2c4843f81e20213d2b2f43e450a/staging/src/k8s.io/apimachinery/pkg/util/version/version.go#L137
+ sv, err := k8sversion.ParseGeneric(version)
+ if err != nil {
+ return nil, err
+ }
+ return &KubeVersion{
+ Version: "v" + sv.String(),
+ Major: strconv.FormatUint(uint64(sv.Major()), 10),
+ Minor: strconv.FormatUint(uint64(sv.Minor()), 10),
+ }, nil
+}
+
+// VersionSet is a set of Kubernetes API versions.
+type VersionSet []string
+
+// Has returns true if the version string is in the set.
+//
+// vs.Has("apps/v1")
+func (v VersionSet) Has(apiVersion string) bool {
+ return slices.Contains(v, apiVersion)
+}
+
+func allKnownVersions() VersionSet {
+ // We should register the built in extension APIs as well so CRDs are
+ // supported in the default version set. This has caused problems with `helm
+ // template` in the past, so let's be safe
+ apiextensionsv1beta1.AddToScheme(scheme.Scheme)
+ apiextensionsv1.AddToScheme(scheme.Scheme)
+
+ groups := scheme.Scheme.PrioritizedVersionsAllGroups()
+ vs := make(VersionSet, 0, len(groups))
+ for _, gv := range groups {
+ vs = append(vs, gv.String())
+ }
+ return vs
+}
diff --git a/pkg/chart/v2/util/capabilities_test.go b/pkg/chart/v2/util/capabilities_test.go
new file mode 100644
index 000000000..e5513b3fd
--- /dev/null
+++ b/pkg/chart/v2/util/capabilities_test.go
@@ -0,0 +1,100 @@
+/*
+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 util
+
+import (
+ "testing"
+)
+
+func TestVersionSet(t *testing.T) {
+ vs := VersionSet{"v1", "apps/v1"}
+ if d := len(vs); d != 2 {
+ t.Errorf("Expected 2 versions, got %d", d)
+ }
+
+ if !vs.Has("apps/v1") {
+ t.Error("Expected to find apps/v1")
+ }
+
+ if vs.Has("Spanish/inquisition") {
+ t.Error("No one expects the Spanish/inquisition")
+ }
+}
+
+func TestDefaultVersionSet(t *testing.T) {
+ if !DefaultVersionSet.Has("v1") {
+ t.Error("Expected core v1 version set")
+ }
+}
+
+func TestDefaultCapabilities(t *testing.T) {
+ kv := DefaultCapabilities.KubeVersion
+ if kv.String() != "v1.20.0" {
+ t.Errorf("Expected default KubeVersion.String() to be v1.20.0, got %q", kv.String())
+ }
+ if kv.Version != "v1.20.0" {
+ t.Errorf("Expected default KubeVersion.Version to be v1.20.0, got %q", kv.Version)
+ }
+ if kv.GitVersion() != "v1.20.0" {
+ t.Errorf("Expected default KubeVersion.GitVersion() to be v1.20.0, got %q", kv.Version)
+ }
+ if kv.Major != "1" {
+ t.Errorf("Expected default KubeVersion.Major to be 1, got %q", kv.Major)
+ }
+ if kv.Minor != "20" {
+ t.Errorf("Expected default KubeVersion.Minor to be 20, got %q", kv.Minor)
+ }
+}
+
+func TestDefaultCapabilitiesHelmVersion(t *testing.T) {
+ hv := DefaultCapabilities.HelmVersion
+
+ if hv.Version != "v4.0" {
+ t.Errorf("Expected default HelmVersion to be v4.0, got %q", hv.Version)
+ }
+}
+
+func TestParseKubeVersion(t *testing.T) {
+ kv, err := ParseKubeVersion("v1.16.0")
+ if err != nil {
+ t.Errorf("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())
+ }
+ if kv.Major != "1" {
+ t.Errorf("Expected parsed KubeVersion.Major to be 1, got %q", kv.Major)
+ }
+ if kv.Minor != "16" {
+ t.Errorf("Expected parsed KubeVersion.Minor to be 16, got %q", kv.Minor)
+ }
+}
+
+func TestParseKubeVersionSuffix(t *testing.T) {
+ kv, err := ParseKubeVersion("v1.28+")
+ if err != nil {
+ t.Errorf("Expected v1.28+ to parse successfully")
+ }
+ if kv.Version != "v1.28" {
+ t.Errorf("Expected parsed KubeVersion.Version to be v1.28, got %q", kv.String())
+ }
+ if kv.Major != "1" {
+ t.Errorf("Expected parsed KubeVersion.Major to be 1, got %q", kv.Major)
+ }
+ if kv.Minor != "28" {
+ t.Errorf("Expected parsed KubeVersion.Minor to be 28, got %q", kv.Minor)
+ }
+}
diff --git a/pkg/chartutil/chartfile.go b/pkg/chart/v2/util/chartfile.go
similarity index 71%
rename from pkg/chartutil/chartfile.go
rename to pkg/chart/v2/util/chartfile.go
index 4f537a6e7..1f9c712b2 100644
--- a/pkg/chartutil/chartfile.go
+++ b/pkg/chart/v2/util/chartfile.go
@@ -14,16 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
+ "errors"
+ "fmt"
+ "io/fs"
"os"
"path/filepath"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// LoadChartfile loads a Chart.yaml file into a *chart.Metadata.
@@ -37,6 +39,17 @@ func LoadChartfile(filename string) (*chart.Metadata, error) {
return y, err
}
+// StrictLoadChartfile loads a Chart.yaml into a *chart.Metadata using a strict unmarshaling
+func StrictLoadChartfile(filename string) (*chart.Metadata, error) {
+ b, err := os.ReadFile(filename)
+ if err != nil {
+ return nil, err
+ }
+ y := new(chart.Metadata)
+ err = yaml.UnmarshalStrict(b, y)
+ return y, err
+}
+
// SaveChartfile saves the given metadata as a Chart.yaml file at the given path.
//
// 'filename' should be the complete path and filename ('foo/Chart.yaml')
@@ -64,17 +77,17 @@ func IsChartDir(dirName string) (bool, error) {
if fi, err := os.Stat(dirName); err != nil {
return false, err
} else if !fi.IsDir() {
- return false, errors.Errorf("%q is not a directory", dirName)
+ return false, fmt.Errorf("%q is not a directory", dirName)
}
chartYaml := filepath.Join(dirName, ChartfileName)
- if _, err := os.Stat(chartYaml); os.IsNotExist(err) {
- return false, errors.Errorf("no %s exists in directory %q", ChartfileName, dirName)
+ if _, err := os.Stat(chartYaml); errors.Is(err, fs.ErrNotExist) {
+ return false, fmt.Errorf("no %s exists in directory %q", ChartfileName, dirName)
}
chartYamlContent, err := os.ReadFile(chartYaml)
if err != nil {
- return false, errors.Errorf("cannot read %s in directory %q", ChartfileName, dirName)
+ return false, fmt.Errorf("cannot read %s in directory %q", ChartfileName, dirName)
}
chartContent := new(chart.Metadata)
@@ -82,10 +95,10 @@ func IsChartDir(dirName string) (bool, error) {
return false, err
}
if chartContent == nil {
- return false, errors.Errorf("chart metadata (%s) missing", ChartfileName)
+ return false, fmt.Errorf("chart metadata (%s) missing", ChartfileName)
}
if chartContent.Name == "" {
- return false, errors.Errorf("invalid chart (%s): name must not be empty", ChartfileName)
+ return false, fmt.Errorf("invalid chart (%s): name must not be empty", ChartfileName)
}
return true, nil
diff --git a/pkg/chartutil/chartfile_test.go b/pkg/chart/v2/util/chartfile_test.go
similarity index 97%
rename from pkg/chartutil/chartfile_test.go
rename to pkg/chart/v2/util/chartfile_test.go
index ef5c5462a..00c530b8a 100644
--- a/pkg/chartutil/chartfile_test.go
+++ b/pkg/chart/v2/util/chartfile_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"testing"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
const testfile = "testdata/chartfiletest.yaml"
@@ -34,7 +34,7 @@ func TestLoadChartfile(t *testing.T) {
}
func verifyChartfile(t *testing.T, f *chart.Metadata, name string) {
-
+ t.Helper()
if f == nil { //nolint:staticcheck
t.Fatal("Failed verifyChartfile because f is nil")
}
diff --git a/pkg/chartutil/coalesce.go b/pkg/chart/v2/util/coalesce.go
similarity index 94%
rename from pkg/chartutil/coalesce.go
rename to pkg/chart/v2/util/coalesce.go
index 6cf23a122..a3e0f5ae8 100644
--- a/pkg/chartutil/coalesce.go
+++ b/pkg/chart/v2/util/coalesce.go
@@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"fmt"
"log"
+ "maps"
"github.com/mitchellh/copystructure"
- "github.com/pkg/errors"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
func concatPrefix(a, b string) string {
@@ -108,7 +108,7 @@ func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}
// If dest doesn't already have the key, create it.
dest[subchart.Name()] = make(map[string]interface{})
} else if !istable(c) {
- return dest, errors.Errorf("type mismatch on %s: %t", subchart.Name(), c)
+ return dest, fmt.Errorf("type mismatch on %s: %t", subchart.Name(), c)
}
if dv, ok := dest[subchart.Name()]; ok {
dvmap := dv.(map[string]interface{})
@@ -129,7 +129,7 @@ func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}
// coalesceGlobals copies the globals out of src and merges them into dest.
//
// For convenience, returns dest.
-func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, merge bool) {
+func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) {
var dg, sg map[string]interface{}
if destglob, ok := dest[GlobalKey]; !ok {
@@ -183,9 +183,7 @@ func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix st
func copyMap(src map[string]interface{}) map[string]interface{} {
m := make(map[string]interface{}, len(src))
- for k, v := range src {
- m[k] = v
- }
+ maps.Copy(m, src)
return m
}
@@ -237,6 +235,9 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr
printf("warning: skipped value for %s.%s: Not a table.", subPrefix, key)
}
} else {
+ // If the key is a child chart, coalesce tables with Merge set to true
+ merge := childChartMergeTrue(c, key, merge)
+
// Because v has higher precedence than nv, dest values override src
// values.
coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge)
@@ -249,6 +250,15 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr
}
}
+func childChartMergeTrue(chrt *chart.Chart, key string, merge bool) bool {
+ for _, subchart := range chrt.Dependencies() {
+ if subchart.Name() == key {
+ return true
+ }
+ }
+ return merge
+}
+
// CoalesceTables merges a source map into a destination map.
//
// dest is considered authoritative.
@@ -271,6 +281,11 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref
if dst == nil {
return src
}
+ for key, val := range dst {
+ if val == nil {
+ src[key] = nil
+ }
+ }
// Because dest has higher precedence than src, dest values override src
// values.
for key, val := range src {
diff --git a/pkg/chartutil/coalesce_test.go b/pkg/chart/v2/util/coalesce_test.go
similarity index 93%
rename from pkg/chartutil/coalesce_test.go
rename to pkg/chart/v2/util/coalesce_test.go
index 61b718d97..e2c45a435 100644
--- a/pkg/chartutil/coalesce_test.go
+++ b/pkg/chart/v2/util/coalesce_test.go
@@ -14,16 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"encoding/json"
"fmt"
+ "maps"
"testing"
"github.com/stretchr/testify/assert"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// ref: http://www.yaml.org/spec/1.2/spec.html#id2803362
@@ -44,18 +45,21 @@ global:
boat: true
pequod:
+ boat: null
global:
name: Stinky
harpooner: Tashtego
nested:
boat: false
sail: true
+ foo2: null
ahab:
scope: whale
boat: null
nested:
foo: true
- bar: null
+ boat: null
+ object: null
`)
func withDeps(c *chart.Chart, deps ...*chart.Chart) *chart.Chart {
@@ -82,6 +86,13 @@ func TestCoalesceValues(t *testing.T) {
"global": map[string]interface{}{
"nested2": map[string]interface{}{"l0": "moby"},
},
+ "pequod": map[string]interface{}{
+ "boat": "maybe",
+ "ahab": map[string]interface{}{
+ "boat": "maybe",
+ "nested": map[string]interface{}{"boat": "maybe"},
+ },
+ },
},
},
withDeps(&chart.Chart{
@@ -92,19 +103,25 @@ func TestCoalesceValues(t *testing.T) {
"global": map[string]interface{}{
"nested2": map[string]interface{}{"l1": "pequod"},
},
+ "boat": false,
+ "ahab": map[string]interface{}{
+ "boat": false,
+ "nested": map[string]interface{}{"boat": false},
+ },
},
},
&chart.Chart{
Metadata: &chart.Metadata{Name: "ahab"},
Values: map[string]interface{}{
"global": map[string]interface{}{
- "nested": map[string]interface{}{"foo": "bar"},
+ "nested": map[string]interface{}{"foo": "bar", "foo2": "bar2"},
"nested2": map[string]interface{}{"l2": "ahab"},
},
"scope": "ahab",
"name": "ahab",
"boat": true,
- "nested": map[string]interface{}{"foo": false, "bar": true},
+ "nested": map[string]interface{}{"foo": false, "boat": true},
+ "object": map[string]interface{}{"foo": "bar"},
},
},
),
@@ -128,9 +145,7 @@ func TestCoalesceValues(t *testing.T) {
// to CoalesceValues as argument, so that we can
// use it for asserting later
valsCopy := make(Values, len(vals))
- for key, value := range vals {
- valsCopy[key] = value
- }
+ maps.Copy(valsCopy, vals)
v, err := CoalesceValues(c, vals)
if err != nil {
@@ -155,6 +170,7 @@ func TestCoalesceValues(t *testing.T) {
{"{{.pequod.ahab.nested.foo}}", "true"},
{"{{.pequod.ahab.global.name}}", "Ishmael"},
{"{{.pequod.ahab.global.nested.foo}}", "bar"},
+ {"{{.pequod.ahab.global.nested.foo2}}", ""},
{"{{.pequod.ahab.global.subject}}", "Queequeg"},
{"{{.pequod.ahab.global.harpooner}}", "Tashtego"},
{"{{.pequod.global.name}}", "Ishmael"},
@@ -200,13 +216,22 @@ func TestCoalesceValues(t *testing.T) {
t.Error("Expected nested boat key to be removed, still present")
}
- subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{})
+ subchart := v["pequod"].(map[string]interface{})
if _, ok := subchart["boat"]; ok {
t.Error("Expected subchart boat key to be removed, still present")
}
- if _, ok := subchart["nested"].(map[string]interface{})["bar"]; ok {
- t.Error("Expected subchart nested bar key to be removed, still present")
+ subsubchart := subchart["ahab"].(map[string]interface{})
+ if _, ok := subsubchart["boat"]; ok {
+ t.Error("Expected sub-subchart ahab boat key to be removed, still present")
+ }
+
+ if _, ok := subsubchart["nested"].(map[string]interface{})["boat"]; ok {
+ t.Error("Expected sub-subchart nested boat key to be removed, still present")
+ }
+
+ if _, ok := subsubchart["object"]; ok {
+ t.Error("Expected sub-subchart object map to be removed, still present")
}
// CoalesceValues should not mutate the passed arguments
@@ -278,9 +303,7 @@ func TestMergeValues(t *testing.T) {
// to MergeValues as argument, so that we can
// use it for asserting later
valsCopy := make(Values, len(vals))
- for key, value := range vals {
- valsCopy[key] = value
- }
+ maps.Copy(valsCopy, vals)
v, err := MergeValues(c, vals)
if err != nil {
diff --git a/pkg/chart/v2/util/compatible.go b/pkg/chart/v2/util/compatible.go
new file mode 100644
index 000000000..d384d2d45
--- /dev/null
+++ b/pkg/chart/v2/util/compatible.go
@@ -0,0 +1,34 @@
+/*
+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 util
+
+import "github.com/Masterminds/semver/v3"
+
+// IsCompatibleRange compares a version to a constraint.
+// It returns true if the version matches the constraint, and false in all other cases.
+func IsCompatibleRange(constraint, ver string) bool {
+ sv, err := semver.NewVersion(ver)
+ if err != nil {
+ return false
+ }
+
+ c, err := semver.NewConstraint(constraint)
+ if err != nil {
+ return false
+ }
+ return c.Check(sv)
+}
diff --git a/pkg/chart/v2/util/compatible_test.go b/pkg/chart/v2/util/compatible_test.go
new file mode 100644
index 000000000..e17d33e35
--- /dev/null
+++ b/pkg/chart/v2/util/compatible_test.go
@@ -0,0 +1,43 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package version represents the current version of the project.
+package util
+
+import "testing"
+
+func TestIsCompatibleRange(t *testing.T) {
+ tests := []struct {
+ constraint string
+ ver string
+ expected bool
+ }{
+ {"v2.0.0-alpha.4", "v2.0.0-alpha.4", true},
+ {"v2.0.0-alpha.3", "v2.0.0-alpha.4", false},
+ {"v2.0.0", "v2.0.0-alpha.4", false},
+ {"v2.0.0-alpha.4", "v2.0.0", false},
+ {"~v2.0.0", "v2.0.1", true},
+ {"v2", "v2.0.0", true},
+ {">2.0.0", "v2.1.1", true},
+ {"v2.1.*", "v2.1.1", true},
+ }
+
+ for _, tt := range tests {
+ if IsCompatibleRange(tt.constraint, tt.ver) != tt.expected {
+ t.Errorf("expected constraint %s to be %v for %s", tt.constraint, tt.expected, tt.ver)
+ }
+ }
+}
diff --git a/pkg/chart/v2/util/create.go b/pkg/chart/v2/util/create.go
new file mode 100644
index 000000000..a8ae3ab40
--- /dev/null
+++ b/pkg/chart/v2/util/create.go
@@ -0,0 +1,832 @@
+/*
+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 util
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+)
+
+// chartName is a regular expression for testing the supplied name of a chart.
+// This regular expression is probably stricter than it needs to be. We can relax it
+// somewhat. Newline characters, as well as $, quotes, +, parens, and % are known to be
+// problematic.
+var chartName = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
+
+const (
+ // ChartfileName is the default Chart file name.
+ ChartfileName = "Chart.yaml"
+ // ValuesfileName is the default values file name.
+ ValuesfileName = "values.yaml"
+ // SchemafileName is the default values schema file name.
+ SchemafileName = "values.schema.json"
+ // TemplatesDir is the relative directory name for templates.
+ TemplatesDir = "templates"
+ // ChartsDir is the relative directory name for charts dependencies.
+ ChartsDir = "charts"
+ // TemplatesTestsDir is the relative directory name for tests.
+ TemplatesTestsDir = TemplatesDir + sep + "tests"
+ // IgnorefileName is the name of the Helm ignore file.
+ IgnorefileName = ".helmignore"
+ // IngressFileName is the name of the example ingress file.
+ IngressFileName = TemplatesDir + sep + "ingress.yaml"
+ // HTTPRouteFileName is the name of the example HTTPRoute file.
+ HTTPRouteFileName = TemplatesDir + sep + "httproute.yaml"
+ // DeploymentName is the name of the example deployment file.
+ DeploymentName = TemplatesDir + sep + "deployment.yaml"
+ // ServiceName is the name of the example service file.
+ ServiceName = TemplatesDir + sep + "service.yaml"
+ // ServiceAccountName is the name of the example serviceaccount file.
+ ServiceAccountName = TemplatesDir + sep + "serviceaccount.yaml"
+ // HorizontalPodAutoscalerName is the name of the example hpa file.
+ HorizontalPodAutoscalerName = TemplatesDir + sep + "hpa.yaml"
+ // NotesName is the name of the example NOTES.txt file.
+ NotesName = TemplatesDir + sep + "NOTES.txt"
+ // HelpersName is the name of the example helpers file.
+ HelpersName = TemplatesDir + sep + "_helpers.tpl"
+ // TestConnectionName is the name of the example test file.
+ TestConnectionName = TemplatesTestsDir + sep + "test-connection.yaml"
+)
+
+// maxChartNameLength is lower than the limits we know of with certain file systems,
+// and with certain Kubernetes fields.
+const maxChartNameLength = 250
+
+const sep = string(filepath.Separator)
+
+const defaultChartfile = `apiVersion: v2
+name: %s
+description: A Helm chart for Kubernetes
+
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.1.0
+
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+# It is recommended to use it with quotes.
+appVersion: "1.16.0"
+`
+
+const defaultValues = `# Default values for %s.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+
+# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
+replicaCount: 1
+
+# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
+image:
+ repository: nginx
+ # This sets the pull policy for images.
+ pullPolicy: IfNotPresent
+ # Overrides the image tag whose default is the chart appVersion.
+ tag: ""
+
+# This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
+imagePullSecrets: []
+# This is to override the chart name.
+nameOverride: ""
+fullnameOverride: ""
+
+# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
+serviceAccount:
+ # Specifies whether a service account should be created.
+ create: true
+ # Automatically mount a ServiceAccount's API credentials?
+ automount: true
+ # Annotations to add to the service account.
+ annotations: {}
+ # The name of the service account to use.
+ # If not set and create is true, a name is generated using the fullname template.
+ name: ""
+
+# This is for setting Kubernetes Annotations to a Pod.
+# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
+podAnnotations: {}
+# This is for setting Kubernetes Labels to a Pod.
+# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
+podLabels: {}
+
+podSecurityContext: {}
+ # fsGroup: 2000
+
+securityContext: {}
+ # capabilities:
+ # drop:
+ # - ALL
+ # readOnlyRootFilesystem: true
+ # runAsNonRoot: true
+ # runAsUser: 1000
+
+# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
+service:
+ # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
+ type: ClusterIP
+ # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
+ port: 80
+
+# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ # kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: "true"
+ hosts:
+ - host: chart-example.local
+ paths:
+ - path: /
+ pathType: ImplementationSpecific
+ tls: []
+ # - secretName: chart-example-tls
+ # hosts:
+ # - chart-example.local
+
+# -- Expose the service via gateway-api HTTPRoute
+# Requires Gateway API resources and suitable controller installed within the cluster
+# (see: https://gateway-api.sigs.k8s.io/guides/)
+httpRoute:
+ # HTTPRoute enabled.
+ enabled: false
+ # HTTPRoute annotations.
+ annotations: {}
+ # Which Gateways this Route is attached to.
+ parentRefs:
+ - name: gateway
+ sectionName: http
+ # namespace: default
+ # Hostnames matching HTTP header.
+ hostnames:
+ - chart-example.local
+ # List of rules and filters applied.
+ rules:
+ - matches:
+ - path:
+ type: PathPrefix
+ value: /headers
+ # filters:
+ # - type: RequestHeaderModifier
+ # requestHeaderModifier:
+ # set:
+ # - name: My-Overwrite-Header
+ # value: this-is-the-only-value
+ # remove:
+ # - User-Agent
+ # - matches:
+ # - path:
+ # type: PathPrefix
+ # value: /echo
+ # headers:
+ # - name: version
+ # value: v2
+
+resources: {}
+ # We usually recommend not to specify default resources and to leave this as a conscious
+ # choice for the user. This also increases chances charts run on environments with little
+ # resources, such as Minikube. If you do want to specify resources, uncomment the following
+ # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
+ # limits:
+ # cpu: 100m
+ # memory: 128Mi
+ # requests:
+ # cpu: 100m
+ # memory: 128Mi
+
+# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
+livenessProbe:
+ httpGet:
+ path: /
+ port: http
+readinessProbe:
+ httpGet:
+ path: /
+ port: http
+
+# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/
+autoscaling:
+ enabled: false
+ minReplicas: 1
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 80
+ # targetMemoryUtilizationPercentage: 80
+
+# Additional volumes on the output Deployment definition.
+volumes: []
+ # - name: foo
+ # secret:
+ # secretName: mysecret
+ # optional: false
+
+# Additional volumeMounts on the output Deployment definition.
+volumeMounts: []
+ # - name: foo
+ # mountPath: "/etc/foo"
+ # readOnly: true
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
+`
+
+const defaultIgnore = `# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
+`
+
+const defaultIngress = `{{- if .Values.ingress.enabled -}}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include ".fullname" . }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- with .Values.ingress.className }}
+ ingressClassName: {{ . }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ {{- with .pathType }}
+ pathType: {{ . }}
+ {{- end }}
+ backend:
+ service:
+ name: {{ include ".fullname" $ }}
+ port:
+ number: {{ $.Values.service.port }}
+ {{- end }}
+ {{- end }}
+{{- end }}
+`
+
+const defaultHTTPRoute = `{{- if .Values.httpRoute.enabled -}}
+{{- $fullName := include ".fullname" . -}}
+{{- $svcPort := .Values.service.port -}}
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: {{ $fullName }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+ {{- with .Values.httpRoute.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ parentRefs:
+ {{- with .Values.httpRoute.parentRefs }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- with .Values.httpRoute.hostnames }}
+ hostnames:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ rules:
+ {{- range .Values.httpRoute.rules }}
+ {{- with .matches }}
+ - matches:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .filters }}
+ filters:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ backendRefs:
+ - name: {{ $fullName }}
+ port: {{ $svcPort }}
+ weight: 1
+ {{- end }}
+{{- end }}
+`
+
+const defaultDeployment = `apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include ".fullname" . }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include ".selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include ".labels" . | nindent 8 }}
+ {{- with .Values.podLabels }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include ".serviceAccountName" . }}
+ {{- with .Values.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: {{ .Chart.Name }}
+ {{- with .Values.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: {{ .Values.service.port }}
+ protocol: TCP
+ {{- with .Values.livenessProbe }}
+ livenessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.readinessProbe }}
+ readinessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.volumeMounts }}
+ volumeMounts:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.volumes }}
+ volumes:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+`
+
+const defaultService = `apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include ".fullname" . }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include ".selectorLabels" . | nindent 4 }}
+`
+
+const defaultServiceAccount = `{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include ".serviceAccountName" . }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
+{{- end }}
+`
+
+const defaultHorizontalPodAutoscaler = `{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include ".fullname" . }}
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include ".fullname" . }}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- end }}
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ {{- end }}
+{{- end }}
+`
+
+const defaultNotes = `1. Get the application URL by running these commands:
+{{- if .Values.httpRoute.enabled }}
+{{- if .Values.httpRoute.hostnames }}
+ export APP_HOSTNAME={{ .Values.httpRoute.hostnames | first }}
+{{- else }}
+ export APP_HOSTNAME=$(kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o jsonpath="{.spec.listeners[0].hostname}")
+ {{- end }}
+{{- if and .Values.httpRoute.rules (first .Values.httpRoute.rules).matches (first (first .Values.httpRoute.rules).matches).path.value }}
+ echo "Visit http://$APP_HOSTNAME{{ (first (first .Values.httpRoute.rules).matches).path.value }} to use your application"
+
+ NOTE: Your HTTPRoute depends on the listener configuration of your gateway and your HTTPRoute rules.
+ The rules can be set for path, method, header and query parameters.
+ You can check the gateway configuration with 'kubectl get --namespace {{(first .Values.httpRoute.parentRefs).namespace | default .Release.Namespace }} gateway/{{ (first .Values.httpRoute.parentRefs).name }} -o yaml'
+{{- end }}
+{{- else if .Values.ingress.enabled }}
+{{- range $host := .Values.ingress.hosts }}
+ {{- range .paths }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
+ {{- end }}
+{{- end }}
+{{- else if contains "NodePort" .Values.service.type }}
+ export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include ".fullname" . }})
+ export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
+ echo http://$NODE_IP:$NODE_PORT
+{{- else if contains "LoadBalancer" .Values.service.type }}
+ NOTE: It may take a few minutes for the LoadBalancer IP to be available.
+ You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include ".fullname" . }}'
+ export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include ".fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
+ echo http://$SERVICE_IP:{{ .Values.service.port }}
+{{- else if contains "ClusterIP" .Values.service.type }}
+ export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include ".name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
+ export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
+ echo "Visit http://127.0.0.1:8080 to use your application"
+ kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+{{- end }}
+`
+
+const defaultHelpers = `{{/*
+Expand the name of the chart.
+*/}}
+{{- define ".name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define ".fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define ".chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define ".labels" -}}
+helm.sh/chart: {{ include ".chart" . }}
+{{ include ".selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define ".selectorLabels" -}}
+app.kubernetes.io/name: {{ include ".name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define ".serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include ".fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
+`
+
+const defaultTestConnection = `apiVersion: v1
+kind: Pod
+metadata:
+ name: "{{ include ".fullname" . }}-test-connection"
+ labels:
+ {{- include ".labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": test
+spec:
+ containers:
+ - name: wget
+ image: busybox
+ command: ['wget']
+ args: ['{{ include ".fullname" . }}:{{ .Values.service.port }}']
+ restartPolicy: Never
+`
+
+// Stderr is an io.Writer to which error messages can be written
+//
+// In Helm 4, this will be replaced. It is needed in Helm 3 to preserve API backward
+// compatibility.
+var Stderr io.Writer = os.Stderr
+
+// CreateFrom creates a new chart, but scaffolds it from the src chart.
+func CreateFrom(chartfile *chart.Metadata, dest, src string) error {
+ schart, err := loader.Load(src)
+ if err != nil {
+ return fmt.Errorf("could not load %s: %w", src, err)
+ }
+
+ schart.Metadata = chartfile
+
+ var updatedTemplates []*chart.File
+
+ for _, template := range schart.Templates {
+ newData := transform(string(template.Data), schart.Name())
+ updatedTemplates = append(updatedTemplates, &chart.File{Name: template.Name, Data: newData})
+ }
+
+ schart.Templates = updatedTemplates
+ b, err := yaml.Marshal(schart.Values)
+ if err != nil {
+ return fmt.Errorf("reading values file: %w", err)
+ }
+
+ var m map[string]interface{}
+ if err := yaml.Unmarshal(transform(string(b), schart.Name()), &m); err != nil {
+ return fmt.Errorf("transforming values file: %w", err)
+ }
+ schart.Values = m
+
+ // SaveDir looks for the file values.yaml when saving rather than the values
+ // key in order to preserve the comments in the YAML. The name placeholder
+ // needs to be replaced on that file.
+ for _, f := range schart.Raw {
+ if f.Name == ValuesfileName {
+ f.Data = transform(string(f.Data), schart.Name())
+ }
+ }
+
+ return SaveDir(schart, dest)
+}
+
+// Create creates a new chart in a directory.
+//
+// Inside of dir, this will create a directory based on the name of
+// chartfile.Name. It will then write the Chart.yaml into this directory and
+// create the (empty) appropriate directories.
+//
+// The returned string will point to the newly created directory. It will be
+// an absolute path, even if the provided base directory was relative.
+//
+// If dir does not exist, this will return an error.
+// If Chart.yaml or any directories cannot be created, this will return an
+// 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
+ }
+
+ path, err := filepath.Abs(dir)
+ if err != nil {
+ return path, err
+ }
+
+ if fi, err := os.Stat(path); err != nil {
+ return path, err
+ } else if !fi.IsDir() {
+ return path, fmt.Errorf("no such directory %s", path)
+ }
+
+ cdir := filepath.Join(path, name)
+ if fi, err := os.Stat(cdir); err == nil && !fi.IsDir() {
+ return cdir, fmt.Errorf("file %s already exists and is not a directory", cdir)
+ }
+
+ // Note: If adding a new template below (i.e., to `helm create`) which is disabled by default (similar to hpa and
+ // ingress below); or making an existing template disabled by default, add the enabling condition in
+ // `TestHelmCreateChart_CheckDeprecatedWarnings` in `pkg/lint/lint_test.go` to make it run through deprecation checks
+ // with latest Kubernetes version.
+ files := []struct {
+ path string
+ content []byte
+ }{
+ {
+ // Chart.yaml
+ path: filepath.Join(cdir, ChartfileName),
+ content: fmt.Appendf(nil, defaultChartfile, name),
+ },
+ {
+ // values.yaml
+ path: filepath.Join(cdir, ValuesfileName),
+ content: fmt.Appendf(nil, defaultValues, name),
+ },
+ {
+ // .helmignore
+ path: filepath.Join(cdir, IgnorefileName),
+ content: []byte(defaultIgnore),
+ },
+ {
+ // ingress.yaml
+ path: filepath.Join(cdir, IngressFileName),
+ content: transform(defaultIngress, name),
+ },
+ {
+ // httproute.yaml
+ path: filepath.Join(cdir, HTTPRouteFileName),
+ content: transform(defaultHTTPRoute, name),
+ },
+ {
+ // deployment.yaml
+ path: filepath.Join(cdir, DeploymentName),
+ content: transform(defaultDeployment, name),
+ },
+ {
+ // service.yaml
+ path: filepath.Join(cdir, ServiceName),
+ content: transform(defaultService, name),
+ },
+ {
+ // serviceaccount.yaml
+ path: filepath.Join(cdir, ServiceAccountName),
+ content: transform(defaultServiceAccount, name),
+ },
+ {
+ // hpa.yaml
+ path: filepath.Join(cdir, HorizontalPodAutoscalerName),
+ content: transform(defaultHorizontalPodAutoscaler, name),
+ },
+ {
+ // NOTES.txt
+ path: filepath.Join(cdir, NotesName),
+ content: transform(defaultNotes, name),
+ },
+ {
+ // _helpers.tpl
+ path: filepath.Join(cdir, HelpersName),
+ content: transform(defaultHelpers, name),
+ },
+ {
+ // test-connection.yaml
+ path: filepath.Join(cdir, TestConnectionName),
+ content: transform(defaultTestConnection, name),
+ },
+ }
+
+ for _, file := range files {
+ if _, err := os.Stat(file.path); err == nil {
+ // There is no handle to a preferred output stream here.
+ fmt.Fprintf(Stderr, "WARNING: File %q already exists. Overwriting.\n", file.path)
+ }
+ if err := writeFile(file.path, file.content); err != nil {
+ return cdir, err
+ }
+ }
+ // Need to add the ChartsDir explicitly as it does not contain any file OOTB
+ if err := os.MkdirAll(filepath.Join(cdir, ChartsDir), 0755); err != nil {
+ return cdir, err
+ }
+ return cdir, nil
+}
+
+// transform performs a string replacement of the specified source for
+// a given key with the replacement string
+func transform(src, replacement string) []byte {
+ return []byte(strings.ReplaceAll(src, "", replacement))
+}
+
+func writeFile(name string, content []byte) error {
+ if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(name, content, 0644)
+}
+
+func validateChartName(name string) error {
+ if name == "" || len(name) > maxChartNameLength {
+ return fmt.Errorf("chart name must be between 1 and %d characters", maxChartNameLength)
+ }
+ if !chartName.MatchString(name) {
+ return fmt.Errorf("chart name must match the regular expression %q", chartName.String())
+ }
+ return nil
+}
diff --git a/pkg/chartutil/create_test.go b/pkg/chart/v2/util/create_test.go
similarity index 97%
rename from pkg/chartutil/create_test.go
rename to pkg/chart/v2/util/create_test.go
index 1697c4218..086c4e5c8 100644
--- a/pkg/chartutil/create_test.go
+++ b/pkg/chart/v2/util/create_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"bytes"
@@ -22,8 +22,8 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
func TestCreate(t *testing.T) {
diff --git a/pkg/chartutil/dependencies.go b/pkg/chart/v2/util/dependencies.go
similarity index 85%
rename from pkg/chartutil/dependencies.go
rename to pkg/chart/v2/util/dependencies.go
index 77064c174..c2dded99c 100644
--- a/pkg/chartutil/dependencies.go
+++ b/pkg/chart/v2/util/dependencies.go
@@ -13,31 +13,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
- "log"
+ "fmt"
+ "log/slog"
"strings"
"github.com/mitchellh/copystructure"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// ProcessDependencies checks through this chart's dependencies, processing accordingly.
-//
-// TODO: For Helm v4 this can be combined with or turned into ProcessDependenciesWithMerge
func ProcessDependencies(c *chart.Chart, v Values) error {
- if err := processDependencyEnabled(c, v, ""); err != nil {
- return err
- }
- return processDependencyImportValues(c, false)
-}
-
-// ProcessDependenciesWithMerge checks through this chart's dependencies, processing accordingly.
-// It is similar to ProcessDependencies but it does not remove nil values during
-// the import/export handling process.
-func ProcessDependenciesWithMerge(c *chart.Chart, v Values) error {
if err := processDependencyEnabled(c, v, ""); err != nil {
return err
}
@@ -50,7 +39,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s
return
}
for _, r := range reqs {
- for _, c := range strings.Split(strings.TrimSpace(r.Condition), ",") {
+ for c := range strings.SplitSeq(strings.TrimSpace(r.Condition), ",") {
if len(c) > 0 {
// retrieve value
vv, err := cvals.PathValue(cpath + c)
@@ -59,12 +48,11 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s
if bv, ok := vv.(bool); ok {
r.Enabled = bv
break
- } else {
- log.Printf("Warning: Condition path '%s' for chart %s returned non-bool value", c, r.Name)
}
+ slog.Warn("returned non-bool value", "path", c, "chart", r.Name)
} else if _, ok := err.(ErrNoValue); !ok {
// this is a real error
- log.Printf("Warning: PathValue returned error %v", err)
+ slog.Warn("the method PathValue returned error", slog.Any("error", err))
}
}
}
@@ -92,7 +80,7 @@ func processDependencyTags(reqs []*chart.Dependency, cvals Values) {
hasFalse = true
}
} else {
- log.Printf("Warning: Tag '%s' for chart %s returned non-bool value", k, r.Name)
+ slog.Warn("returned non-bool value", "tag", k, "chart", r.Name)
}
}
}
@@ -104,6 +92,7 @@ func processDependencyTags(reqs []*chart.Dependency, cvals Values) {
}
}
+// getAliasDependency finds the chart for an alias dependency and copies parts that will be modified
func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Chart {
for _, c := range charts {
if c == nil {
@@ -117,17 +106,38 @@ func getAliasDependency(charts []*chart.Chart, dep *chart.Dependency) *chart.Cha
}
out := *c
- md := *c.Metadata
- out.Metadata = &md
+ out.Metadata = copyMetadata(c.Metadata)
+
+ // empty dependencies and shallow copy all dependencies, otherwise parent info may be corrupted if
+ // there is more than one dependency aliasing this chart
+ out.SetDependencies()
+ for _, dependency := range c.Dependencies() {
+ cpy := *dependency
+ out.AddDependency(&cpy)
+ }
if dep.Alias != "" {
- md.Name = dep.Alias
+ out.Metadata.Name = dep.Alias
}
return &out
}
return nil
}
+func copyMetadata(metadata *chart.Metadata) *chart.Metadata {
+ md := *metadata
+
+ if md.Dependencies != nil {
+ dependencies := make([]*chart.Dependency, len(md.Dependencies))
+ for i := range md.Dependencies {
+ dependency := *md.Dependencies[i]
+ dependencies[i] = &dependency
+ }
+ md.Dependencies = dependencies
+ }
+ return &md
+}
+
// processDependencyEnabled removes disabled charts from dependencies
func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path string) error {
if c.Metadata.Dependencies == nil {
@@ -138,7 +148,7 @@ func processDependencyEnabled(c *chart.Chart, v map[string]interface{}, path str
// If any dependency is not a part of Chart.yaml
// then this should be added to chartDependencies.
// However, if the dependency is already specified in Chart.yaml
- // we should not add it, as it would be anyways processed from Chart.yaml
+ // we should not add it, as it would be processed from Chart.yaml anyway.
Loop:
for _, existing := range c.Dependencies() {
@@ -256,8 +266,8 @@ func processImportValues(c *chart.Chart, merge bool) error {
for _, riv := range r.ImportValues {
switch iv := riv.(type) {
case map[string]interface{}:
- child := iv["child"].(string)
- parent := iv["parent"].(string)
+ child := fmt.Sprintf("%v", iv["child"])
+ parent := fmt.Sprintf("%v", iv["parent"])
outiv = append(outiv, map[string]string{
"child": child,
@@ -271,7 +281,7 @@ func processImportValues(c *chart.Chart, merge bool) error {
}
vv, err := cvals.Table(ref)
if err != nil {
- log.Printf("Warning: ImportValues missing table from chart %s: %v", r.Name, err)
+ slog.Warn("ImportValues missing table from chart", "chart", r.Name, slog.Any("error", err))
continue
}
// create value map from child to be merged into parent
@@ -288,7 +298,7 @@ func processImportValues(c *chart.Chart, merge bool) error {
})
vm, err := cvals.Table(r.Name + "." + child)
if err != nil {
- log.Printf("Warning: ImportValues missing table: %v", err)
+ slog.Warn("ImportValues missing table", slog.Any("error", err))
continue
}
if merge {
@@ -338,11 +348,9 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} {
valsCopyMap := valsCopy.(map[string]interface{})
for key, val := range valsCopyMap {
if val == nil {
- log.Printf("trim deleting %q", key)
// Iterate over the values and remove nil keys
delete(valsCopyMap, key)
} else if istable(val) {
- log.Printf("trim copying %q", key)
// Recursively call into ourselves to remove keys from inner tables
valsCopyMap[key] = trimNilValues(val.(map[string]interface{}))
}
diff --git a/pkg/chartutil/dependencies_test.go b/pkg/chart/v2/util/dependencies_test.go
similarity index 88%
rename from pkg/chartutil/dependencies_test.go
rename to pkg/chart/v2/util/dependencies_test.go
index 0a37cf7ca..1cf78f0c5 100644
--- a/pkg/chartutil/dependencies_test.go
+++ b/pkg/chart/v2/util/dependencies_test.go
@@ -12,7 +12,7 @@ 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 chartutil
+package util
import (
"os"
@@ -21,8 +21,8 @@ import (
"strconv"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
func loadChart(t *testing.T, path string) *chart.Chart {
@@ -133,7 +133,7 @@ func TestDependencyEnabled(t *testing.T) {
}
}
-// extractCharts recursively searches chart dependencies returning all charts found
+// extractChartNames recursively searches chart dependencies returning all charts found
func extractChartNames(c *chart.Chart) []string {
var out []string
var fn func(c *chart.Chart)
@@ -282,6 +282,38 @@ func TestProcessDependencyImportValues(t *testing.T) {
}
}
+func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T) {
+ c := loadChart(t, "testdata/chart-with-import-from-aliased-dependencies")
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+ if err := processDependencyImportValues(c, true); err != nil {
+ t.Fatalf("processing import values dependencies %v", err)
+ }
+ e := make(map[string]string)
+
+ e["foo-defaults.defaultValue"] = "42"
+ e["bar-defaults.defaultValue"] = "42"
+
+ e["foo.defaults.defaultValue"] = "42"
+ e["bar.defaults.defaultValue"] = "42"
+
+ e["foo.grandchild.defaults.defaultValue"] = "42"
+ e["bar.grandchild.defaults.defaultValue"] = "42"
+
+ cValues := Values(c.Values)
+ for kk, vv := range e {
+ pv, err := cValues.PathValue(kk)
+ if err != nil {
+ t.Fatalf("retrieving import values table %v %v", kk, err)
+ }
+ if pv != vv {
+ t.Errorf("failed to match imported value %v with expected %v", pv, vv)
+ }
+ }
+}
+
func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) {
c := loadChart(t, "testdata/three-level-dependent-chart/umbrella")
@@ -422,6 +454,9 @@ func TestDependentChartAliases(t *testing.T) {
if aliasChart == nil {
t.Fatalf("failed to get dependency chart for alias %s", req[2].Name)
}
+ if aliasChart.Parent() != c {
+ t.Fatalf("dependency chart has wrong parent, expected %s but got %s", c.Name(), aliasChart.Parent().Name())
+ }
if req[2].Alias != "" {
if aliasChart.Name() != req[2].Alias {
t.Fatalf("dependency chart name should be %s but got %s", req[2].Alias, aliasChart.Name())
@@ -513,3 +548,33 @@ func TestDependentChartsWithSomeSubchartsSpecifiedInDependency(t *testing.T) {
t.Fatalf("expected 1 dependency specified in Chart.yaml, got %d", len(c.Metadata.Dependencies))
}
}
+
+func validateDependencyTree(t *testing.T, c *chart.Chart) {
+ t.Helper()
+ for _, dependency := range c.Dependencies() {
+ if dependency.Parent() != c {
+ if dependency.Parent() != c {
+ t.Fatalf("dependency chart %s has wrong parent, expected %s but got %s", dependency.Name(), c.Name(), dependency.Parent().Name())
+ }
+ }
+ // recurse entire tree
+ validateDependencyTree(t, dependency)
+ }
+}
+
+func TestChartWithDependencyAliasedTwiceAndDoublyReferencedSubDependency(t *testing.T) {
+ c := loadChart(t, "testdata/chart-with-dependency-aliased-twice")
+
+ if len(c.Dependencies()) != 1 {
+ t.Fatalf("expected one dependency for this chart, but got %d", len(c.Dependencies()))
+ }
+
+ if err := processDependencyEnabled(c, c.Values, ""); err != nil {
+ t.Fatalf("expected no errors but got %q", err)
+ }
+
+ if len(c.Dependencies()) != 2 {
+ t.Fatal("expected two dependencies after processing aliases")
+ }
+ validateDependencyTree(t, c)
+}
diff --git a/pkg/chartutil/doc.go b/pkg/chart/v2/util/doc.go
similarity index 92%
rename from pkg/chartutil/doc.go
rename to pkg/chart/v2/util/doc.go
index 49c55ac52..141062074 100644
--- a/pkg/chartutil/doc.go
+++ b/pkg/chart/v2/util/doc.go
@@ -15,7 +15,7 @@ limitations under the License.
*/
/*
-Package chartutil contains tools for working with charts.
+package util contains tools for working with charts.
Charts are described in the chart package (pkg/chart).
This package provides utilities for serializing and deserializing charts.
@@ -42,4 +42,4 @@ into a Chart.
When creating charts in memory, use the 'helm.sh/helm/pkg/chart'
package directly.
*/
-package chartutil // import "helm.sh/helm/v3/pkg/chartutil"
+package util // import chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
diff --git a/pkg/chart/v2/util/errors.go b/pkg/chart/v2/util/errors.go
new file mode 100644
index 000000000..a175b9758
--- /dev/null
+++ b/pkg/chart/v2/util/errors.go
@@ -0,0 +1,43 @@
+/*
+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 util
+
+import (
+ "fmt"
+)
+
+// ErrNoTable indicates that a chart does not have a matching table.
+type ErrNoTable struct {
+ Key string
+}
+
+func (e ErrNoTable) Error() string { return fmt.Sprintf("%q is not a table", e.Key) }
+
+// ErrNoValue indicates that Values does not contain a key with a value
+type ErrNoValue struct {
+ Key string
+}
+
+func (e ErrNoValue) Error() string { return fmt.Sprintf("%q is not a value", e.Key) }
+
+type ErrInvalidChartName struct {
+ Name string
+}
+
+func (e ErrInvalidChartName) Error() string {
+ return fmt.Sprintf("%q is not a valid chart name", e.Name)
+}
diff --git a/cmd/helm/repo_list_test.go b/pkg/chart/v2/util/errors_test.go
similarity index 71%
rename from cmd/helm/repo_list_test.go
rename to pkg/chart/v2/util/errors_test.go
index 90149ebda..b8ae86384 100644
--- a/cmd/helm/repo_list_test.go
+++ b/pkg/chart/v2/util/errors_test.go
@@ -14,16 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package util
import (
"testing"
)
-func TestRepoListOutputCompletion(t *testing.T) {
- outputFlagCompletionTest(t, "repo list")
+func TestErrorNoTableDoesNotPanic(t *testing.T) {
+ x := "empty"
+
+ y := ErrNoTable{x}
+
+ t.Logf("error is: %s", y)
}
-func TestRepoListFileCompletion(t *testing.T) {
- checkFileCompletion(t, "repo list", false)
+func TestErrorNoValueDoesNotPanic(t *testing.T) {
+ x := "empty"
+
+ y := ErrNoValue{x}
+
+ t.Logf("error is: %s", y)
}
diff --git a/pkg/chartutil/expand.go b/pkg/chart/v2/util/expand.go
similarity index 84%
rename from pkg/chartutil/expand.go
rename to pkg/chart/v2/util/expand.go
index 7ae1ae6fa..9d08571ed 100644
--- a/pkg/chartutil/expand.go
+++ b/pkg/chart/v2/util/expand.go
@@ -14,19 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
+ "errors"
+ "fmt"
"io"
"os"
"path/filepath"
securejoin "github.com/cyphar/filepath-securejoin"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
// Expand uncompresses and extracts a chart into the specified directory.
@@ -42,7 +43,7 @@ func Expand(dir string, r io.Reader) error {
if file.Name == "Chart.yaml" {
ch := &chart.Metadata{}
if err := yaml.Unmarshal(file.Data, ch); err != nil {
- return errors.Wrap(err, "cannot load Chart.yaml")
+ return fmt.Errorf("cannot load Chart.yaml: %w", err)
}
chartName = ch.Name
}
@@ -52,6 +53,9 @@ func Expand(dir string, r io.Reader) error {
}
// 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.
+ dir = filepath.Clean(dir)
chartdir, err := securejoin.SecureJoin(dir, chartName)
if err != nil {
return err
diff --git a/pkg/chart/v2/util/expand_test.go b/pkg/chart/v2/util/expand_test.go
new file mode 100644
index 000000000..280995f7e
--- /dev/null
+++ b/pkg/chart/v2/util/expand_test.go
@@ -0,0 +1,124 @@
+/*
+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 util
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestExpand(t *testing.T) {
+ dest := t.TempDir()
+
+ reader, err := os.Open("testdata/frobnitz-1.2.3.tgz")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Expand(dest, reader); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedChartPath := filepath.Join(dest, "frobnitz")
+ fi, err := os.Stat(expectedChartPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !fi.IsDir() {
+ t.Fatalf("expected a chart directory at %s", expectedChartPath)
+ }
+
+ dir, err := os.Open(expectedChartPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fis, err := dir.Readdir(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectLen := 11
+ if len(fis) != expectLen {
+ t.Errorf("Expected %d files, but got %d", expectLen, len(fis))
+ }
+
+ for _, fi := range fis {
+ expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // os.Stat can return different values for directories, based on the OS
+ // for Linux, for example, os.Stat always returns the size of the directory
+ // (value-4096) regardless of the size of the contents of the directory
+ mode := expect.Mode()
+ if !mode.IsDir() {
+ if fi.Size() != expect.Size() {
+ t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size())
+ }
+ }
+ }
+}
+
+func TestExpandFile(t *testing.T) {
+ dest := t.TempDir()
+
+ if err := ExpandFile(dest, "testdata/frobnitz-1.2.3.tgz"); err != nil {
+ t.Fatal(err)
+ }
+
+ expectedChartPath := filepath.Join(dest, "frobnitz")
+ fi, err := os.Stat(expectedChartPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !fi.IsDir() {
+ t.Fatalf("expected a chart directory at %s", expectedChartPath)
+ }
+
+ dir, err := os.Open(expectedChartPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ fis, err := dir.Readdir(0)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectLen := 11
+ if len(fis) != expectLen {
+ t.Errorf("Expected %d files, but got %d", expectLen, len(fis))
+ }
+
+ for _, fi := range fis {
+ expect, err := os.Stat(filepath.Join("testdata", "frobnitz", fi.Name()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // os.Stat can return different values for directories, based on the OS
+ // for Linux, for example, os.Stat always returns the size of the directory
+ // (value-4096) regardless of the size of the contents of the directory
+ mode := expect.Mode()
+ if !mode.IsDir() {
+ if fi.Size() != expect.Size() {
+ t.Errorf("Expected %s to have size %d, got %d", fi.Name(), expect.Size(), fi.Size())
+ }
+ }
+ }
+}
diff --git a/pkg/chart/v2/util/jsonschema.go b/pkg/chart/v2/util/jsonschema.go
new file mode 100644
index 000000000..72e133363
--- /dev/null
+++ b/pkg/chart/v2/util/jsonschema.go
@@ -0,0 +1,163 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package util
+
+import (
+ "bytes"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+
+ "helm.sh/helm/v4/internal/version"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+)
+
+// HTTPURLLoader implements a loader for HTTP/HTTPS URLs
+type HTTPURLLoader http.Client
+
+func (l *HTTPURLLoader) Load(urlStr string) (any, error) {
+ client := (*http.Client)(l)
+
+ req, err := http.NewRequest(http.MethodGet, urlStr, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create HTTP request for %s: %w", urlStr, err)
+ }
+ req.Header.Set("User-Agent", version.GetUserAgent())
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("HTTP request failed for %s: %w", urlStr, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP request to %s returned status %d (%s)", urlStr, resp.StatusCode, http.StatusText(resp.StatusCode))
+ }
+
+ return jsonschema.UnmarshalJSON(resp.Body)
+}
+
+// newHTTPURLLoader creates a HTTP URL loader with proxy support.
+func newHTTPURLLoader() *HTTPURLLoader {
+ httpLoader := HTTPURLLoader(http.Client{
+ Timeout: 15 * time.Second,
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ TLSClientConfig: &tls.Config{},
+ },
+ })
+ return &httpLoader
+}
+
+// ValidateAgainstSchema checks that values does not violate the structure laid out in schema
+func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error {
+ var sb strings.Builder
+ if chrt.Schema != nil {
+ slog.Debug("chart name", "chart-name", chrt.Name())
+ err := ValidateAgainstSingleSchema(values, chrt.Schema)
+ if err != nil {
+ sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name()))
+ sb.WriteString(err.Error())
+ }
+ }
+ slog.Debug("number of dependencies in the chart", "dependencies", len(chrt.Dependencies()))
+ // For each dependency, recursively call this function with the coalesced values
+ for _, subchart := range chrt.Dependencies() {
+ subchartValues := values[subchart.Name()].(map[string]interface{})
+ if err := ValidateAgainstSchema(subchart, subchartValues); err != nil {
+ sb.WriteString(err.Error())
+ }
+ }
+
+ if sb.Len() > 0 {
+ return errors.New(sb.String())
+ }
+
+ return nil
+}
+
+// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema
+func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error) {
+ defer func() {
+ if r := recover(); r != nil {
+ reterr = fmt.Errorf("unable to validate schema: %s", r)
+ }
+ }()
+
+ // This unmarshal function leverages UseNumber() for number precision. The parser
+ // used for values does this as well.
+ schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
+ if err != nil {
+ return err
+ }
+ slog.Debug("unmarshalled JSON schema", "schema", schemaJSON)
+
+ // Configure compiler with loaders for different URL schemes
+ loader := jsonschema.SchemeURLLoader{
+ "file": jsonschema.FileLoader{},
+ "http": newHTTPURLLoader(),
+ "https": newHTTPURLLoader(),
+ }
+
+ compiler := jsonschema.NewCompiler()
+ compiler.UseLoader(loader)
+ err = compiler.AddResource("file:///values.schema.json", schema)
+ if err != nil {
+ return err
+ }
+
+ validator, err := compiler.Compile("file:///values.schema.json")
+ if err != nil {
+ return err
+ }
+
+ err = validator.Validate(values.AsMap())
+ if err != nil {
+ return JSONSchemaValidationError{err}
+ }
+
+ return nil
+}
+
+// Note, JSONSchemaValidationError is used to wrap the error from the underlying
+// validation package so that Helm has a clean interface and the validation package
+// could be replaced without changing the Helm SDK API.
+
+// JSONSchemaValidationError is the error returned when there is a schema validation
+// error.
+type JSONSchemaValidationError struct {
+ embeddedErr error
+}
+
+// Error prints the error message
+func (e JSONSchemaValidationError) Error() string {
+ errStr := e.embeddedErr.Error()
+
+ // This string prefixes all of our error details. Further up the stack of helm error message
+ // building more detail is provided to users. This is removed.
+ errStr = strings.TrimPrefix(errStr, "jsonschema validation failed with 'file:///values.schema.json#'\n")
+
+ // The extra new line is needed for when there are sub-charts.
+ return errStr + "\n"
+}
diff --git a/pkg/chart/v2/util/jsonschema_test.go b/pkg/chart/v2/util/jsonschema_test.go
new file mode 100644
index 000000000..cd95b7faf
--- /dev/null
+++ b/pkg/chart/v2/util/jsonschema_test.go
@@ -0,0 +1,287 @@
+/*
+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 util
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+)
+
+func TestValidateAgainstSingleSchema(t *testing.T) {
+ values, err := ReadValuesFile("./testdata/test-values.yaml")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+ schema, err := os.ReadFile("./testdata/test-values.schema.json")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+
+ if err := ValidateAgainstSingleSchema(values, schema); err != nil {
+ t.Errorf("Error validating Values against Schema: %s", err)
+ }
+}
+
+func TestValidateAgainstInvalidSingleSchema(t *testing.T) {
+ values, err := ReadValuesFile("./testdata/test-values.yaml")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+ schema, err := os.ReadFile("./testdata/test-values-invalid.schema.json")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+
+ var errString string
+ if err := ValidateAgainstSingleSchema(values, schema); err == nil {
+ t.Fatalf("Expected an error, but got nil")
+ } else {
+ errString = err.Error()
+ }
+
+ expectedErrString := `"file:///values.schema.json#" is not valid against metaschema: jsonschema validation failed with 'https://json-schema.org/draft/2020-12/schema#'
+- at '': got number, want boolean or object`
+ if errString != expectedErrString {
+ t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
+ }
+}
+
+func TestValidateAgainstSingleSchemaNegative(t *testing.T) {
+ values, err := ReadValuesFile("./testdata/test-values-negative.yaml")
+ if err != nil {
+ t.Fatalf("Error reading YAML file: %s", err)
+ }
+ schema, err := os.ReadFile("./testdata/test-values.schema.json")
+ if err != nil {
+ t.Fatalf("Error reading JSON file: %s", err)
+ }
+
+ var errString string
+ if err := ValidateAgainstSingleSchema(values, schema); err == nil {
+ t.Fatalf("Expected an error, but got nil")
+ } else {
+ errString = err.Error()
+ }
+
+ expectedErrString := `- at '': missing property 'employmentInfo'
+- at '/age': minimum: got -5, want 0
+`
+ if errString != expectedErrString {
+ t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
+ }
+}
+
+const subchartSchema = `{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Values",
+ "type": "object",
+ "properties": {
+ "age": {
+ "description": "Age",
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "age"
+ ]
+}
+`
+
+const subchartSchema2020 = `{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "Values",
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "array",
+ "contains": { "type": "string" },
+ "unevaluatedItems": { "type": "number" }
+ }
+ },
+ "required": ["data"]
+}
+`
+
+func TestValidateAgainstSchema(t *testing.T) {
+ subchartJSON := []byte(subchartSchema)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{
+ "age": 25,
+ },
+ }
+
+ if err := ValidateAgainstSchema(chrt, vals); err != nil {
+ t.Errorf("Error validating Values against Schema: %s", err)
+ }
+}
+
+func TestValidateAgainstSchemaNegative(t *testing.T) {
+ subchartJSON := []byte(subchartSchema)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{},
+ }
+
+ var errString string
+ if err := ValidateAgainstSchema(chrt, vals); err == nil {
+ t.Fatalf("Expected an error, but got nil")
+ } else {
+ errString = err.Error()
+ }
+
+ expectedErrString := `subchart:
+- at '': missing property 'age'
+`
+ if errString != expectedErrString {
+ t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
+ }
+}
+
+func TestValidateAgainstSchema2020(t *testing.T) {
+ subchartJSON := []byte(subchartSchema2020)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{
+ "data": []any{"hello", 12},
+ },
+ }
+
+ if err := ValidateAgainstSchema(chrt, vals); err != nil {
+ t.Errorf("Error validating Values against Schema: %s", err)
+ }
+}
+
+func TestValidateAgainstSchema2020Negative(t *testing.T) {
+ subchartJSON := []byte(subchartSchema2020)
+ subchart := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "subchart",
+ },
+ Schema: subchartJSON,
+ }
+ chrt := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chrt",
+ },
+ }
+ chrt.AddDependency(subchart)
+
+ vals := map[string]interface{}{
+ "name": "John",
+ "subchart": map[string]interface{}{
+ "data": []any{12},
+ },
+ }
+
+ var errString string
+ if err := ValidateAgainstSchema(chrt, vals); err == nil {
+ t.Fatalf("Expected an error, but got nil")
+ } else {
+ errString = err.Error()
+ }
+
+ expectedErrString := `subchart:
+- at '/data': no items match contains schema
+ - at '/data/0': got number, want string
+`
+ if errString != expectedErrString {
+ t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString)
+ }
+}
+
+func TestHTTPURLLoader_Load(t *testing.T) {
+ // Test successful JSON schema loading
+ t.Run("successful load", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"type": "object", "properties": {"name": {"type": "string"}}}`))
+ }))
+ defer server.Close()
+
+ loader := newHTTPURLLoader()
+ result, err := loader.Load(server.URL)
+ if err != nil {
+ t.Fatalf("Expected no error, got: %v", err)
+ }
+ if result == nil {
+ t.Fatal("Expected result to be non-nil")
+ }
+ })
+
+ t.Run("HTTP error status", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ loader := newHTTPURLLoader()
+ _, err := loader.Load(server.URL)
+ if err == nil {
+ t.Fatal("Expected error for HTTP 404")
+ }
+ if !strings.Contains(err.Error(), "404") {
+ t.Errorf("Expected error message to contain '404', got: %v", err)
+ }
+ })
+}
diff --git a/pkg/chartutil/save.go b/pkg/chart/v2/util/save.go
similarity index 86%
rename from pkg/chartutil/save.go
rename to pkg/chart/v2/util/save.go
index 2ce4eddaf..624a5b562 100644
--- a/pkg/chartutil/save.go
+++ b/pkg/chart/v2/util/save.go
@@ -14,21 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"archive/tar"
"compress/gzip"
"encoding/json"
+ "errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"time"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=")
@@ -39,9 +40,13 @@ var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=")
// directory, writing the chart's contents to that subdirectory.
func SaveDir(c *chart.Chart, dest string) error {
// Create the chart directory
+ err := validateName(c.Name())
+ if err != nil {
+ return err
+ }
outdir := filepath.Join(dest, c.Name())
if fi, err := os.Stat(outdir); err == nil && !fi.IsDir() {
- return errors.Errorf("file %s already exists and is not a directory", outdir)
+ return fmt.Errorf("file %s already exists and is not a directory", outdir)
}
if err := os.MkdirAll(outdir, 0755); err != nil {
return err
@@ -85,7 +90,7 @@ func SaveDir(c *chart.Chart, dest string) error {
for _, dep := range c.Dependencies() {
// Here, we write each dependency as a tar file.
if _, err := Save(dep, base); err != nil {
- return errors.Wrapf(err, "saving %s", dep.ChartFullPath())
+ return fmt.Errorf("saving %s: %w", dep.ChartFullPath(), err)
}
}
return nil
@@ -101,22 +106,22 @@ func SaveDir(c *chart.Chart, dest string) error {
// This returns the absolute path to the chart archive file.
func Save(c *chart.Chart, outDir string) (string, error) {
if err := c.Validate(); err != nil {
- return "", errors.Wrap(err, "chart validation")
+ return "", fmt.Errorf("chart validation: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", c.Name(), c.Metadata.Version)
filename = filepath.Join(outDir, filename)
dir := filepath.Dir(filename)
if stat, err := os.Stat(dir); err != nil {
- if os.IsNotExist(err) {
+ if errors.Is(err, fs.ErrNotExist) {
if err2 := os.MkdirAll(dir, 0755); err2 != nil {
return "", err2
}
} else {
- return "", errors.Wrapf(err, "stat %s", dir)
+ return "", fmt.Errorf("stat %s: %w", dir, err)
}
} else if !stat.IsDir() {
- return "", errors.Errorf("is not a directory: %s", dir)
+ return "", fmt.Errorf("is not a directory: %s", dir)
}
f, err := os.Create(filename)
@@ -126,8 +131,8 @@ func Save(c *chart.Chart, outDir string) (string, error) {
// Wrap in gzip writer
zipper := gzip.NewWriter(f)
- zipper.Header.Extra = headerBytes
- zipper.Header.Comment = "Helm"
+ zipper.Extra = headerBytes
+ zipper.Comment = "Helm"
// Wrap in tar writer
twriter := tar.NewWriter(zipper)
@@ -149,6 +154,10 @@ func Save(c *chart.Chart, outDir string) (string, error) {
}
func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
+ err := validateName(c.Name())
+ if err != nil {
+ return err
+ }
base := filepath.Join(prefix, c.Name())
// Pull out the dependencies of a v1 Chart, since there's no way
@@ -195,7 +204,7 @@ func writeTarContents(out *tar.Writer, c *chart.Chart, prefix string) error {
// Save values.schema.json if it exists
if c.Schema != nil {
if !json.Valid(c.Schema) {
- return errors.New("Invalid JSON in " + SchemafileName)
+ return errors.New("invalid JSON in " + SchemafileName)
}
if err := writeToTar(out, filepath.Join(base, SchemafileName), c.Schema); err != nil {
return err
@@ -242,3 +251,15 @@ func writeToTar(out *tar.Writer, name string, body []byte) error {
_, err := out.Write(body)
return err
}
+
+// If the name has directory name has characters which would change the location
+// they need to be removed.
+func validateName(name string) error {
+ nname := filepath.Base(name)
+
+ if nname != name {
+ return ErrInvalidChartName{name}
+ }
+
+ return nil
+}
diff --git a/pkg/chartutil/save_test.go b/pkg/chart/v2/util/save_test.go
similarity index 87%
rename from pkg/chartutil/save_test.go
rename to pkg/chart/v2/util/save_test.go
index db485c7cb..ff96331b5 100644
--- a/pkg/chartutil/save_test.go
+++ b/pkg/chart/v2/util/save_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"archive/tar"
@@ -29,8 +29,8 @@ import (
"testing"
"time"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
func TestSave(t *testing.T) {
@@ -106,6 +106,24 @@ func TestSave(t *testing.T) {
}
})
}
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV1,
+ Name: "../ahab",
+ Version: "1.2.3",
+ },
+ Lock: &chart.Lock{
+ Digest: "testdigest",
+ },
+ Files: []*chart.File{
+ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")},
+ },
+ }
+ _, err := Save(c, tmp)
+ if err == nil {
+ t.Fatal("Expected error saving chart with invalid name")
+ }
}
// Creates a copy with a different schema; does not modify anything.
@@ -232,4 +250,15 @@ func TestSaveDir(t *testing.T) {
if len(c2.Files) != 1 || c2.Files[0].Name != c.Files[0].Name {
t.Fatal("Files data did not match")
}
+
+ tmp2 := t.TempDir()
+ c.Metadata.Name = "../ahab"
+ pth := filepath.Join(tmp2, "tmpcharts")
+ if err := os.MkdirAll(filepath.Join(pth), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := SaveDir(c, pth); err.Error() != "\"../ahab\" is not a valid chart name" {
+ t.Fatalf("Did not get expected error for chart named %q", c.Name())
+ }
}
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml
new file mode 100644
index 000000000..d778f8fe9
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/Chart.yaml
@@ -0,0 +1,14 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: chart-with-dependency-aliased-twice
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: child
+ alias: foo
+ version: 1.0.0
+ - name: child
+ alias: bar
+ version: 1.0.0
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml
new file mode 100644
index 000000000..220fda663
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: child
+type: application
+version: 1.0.0
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml
new file mode 100644
index 000000000..50e620a8d
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: grandchild
+type: application
+version: 1.0.0
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml
new file mode 100644
index 000000000..1830492ef
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/charts/grandchild/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}-{{ .Values.from }}
+data:
+ {{- toYaml .Values | nindent 2 }}
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml
new file mode 100644
index 000000000..b5d55af7c
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/charts/child/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{- toYaml .Values | nindent 2 }}
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml
new file mode 100644
index 000000000..695521a4a
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-dependency-aliased-twice/values.yaml
@@ -0,0 +1,7 @@
+foo:
+ grandchild:
+ from: foo
+bar:
+ grandchild:
+ from: bar
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml
new file mode 100644
index 000000000..c408f0ca8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/Chart.yaml
@@ -0,0 +1,20 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: chart-with-dependency-aliased-twice
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: child
+ alias: foo
+ version: 1.0.0
+ import-values:
+ - parent: foo-defaults
+ child: defaults
+ - name: child
+ alias: bar
+ version: 1.0.0
+ import-values:
+ - parent: bar-defaults
+ child: defaults
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml
new file mode 100644
index 000000000..ecdaf04dc
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/Chart.yaml
@@ -0,0 +1,12 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: child
+type: application
+version: 1.0.0
+
+dependencies:
+ - name: grandchild
+ version: 1.0.0
+ import-values:
+ - parent: defaults
+ child: defaults
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml
new file mode 100644
index 000000000..50e620a8d
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: 1.0.0
+name: grandchild
+type: application
+version: 1.0.0
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml
new file mode 100644
index 000000000..f51c594f4
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/charts/grandchild/values.yaml
@@ -0,0 +1,2 @@
+defaults:
+ defaultValue: "42"
\ No newline at end of file
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml
new file mode 100644
index 000000000..3140f53dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/charts/child/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{ .Values.defaults | toYaml }}
+
diff --git a/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml
new file mode 100644
index 000000000..a2b62c95a
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/chart-with-import-from-aliased-dependencies/templates/dummy.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ .Chart.Name }}
+data:
+ {{ toYaml .Values.defaults | indent 2 }}
+
diff --git a/pkg/chartutil/testdata/chartfiletest.yaml b/pkg/chart/v2/util/testdata/chartfiletest.yaml
similarity index 100%
rename from pkg/chartutil/testdata/chartfiletest.yaml
rename to pkg/chart/v2/util/testdata/chartfiletest.yaml
diff --git a/pkg/chart/v2/util/testdata/coleridge.yaml b/pkg/chart/v2/util/testdata/coleridge.yaml
new file mode 100644
index 000000000..b6579628b
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/coleridge.yaml
@@ -0,0 +1,12 @@
+poet: "Coleridge"
+title: "Rime of the Ancient Mariner"
+stanza: ["at", "length", "did", "cross", "an", "Albatross"]
+
+mariner:
+ with: "crossbow"
+ shot: "ALBATROSS"
+
+water:
+ water:
+ where: "everywhere"
+ nor: "any drop to drink"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore b/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock b/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock
new file mode 100644
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-alias/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt b/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE b/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md b/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/templates/alpine-pod.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..3190136b0
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-alias/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md b/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg b/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-alias/ignore/me.txt
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl b/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-alias/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore
new file mode 100644
index 000000000..8a71bc82e
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/.helmignore
@@ -0,0 +1,2 @@
+ignore/
+.*
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-helmignore/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/.ignore_me
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/values.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/.ignore_me
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-helmignore/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..3190136b0
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/ignore/me.txt
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-no-requirements-yaml/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..3190136b0
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/testcharts/object-order/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/object-order/values.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/ignore/me.txt
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-all-in-requirements-yaml/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz
new file mode 100644
index 000000000..3190136b0
Binary files /dev/null and b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/charts/mariner-4.3.2.tgz differ
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/cmd/helm/testdata/testcharts/signtest/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/values.yaml
rename to pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/ignore/me.txt
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/dependent-chart-with-mixed-requirements-yaml/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/frobnitz-1.2.3.tgz b/pkg/chart/v2/util/testdata/frobnitz-1.2.3.tgz
new file mode 100644
index 000000000..8731dce02
Binary files /dev/null and b/pkg/chart/v2/util/testdata/frobnitz-1.2.3.tgz differ
diff --git a/pkg/chart/v2/util/testdata/frobnitz/.helmignore b/pkg/chart/v2/util/testdata/frobnitz/.helmignore
new file mode 100644
index 000000000..9973a57b8
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/.helmignore
@@ -0,0 +1 @@
+ignore/
diff --git a/pkg/chart/v2/util/testdata/frobnitz/Chart.lock b/pkg/chart/v2/util/testdata/frobnitz/Chart.lock
new file mode 100644
index 000000000..6fcc2ed9f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/Chart.lock
@@ -0,0 +1,8 @@
+dependencies:
+ - name: alpine
+ version: "0.1.0"
+ repository: https://example.com/charts
+ - name: mariner
+ version: "4.3.2"
+ repository: https://example.com/charts
+digest: invalid
diff --git a/pkg/chartutil/testdata/frobnitz/Chart.yaml b/pkg/chart/v2/util/testdata/frobnitz/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/Chart.yaml
rename to pkg/chart/v2/util/testdata/frobnitz/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt b/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt
new file mode 100644
index 000000000..2010438c2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/INSTALL.txt
@@ -0,0 +1 @@
+This is an install document. The client may display this.
diff --git a/pkg/chart/v2/util/testdata/frobnitz/LICENSE b/pkg/chart/v2/util/testdata/frobnitz/LICENSE
new file mode 100644
index 000000000..6121943b1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/LICENSE
@@ -0,0 +1 @@
+LICENSE placeholder.
diff --git a/pkg/chart/v2/util/testdata/frobnitz/README.md b/pkg/chart/v2/util/testdata/frobnitz/README.md
new file mode 100644
index 000000000..8cf4cc3d7
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/README.md
@@ -0,0 +1,11 @@
+# Frobnitz
+
+This is an example chart.
+
+## Usage
+
+This is an example. It has no usage.
+
+## Development
+
+For developer info, see the top-level repository.
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me b/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me
new file mode 100644
index 000000000..2cecca682
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/_ignore_me
@@ -0,0 +1 @@
+This should be ignored by the loader, but may be included in a chart.
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/Chart.yaml
rename to pkg/chart/v2/util/testdata/frobnitz/charts/alpine/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md
new file mode 100644
index 000000000..b30b949dd
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/README.md
@@ -0,0 +1,9 @@
+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.toml` file contains the default values for the
+`alpine-pod.yaml` template.
+
+You can install this example using `helm install ./alpine`.
diff --git a/pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
rename to pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
new file mode 100644
index 000000000..42c39c262
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast1/values.yaml
@@ -0,0 +1,4 @@
+# Default values for mast1.
+# This is a YAML-formatted file.
+# Declare name/value pairs to be passed into your templates.
+# name = "value"
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz
new file mode 100644
index 000000000..61cb62051
Binary files /dev/null and b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/charts/mast2-0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml
new file mode 100644
index 000000000..6c2aab7ba
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/alpine/values.yaml
@@ -0,0 +1,2 @@
+# The pod name
+name: "my-alpine"
diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/mariner/Chart.yaml
rename to pkg/chart/v2/util/testdata/frobnitz/charts/mariner/Chart.yaml
diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml
rename to pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml
new file mode 100644
index 000000000..3121cd7ce
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/charts/albatross/values.yaml
@@ -0,0 +1,4 @@
+albatross: "true"
+
+global:
+ author: Coleridge
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl
new file mode 100644
index 000000000..29c11843a
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/templates/placeholder.tpl
@@ -0,0 +1 @@
+# This is a placeholder.
diff --git a/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml
new file mode 100644
index 000000000..b0ccb0086
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/charts/mariner/values.yaml
@@ -0,0 +1,7 @@
+# Default values for .
+# This is a YAML-formatted file. https://github.com/toml-lang/toml
+# Declare name/value pairs to be passed into your templates.
+# name: "value"
+
+:
+ test: true
diff --git a/pkg/chart/v2/util/testdata/frobnitz/docs/README.md b/pkg/chart/v2/util/testdata/frobnitz/docs/README.md
new file mode 100644
index 000000000..d40747caf
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/docs/README.md
@@ -0,0 +1 @@
+This is a placeholder for documentation.
diff --git a/pkg/chart/v2/util/testdata/frobnitz/icon.svg b/pkg/chart/v2/util/testdata/frobnitz/icon.svg
new file mode 100644
index 000000000..892130606
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/icon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/internal/ignore/testdata/.joonix b/pkg/chart/v2/util/testdata/frobnitz/ignore/me.txt
similarity index 100%
rename from internal/ignore/testdata/.joonix
rename to pkg/chart/v2/util/testdata/frobnitz/ignore/me.txt
diff --git a/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl b/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl
new file mode 100644
index 000000000..c651ee6a0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/templates/template.tpl
@@ -0,0 +1 @@
+Hello {{.Name | default "world"}}
diff --git a/pkg/chart/v2/util/testdata/frobnitz/values.yaml b/pkg/chart/v2/util/testdata/frobnitz/values.yaml
new file mode 100644
index 000000000..61f501258
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/frobnitz/values.yaml
@@ -0,0 +1,6 @@
+# A values file contains configuration.
+
+name: "Some Name"
+
+section:
+ name: "Name in a section"
diff --git a/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz b/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz
new file mode 100644
index 000000000..692965951
Binary files /dev/null and b/pkg/chart/v2/util/testdata/frobnitz_backslash-1.2.3.tgz differ
diff --git a/pkg/chart/v2/util/testdata/genfrob.sh b/pkg/chart/v2/util/testdata/genfrob.sh
new file mode 100755
index 000000000..35fdd59f2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/genfrob.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# Pack the albatross chart into the mariner chart.
+echo "Packing albatross into mariner"
+tar -zcvf mariner/charts/albatross-0.1.0.tgz albatross
+
+echo "Packing mariner into frobnitz"
+tar -zcvf frobnitz/charts/mariner-4.3.2.tgz mariner
+tar -zcvf frobnitz_backslash/charts/mariner-4.3.2.tgz mariner
+
+# Pack the frobnitz chart.
+echo "Packing frobnitz"
+tar --exclude=ignore/* -zcvf frobnitz-1.2.3.tgz frobnitz
+tar --exclude=ignore/* -zcvf frobnitz_backslash-1.2.3.tgz frobnitz_backslash
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock
new file mode 100644
index 000000000..b2f17fb39
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.lock
@@ -0,0 +1,9 @@
+dependencies:
+- name: dev
+ repository: file://envs/dev
+ version: v0.1.0
+- name: prod
+ repository: file://envs/prod
+ version: v0.1.0
+digest: sha256:9403fc24f6cf9d6055820126cf7633b4bd1fed3c77e4880c674059f536346182
+generated: "2020-02-03T10:38:51.180474+01:00"
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml
rename to pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz
new file mode 100644
index 000000000..d28e1621c
Binary files /dev/null and b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/dev-v0.1.0.tgz differ
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz
new file mode 100644
index 000000000..a0c5aa84b
Binary files /dev/null and b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/charts/prod-v0.1.0.tgz differ
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml
rename to pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml
new file mode 100644
index 000000000..38f03484d
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/dev/values.yaml
@@ -0,0 +1,9 @@
+# Dev values parent-chart
+nameOverride: parent-chart-dev
+exports:
+ data:
+ resources:
+ autoscaler:
+ minReplicas: 1
+ maxReplicas: 3
+ targetCPUUtilizationPercentage: 80
diff --git a/pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml
rename to pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml
new file mode 100644
index 000000000..10cc756b2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/envs/prod/values.yaml
@@ -0,0 +1,9 @@
+# Prod values parent-chart
+nameOverride: parent-chart-prod
+exports:
+ data:
+ resources:
+ autoscaler:
+ minReplicas: 2
+ maxReplicas: 5
+ targetCPUUtilizationPercentage: 90
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml
new file mode 100644
index 000000000..976e5a8f1
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/templates/autoscaler.yaml
@@ -0,0 +1,16 @@
+###################################################################################################
+# parent-chart horizontal pod autoscaler
+###################################################################################################
+apiVersion: autoscaling/v1
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ .Release.Name }}-autoscaler
+ namespace: {{ .Release.Namespace }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1beta1
+ kind: Deployment
+ name: {{ .Release.Name }}
+ minReplicas: {{ required "A valid .Values.resources.autoscaler.minReplicas entry required!" .Values.resources.autoscaler.minReplicas }}
+ maxReplicas: {{ required "A valid .Values.resources.autoscaler.maxReplicas entry required!" .Values.resources.autoscaler.maxReplicas }}
+ targetCPUUtilizationPercentage: {{ required "A valid .Values.resources.autoscaler.targetCPUUtilizationPercentage!" .Values.resources.autoscaler.targetCPUUtilizationPercentage }}
\ No newline at end of file
diff --git a/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml
new file mode 100644
index 000000000..b812f0a33
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/import-values-from-enabled-subchart/parent-chart/values.yaml
@@ -0,0 +1,10 @@
+# Default values for parent-chart.
+nameOverride: parent-chart
+tags:
+ dev: false
+ prod: true
+resources:
+ autoscaler:
+ minReplicas: 0
+ maxReplicas: 0
+ targetCPUUtilizationPercentage: 99
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/joonix/Chart.yaml b/pkg/chart/v2/util/testdata/joonix/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/joonix/Chart.yaml
rename to pkg/chart/v2/util/testdata/joonix/Chart.yaml
diff --git a/internal/ignore/testdata/a.txt b/pkg/chart/v2/util/testdata/joonix/charts/.gitkeep
similarity index 100%
rename from internal/ignore/testdata/a.txt
rename to pkg/chart/v2/util/testdata/joonix/charts/.gitkeep
diff --git a/pkg/chartutil/testdata/subpop/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/README.md b/pkg/chart/v2/util/testdata/subpop/README.md
new file mode 100644
index 000000000..e43fbfe9c
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/README.md
@@ -0,0 +1,18 @@
+## Subpop
+
+This chart is for testing the processing of enabled/disabled charts
+via conditions and tags.
+
+Currently there are three levels:
+
+````
+parent
+-1 tags: front-end, subchart1
+--A tags: front-end, subchartA
+--B tags: front-end, subchartB
+-2 tags: back-end, subchart2
+--B tags: back-end, subchartB
+--C tags: back-end, subchartC
+````
+
+Tags and conditions are currently in requirements.yaml files.
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartA/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/templates/service.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/templates/service.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartA/values.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartB/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml
diff --git a/pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/noreqs/templates/service.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/templates/service.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml
new file mode 100644
index 000000000..774fdd75c
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/charts/subchartB/values.yaml
@@ -0,0 +1,35 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+
+SCBdata:
+ SCBbool: true
+ SCBfloat: 7.77
+ SCBint: 33
+ SCBstring: "boba"
+
+exports:
+ SCBexported1:
+ SCBexported1A:
+ SCBexported1B: 1965
+
+ SCBexported2:
+ SCBexported2A: "blaster"
+
+global:
+ kolla:
+ nova:
+ api:
+ all:
+ port: 8774
+ metadata:
+ all:
+ port: 8775
+
+
+
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml
new file mode 100644
index 000000000..fca77fd4b
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/crds/crdA.yaml
@@ -0,0 +1,13 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+ name: testCRDs
+spec:
+ group: testCRDGroups
+ names:
+ kind: TestCRD
+ listKind: TestCRDList
+ plural: TestCRDs
+ shortNames:
+ - tc
+ singular: authconfig
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/NOTES.txt
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/templates/NOTES.txt
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/NOTES.txt
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/service.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/templates/service.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/service.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml
new file mode 100644
index 000000000..91b954e5f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/role.yaml
@@ -0,0 +1,7 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ .Chart.Name }}-role
+rules:
+- resources: ["*"]
+ verbs: ["get","list","watch"]
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/rolebinding.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart1/templates/subdir/serviceaccount.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml
new file mode 100644
index 000000000..a974e316a
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart1/values.yaml
@@ -0,0 +1,55 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+# subchart1
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+
+
+SC1data:
+ SC1bool: true
+ SC1float: 3.14
+ SC1int: 100
+ SC1string: "dollywood"
+ SC1extra1: 11
+
+imported-chartA:
+ SC1extra2: 1.337
+
+overridden-chartA:
+ SCAbool: true
+ SCAfloat: 3.14
+ SCAint: 100
+ SCAstring: "jabbathehut"
+ SC1extra3: true
+
+imported-chartA-B:
+ SC1extra5: "tiller"
+
+overridden-chartA-B:
+ SCAbool: true
+ SCAfloat: 3.33
+ SCAint: 555
+ SCAstring: "wormwood"
+ SCAextra1: 23
+
+ SCBbool: true
+ SCBfloat: 0.25
+ SCBint: 98
+ SCBstring: "murkwood"
+ SCBextra1: 13
+
+ SC1extra6: 77
+
+SCBexported1A:
+ SC1extra7: true
+
+exports:
+ SC1exported1:
+ global:
+ SC1exported2:
+ all:
+ SC1exported3: "SC1expstr"
\ No newline at end of file
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart2/Chart.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartB/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
new file mode 100644
index 000000000..fb3dfc445
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: subchart2-{{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: subchart2-{{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml
new file mode 100644
index 000000000..5e5b21065
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartB/values.yaml
@@ -0,0 +1,21 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+replicaCount: 1
+image:
+ repository: nginx
+ tag: stable
+ pullPolicy: IfNotPresent
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ requests:
+ cpu: 100m
+ memory: 128Mi
+
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml
new file mode 100644
index 000000000..27501e1e0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml
new file mode 100644
index 000000000..5e5b21065
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/charts/subchartC/values.yaml
@@ -0,0 +1,21 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+replicaCount: 1
+image:
+ repository: nginx
+ tag: stable
+ pullPolicy: IfNotPresent
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ requests:
+ cpu: 100m
+ memory: 128Mi
+
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml
new file mode 100644
index 000000000..27501e1e0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml
new file mode 100644
index 000000000..5e5b21065
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/charts/subchart2/values.yaml
@@ -0,0 +1,21 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+replicaCount: 1
+image:
+ repository: nginx
+ tag: stable
+ pullPolicy: IfNotPresent
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ requests:
+ cpu: 100m
+ memory: 128Mi
+
diff --git a/pkg/chartutil/testdata/subpop/noreqs/Chart.yaml b/pkg/chart/v2/util/testdata/subpop/noreqs/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/noreqs/Chart.yaml
rename to pkg/chart/v2/util/testdata/subpop/noreqs/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml b/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml
new file mode 100644
index 000000000..27501e1e0
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/noreqs/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml b/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml
new file mode 100644
index 000000000..4ed3b7ad3
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/noreqs/values.yaml
@@ -0,0 +1,26 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+replicaCount: 1
+image:
+ repository: nginx
+ tag: stable
+ pullPolicy: IfNotPresent
+service:
+ name: nginx
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ requests:
+ cpu: 100m
+ memory: 128Mi
+
+
+# switch-like
+tags:
+ front-end: true
+ back-end: false
diff --git a/pkg/chart/v2/util/testdata/subpop/values.yaml b/pkg/chart/v2/util/testdata/subpop/values.yaml
new file mode 100644
index 000000000..ba70ed406
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/subpop/values.yaml
@@ -0,0 +1,45 @@
+# parent/values.yaml
+
+imported-chart1:
+ SPextra1: "helm rocks"
+
+overridden-chart1:
+ SC1bool: false
+ SC1float: 3.141592
+ SC1int: 99
+ SC1string: "pollywog"
+ SPextra2: 42
+
+
+imported-chartA:
+ SPextra3: 1.337
+
+overridden-chartA:
+ SCAbool: true
+ SCAfloat: 41.3
+ SCAint: 808
+ SCAstring: "jabberwocky"
+ SPextra4: true
+
+imported-chartA-B:
+ SPextra5: "k8s"
+
+overridden-chartA-B:
+ SCAbool: true
+ SCAfloat: 41.3
+ SCAint: 808
+ SCAstring: "jabberwocky"
+ SCBbool: false
+ SCBfloat: 1.99
+ SCBint: 77
+ SCBstring: "jango"
+ SPextra6: 111
+
+tags:
+ front-end: true
+ back-end: false
+
+subchart2alias:
+ enabled: false
+
+ensurenull: null
diff --git a/pkg/chart/v2/util/testdata/test-values-invalid.schema.json b/pkg/chart/v2/util/testdata/test-values-invalid.schema.json
new file mode 100644
index 000000000..35a16a2c4
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/test-values-invalid.schema.json
@@ -0,0 +1 @@
+ 1E1111111
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml b/pkg/chart/v2/util/testdata/test-values-negative.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-negative/values.yaml
rename to pkg/chart/v2/util/testdata/test-values-negative.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json b/pkg/chart/v2/util/testdata/test-values.schema.json
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-negative/values.schema.json
rename to pkg/chart/v2/util/testdata/test-values.schema.json
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.yaml b/pkg/chart/v2/util/testdata/test-values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema/values.yaml
rename to pkg/chart/v2/util/testdata/test-values.yaml
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md b/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md
new file mode 100644
index 000000000..536bb9792
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/README.md
@@ -0,0 +1,16 @@
+# Three Level Dependent Chart
+
+This chart is for testing the processing of multi-level dependencies.
+
+Consists of the following charts:
+
+- Library Chart
+- App Chart (Uses Library Chart as dependency, 2x: app1/app2)
+- Umbrella Chart (Has all the app charts as dependencies)
+
+The precedence is as follows: `library < app < umbrella`
+
+Catches two use-cases:
+
+- app overwriting library (app2)
+- umbrella overwriting app and library (app1)
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/Chart.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/Chart.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml
new file mode 100644
index 000000000..3fd398b53
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/templates/service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml
new file mode 100644
index 000000000..0c08b6cd2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/charts/library/values.yaml
@@ -0,0 +1,5 @@
+exports:
+ defaults:
+ service:
+ type: ClusterIP
+ port: 9090
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml
new file mode 100644
index 000000000..8ed8ddf1f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/templates/service.yaml
@@ -0,0 +1 @@
+{{- include "library.service" . }}
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml
new file mode 100644
index 000000000..3728aa930
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app1/values.yaml
@@ -0,0 +1,3 @@
+service:
+ type: ClusterIP
+ port: 1234
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/Chart.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml
new file mode 100644
index 000000000..3fd398b53
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/templates/service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml
new file mode 100644
index 000000000..0c08b6cd2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/charts/library/values.yaml
@@ -0,0 +1,5 @@
+exports:
+ defaults:
+ service:
+ type: ClusterIP
+ port: 9090
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml
new file mode 100644
index 000000000..8ed8ddf1f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/templates/service.yaml
@@ -0,0 +1 @@
+{{- include "library.service" . }}
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml
new file mode 100644
index 000000000..98bd6d24b
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app2/values.yaml
@@ -0,0 +1,3 @@
+service:
+ type: ClusterIP
+ port: 8080
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/Chart.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml
new file mode 100644
index 000000000..3fd398b53
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/templates/service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml
new file mode 100644
index 000000000..0c08b6cd2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/charts/library/values.yaml
@@ -0,0 +1,5 @@
+exports:
+ defaults:
+ service:
+ type: ClusterIP
+ port: 9090
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml
new file mode 100644
index 000000000..8ed8ddf1f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/templates/service.yaml
@@ -0,0 +1 @@
+{{- include "library.service" . }}
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml
new file mode 100644
index 000000000..b738e2a57
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app3/values.yaml
@@ -0,0 +1,2 @@
+service:
+ type: ClusterIP
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/Chart.yaml
diff --git a/pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml
rename to pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/Chart.yaml
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml
new file mode 100644
index 000000000..3fd398b53
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/templates/service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml
new file mode 100644
index 000000000..0c08b6cd2
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/charts/library/values.yaml
@@ -0,0 +1,5 @@
+exports:
+ defaults:
+ service:
+ type: ClusterIP
+ port: 9090
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml
new file mode 100644
index 000000000..8ed8ddf1f
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/templates/service.yaml
@@ -0,0 +1 @@
+{{- include "library.service" . }}
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml
new file mode 100644
index 000000000..3728aa930
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/charts/app4/values.yaml
@@ -0,0 +1,3 @@
+service:
+ type: ClusterIP
+ port: 1234
diff --git a/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml
new file mode 100644
index 000000000..de0bafa51
--- /dev/null
+++ b/pkg/chart/v2/util/testdata/three-level-dependent-chart/umbrella/values.yaml
@@ -0,0 +1,14 @@
+app1:
+ enabled: true
+ service:
+ type: ClusterIP
+ port: 3456
+
+app2:
+ enabled: true
+
+app3:
+ enabled: true
+
+app4:
+ enabled: true
diff --git a/pkg/chart/v2/util/validate_name.go b/pkg/chart/v2/util/validate_name.go
new file mode 100644
index 000000000..6595e085d
--- /dev/null
+++ b/pkg/chart/v2/util/validate_name.go
@@ -0,0 +1,111 @@
+/*
+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 util
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+)
+
+// validName is a regular expression for resource names.
+//
+// According to the Kubernetes help text, the regular expression it uses is:
+//
+// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
+//
+// This follows the above regular expression (but requires a full string match, not partial).
+//
+// The Kubernetes documentation is here, though it is not entirely correct:
+// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+var validName = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
+
+var (
+ // errMissingName indicates that a release (name) was not provided.
+ errMissingName = errors.New("no name provided")
+
+ // errInvalidName indicates that an invalid release name was provided
+ errInvalidName = fmt.Errorf(
+ "invalid release name, must match regex %s and the length must not be longer than 53",
+ validName.String())
+
+ // errInvalidKubernetesName indicates that the name does not meet the Kubernetes
+ // restrictions on metadata names.
+ errInvalidKubernetesName = fmt.Errorf(
+ "invalid metadata name, must match regex %s and the length must not be longer than 253",
+ validName.String())
+)
+
+const (
+ // According to the Kubernetes docs (https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names)
+ // some resource names have a max length of 63 characters while others have a max
+ // length of 253 characters. As we cannot be sure the resources used in a chart, we
+ // therefore need to limit it to 63 chars and reserve 10 chars for additional part to name
+ // of the resource. The reason is that chart maintainers can use release name as part of
+ // the resource name (and some additional chars).
+ maxReleaseNameLen = 53
+ // maxMetadataNameLen is the maximum length Kubernetes allows for any name.
+ maxMetadataNameLen = 253
+)
+
+// ValidateReleaseName performs checks for an entry for a Helm release name
+//
+// For Helm to allow a name, it must be below a certain character count (53) and also match
+// a regular expression.
+//
+// According to the Kubernetes help text, the regular expression it uses is:
+//
+// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
+//
+// This follows the above regular expression (but requires a full string match, not partial).
+//
+// The Kubernetes documentation is here, though it is not entirely correct:
+// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+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
+ }
+ return nil
+}
+
+// ValidateMetadataName validates the name field of a Kubernetes metadata object.
+//
+// Empty strings, strings longer than 253 chars, or strings that don't match the regexp
+// will fail.
+//
+// According to the Kubernetes help text, the regular expression it uses is:
+//
+// [a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*
+//
+// This follows the above regular expression (but requires a full string match, not partial).
+//
+// The Kubernetes documentation is here, though it is not entirely correct:
+// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+//
+// Deprecated: remove in Helm 4. Name validation now uses rules defined in
+// pkg/lint/rules.validateMetadataNameFunc()
+func ValidateMetadataName(name string) error {
+ if name == "" || len(name) > maxMetadataNameLen || !validName.MatchString(name) {
+ return errInvalidKubernetesName
+ }
+ return nil
+}
diff --git a/pkg/chart/v2/util/validate_name_test.go b/pkg/chart/v2/util/validate_name_test.go
new file mode 100644
index 000000000..cfc62a0f7
--- /dev/null
+++ b/pkg/chart/v2/util/validate_name_test.go
@@ -0,0 +1,91 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package util
+
+import "testing"
+
+// TestValidateReleaseName is a regression test for ValidateName
+//
+// Kubernetes has strict naming conventions for resource names. This test represents
+// those conventions.
+//
+// See https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+//
+// NOTE: At the time of this writing, the docs above say that names cannot begin with
+// digits. However, `kubectl`'s regular expression explicit allows this, and
+// Kubernetes (at least as of 1.18) also accepts resources whose names begin with digits.
+func TestValidateReleaseName(t *testing.T) {
+ names := map[string]bool{
+ "": false,
+ "foo": true,
+ "foo.bar1234baz.seventyone": true,
+ "FOO": false,
+ "123baz": true,
+ "foo.BAR.baz": false,
+ "one-two": true,
+ "-two": false,
+ "one_two": false,
+ "a..b": false,
+ "%^$%*@^*@^": false,
+ "example:com": false,
+ "example%%com": false,
+ "a1111111111111111111111111111111111111111111111111111111111z": false,
+ }
+ for input, expectPass := range names {
+ if err := ValidateReleaseName(input); (err == nil) != expectPass {
+ st := "fail"
+ if expectPass {
+ st = "succeed"
+ }
+ t.Errorf("Expected %q to %s", input, st)
+ }
+ }
+}
+
+func TestValidateMetadataName(t *testing.T) {
+ names := map[string]bool{
+ "": false,
+ "foo": true,
+ "foo.bar1234baz.seventyone": true,
+ "FOO": false,
+ "123baz": true,
+ "foo.BAR.baz": false,
+ "one-two": true,
+ "-two": false,
+ "one_two": false,
+ "a..b": false,
+ "%^$%*@^*@^": false,
+ "example:com": false,
+ "example%%com": false,
+ "a1111111111111111111111111111111111111111111111111111111111z": true,
+ "a1111111111111111111111111111111111111111111111111111111111z" +
+ "a1111111111111111111111111111111111111111111111111111111111z" +
+ "a1111111111111111111111111111111111111111111111111111111111z" +
+ "a1111111111111111111111111111111111111111111111111111111111z" +
+ "a1111111111111111111111111111111111111111111111111111111111z" +
+ "a1111111111111111111111111111111111111111111111111111111111z": false,
+ }
+ for input, expectPass := range names {
+ if err := ValidateMetadataName(input); (err == nil) != expectPass {
+ st := "fail"
+ if expectPass {
+ st = "succeed"
+ }
+ t.Errorf("Expected %q to %s", input, st)
+ }
+ }
+}
diff --git a/pkg/chartutil/values.go b/pkg/chart/v2/util/values.go
similarity index 87%
rename from pkg/chartutil/values.go
rename to pkg/chart/v2/util/values.go
index 2fa2bdabb..6850e8b9b 100644
--- a/pkg/chartutil/values.go
+++ b/pkg/chart/v2/util/values.go
@@ -14,18 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
+ "errors"
"fmt"
"io"
"os"
"strings"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// GlobalKey is the name of the Values key that is used for storing global vars.
@@ -135,6 +135,13 @@ type ReleaseOptions struct {
//
// This takes both ReleaseOptions and Capabilities to merge into the render values.
func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities) (Values, error) {
+ return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false)
+}
+
+// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files
+//
+// This takes both ReleaseOptions and Capabilities to merge into the render values.
+func ToRenderValuesWithSchemaValidation(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities, skipSchemaValidation bool) (Values, error) {
if caps == nil {
caps = DefaultCapabilities
}
@@ -156,9 +163,10 @@ func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options
return top, err
}
- if err := ValidateAgainstSchema(chrt, vals); err != nil {
- errFmt := "values don't meet the specifications of the schema(s) in the following chart(s):\n%s"
- return top, fmt.Errorf(errFmt, err.Error())
+ if !skipSchemaValidation {
+ if err := ValidateAgainstSchema(chrt, vals); err != nil {
+ return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err)
+ }
}
top["Values"] = vals
diff --git a/pkg/chartutil/values_test.go b/pkg/chart/v2/util/values_test.go
similarity index 97%
rename from pkg/chartutil/values_test.go
rename to pkg/chart/v2/util/values_test.go
index c95fa503a..1a25fafb8 100644
--- a/pkg/chartutil/values_test.go
+++ b/pkg/chart/v2/util/values_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package chartutil
+package util
import (
"bytes"
@@ -22,7 +22,7 @@ import (
"testing"
"text/template"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
func TestReadValues(t *testing.T) {
@@ -103,7 +103,7 @@ func TestToRenderValues(t *testing.T) {
IsInstall: true,
}
- res, err := ToRenderValues(c, overrideValues, o, nil)
+ res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false)
if err != nil {
t.Fatal(err)
}
@@ -224,6 +224,7 @@ chapter:
}
func matchValues(t *testing.T, data map[string]interface{}) {
+ t.Helper()
if data["poet"] != "Coleridge" {
t.Errorf("Unexpected poet: %s", data["poet"])
}
diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go
index dac2a4bc1..19563cba3 100644
--- a/pkg/cli/environment.go
+++ b/pkg/cli/environment.go
@@ -34,8 +34,9 @@ import (
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/client-go/rest"
- "helm.sh/helm/v3/internal/version"
- "helm.sh/helm/v3/pkg/helmpath"
+ "helm.sh/helm/v4/internal/version"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/kube"
)
// defaultMaxHistory sets the maximum number of releases to 0: unlimited
@@ -44,6 +45,9 @@ const defaultMaxHistory = 10
// defaultBurstLimit sets the default client-side throttling limit
const defaultBurstLimit = 100
+// defaultQPS sets the default QPS value to 0 to use library defaults unless specified
+const defaultQPS = float32(0)
+
// EnvSettings describes all of the environment settings.
type EnvSettings struct {
namespace string
@@ -83,6 +87,12 @@ type EnvSettings struct {
MaxHistory int
// BurstLimit is the default client-side throttling limit.
BurstLimit int
+ // QPS is queries per second which may be used to avoid throttling.
+ QPS float32
+ // ColorMode controls colorized output (never, auto, always)
+ ColorMode string
+ // ContentCache is the location where cached charts are stored
+ ContentCache string
}
func New() *EnvSettings {
@@ -101,12 +111,15 @@ func New() *EnvSettings {
RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")),
RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")),
RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")),
+ ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")),
BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit),
+ QPS: envFloat32Or("HELM_QPS", defaultQPS),
+ ColorMode: envColorMode(),
}
env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG"))
// bind to kubernetes config flags
- env.config = &genericclioptions.ConfigFlags{
+ config := &genericclioptions.ConfigFlags{
Namespace: &env.namespace,
Context: &env.KubeContext,
BearerToken: &env.KubeToken,
@@ -119,13 +132,19 @@ func New() *EnvSettings {
ImpersonateGroup: &env.KubeAsGroups,
WrapConfigFn: func(config *rest.Config) *rest.Config {
config.Burst = env.BurstLimit
+ config.QPS = env.QPS
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
- return &retryingRoundTripper{wrapped: rt}
+ return &kube.RetryingRoundTripper{Wrapped: rt}
})
config.UserAgent = version.GetUserAgent()
return config
},
}
+ if env.BurstLimit != defaultBurstLimit {
+ config = config.WithDiscoveryBurst(env.BurstLimit)
+ }
+ env.config = config
+
return env
}
@@ -144,8 +163,12 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&s.Debug, "debug", s.Debug, "enable verbose output")
fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file")
fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs")
- fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the file containing cached repository indexes")
+ fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes")
+ fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)")
fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit")
+ fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting")
+ fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)")
+ fs.StringVar(&s.ColorMode, "colour", s.ColorMode, "use colored output (never, auto, always)")
}
func envOr(name, def string) string {
@@ -179,6 +202,18 @@ func envIntOr(name string, def int) int {
return ret
}
+func envFloat32Or(name string, def float32) float32 {
+ if name == "" {
+ return def
+ }
+ envVal := envOr(name, strconv.FormatFloat(float64(def), 'f', 2, 32))
+ ret, err := strconv.ParseFloat(envVal, 32)
+ if err != nil {
+ return def
+ }
+ return float32(ret)
+}
+
func envCSV(name string) (ls []string) {
trimmed := strings.Trim(os.Getenv(name), ", ")
if trimmed != "" {
@@ -187,6 +222,23 @@ func envCSV(name string) (ls []string) {
return
}
+func envColorMode() string {
+ // Check NO_COLOR environment variable first (standard)
+ if v, ok := os.LookupEnv("NO_COLOR"); ok && v != "" {
+ return "never"
+ }
+ // Check HELM_COLOR environment variable
+ if v, ok := os.LookupEnv("HELM_COLOR"); ok {
+ v = strings.ToLower(v)
+ switch v {
+ case "never", "auto", "always":
+ return v
+ }
+ }
+ // Default to auto
+ return "auto"
+}
+
func (s *EnvSettings) EnvVars() map[string]string {
envvars := map[string]string{
"HELM_BIN": os.Args[0],
@@ -201,6 +253,7 @@ func (s *EnvSettings) EnvVars() map[string]string {
"HELM_NAMESPACE": s.Namespace(),
"HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory),
"HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit),
+ "HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32),
// broken, these are populated from helm flags and not kubeconfig.
"HELM_KUBECONTEXT": s.KubeContext,
@@ -223,6 +276,9 @@ func (s *EnvSettings) Namespace() string {
if ns, _, err := s.config.ToRawKubeConfigLoader().Namespace(); err == nil {
return ns
}
+ if s.namespace != "" {
+ return s.namespace
+ }
return "default"
}
@@ -235,3 +291,8 @@ func (s *EnvSettings) SetNamespace(namespace string) {
func (s *EnvSettings) RESTClientGetter() genericclioptions.RESTClientGetter {
return s.config
}
+
+// ShouldDisableColor returns true if color output should be disabled
+func (s *EnvSettings) ShouldDisableColor() bool {
+ return s.ColorMode == "never"
+}
diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go
index 3de6fab4c..52326eeff 100644
--- a/pkg/cli/environment_test.go
+++ b/pkg/cli/environment_test.go
@@ -24,7 +24,7 @@ import (
"github.com/spf13/pflag"
- "helm.sh/helm/v3/internal/version"
+ "helm.sh/helm/v4/internal/version"
)
func TestSetNamespace(t *testing.T) {
@@ -38,7 +38,6 @@ func TestSetNamespace(t *testing.T) {
if settings.namespace != "testns" {
t.Errorf("Expected namespace testns, got %s", settings.namespace)
}
-
}
func TestEnvSettings(t *testing.T) {
@@ -59,20 +58,23 @@ func TestEnvSettings(t *testing.T) {
kubeInsecure bool
kubeTLSServer string
burstLimit int
+ qps float32
}{
{
name: "defaults",
ns: "default",
maxhistory: defaultMaxHistory,
burstLimit: defaultBurstLimit,
+ qps: defaultQPS,
},
{
name: "with flags set",
- args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/tmp/ca.crt --burst-limit 100 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org",
+ args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/tmp/ca.crt --burst-limit 100 --qps 50.12 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org",
ns: "myns",
debug: true,
maxhistory: defaultMaxHistory,
burstLimit: 100,
+ qps: 50.12,
kubeAsUser: "poro",
kubeAsGroups: []string{"admins", "teatime", "snackeaters"},
kubeCaFile: "/tmp/ca.crt",
@@ -81,10 +83,11 @@ func TestEnvSettings(t *testing.T) {
},
{
name: "with envvars set",
- envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "150", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org"},
+ envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "150", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org", "HELM_QPS": "60.34"},
ns: "yourns",
maxhistory: 5,
burstLimit: 150,
+ qps: 60.34,
debug: true,
kubeAsUser: "pikachu",
kubeAsGroups: []string{"operators", "snackeaters", "partyanimals"},
@@ -94,18 +97,27 @@ func TestEnvSettings(t *testing.T) {
},
{
name: "with flags and envvars set",
- args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/my/ca.crt --burst-limit 175 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org",
- envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "200", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org"},
+ args: "--debug --namespace=myns --kube-as-user=poro --kube-as-group=admins --kube-as-group=teatime --kube-as-group=snackeaters --kube-ca-file=/my/ca.crt --burst-limit 175 --qps 70 --kube-insecure-skip-tls-verify=true --kube-tls-server-name=example.org",
+ envvars: map[string]string{"HELM_DEBUG": "1", "HELM_NAMESPACE": "yourns", "HELM_KUBEASUSER": "pikachu", "HELM_KUBEASGROUPS": ",,,operators,snackeaters,partyanimals", "HELM_MAX_HISTORY": "5", "HELM_KUBECAFILE": "/tmp/ca.crt", "HELM_BURST_LIMIT": "200", "HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true", "HELM_KUBETLS_SERVER_NAME": "example.org", "HELM_QPS": "40"},
ns: "myns",
debug: true,
maxhistory: 5,
burstLimit: 175,
+ qps: 70,
kubeAsUser: "poro",
kubeAsGroups: []string{"admins", "teatime", "snackeaters"},
kubeCaFile: "/my/ca.crt",
kubeTLSServer: "example.org",
kubeInsecure: true,
},
+ {
+ name: "invalid kubeconfig",
+ ns: "testns",
+ args: "--namespace=testns --kubeconfig=/path/to/fake/file",
+ maxhistory: defaultMaxHistory,
+ burstLimit: defaultBurstLimit,
+ qps: defaultQPS,
+ },
}
for _, tt := range tests {
@@ -113,7 +125,7 @@ func TestEnvSettings(t *testing.T) {
defer resetEnv()()
for k, v := range tt.envvars {
- os.Setenv(k, v)
+ t.Setenv(k, v)
}
flags := pflag.NewFlagSet("testing", pflag.ContinueOnError)
@@ -220,10 +232,7 @@ func TestEnvOrBool(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
- t.Cleanup(func() {
- os.Unsetenv(tt.env)
- })
- os.Setenv(tt.env, tt.val)
+ t.Setenv(tt.env, tt.val)
}
actual := envBoolOr(tt.env, tt.def)
if actual != tt.expected {
diff --git a/pkg/cli/output/output.go b/pkg/cli/output/output.go
index a46c977ad..28d503741 100644
--- a/pkg/cli/output/output.go
+++ b/pkg/cli/output/output.go
@@ -22,7 +22,6 @@ import (
"io"
"github.com/gosuri/uitable"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
)
@@ -73,7 +72,7 @@ func (o Format) Write(out io.Writer, w Writer) error {
}
// ParseFormat takes a raw string and returns the matching Format.
-// If the format does not exists, ErrInvalidFormatType is returned
+// If the format does not exist, ErrInvalidFormatType is returned
func ParseFormat(s string) (out Format, err error) {
switch s {
case Table.String():
@@ -107,7 +106,7 @@ func EncodeJSON(out io.Writer, obj interface{}) error {
enc := json.NewEncoder(out)
err := enc.Encode(obj)
if err != nil {
- return errors.Wrap(err, "unable to write JSON output")
+ return fmt.Errorf("unable to write JSON output: %w", err)
}
return nil
}
@@ -117,12 +116,12 @@ func EncodeJSON(out io.Writer, obj interface{}) error {
func EncodeYAML(out io.Writer, obj interface{}) error {
raw, err := yaml.Marshal(obj)
if err != nil {
- return errors.Wrap(err, "unable to write YAML output")
+ return fmt.Errorf("unable to write YAML output: %w", err)
}
_, err = out.Write(raw)
if err != nil {
- return errors.Wrap(err, "unable to write YAML output")
+ return fmt.Errorf("unable to write YAML output: %w", err)
}
return nil
}
@@ -134,7 +133,7 @@ func EncodeTable(out io.Writer, table *uitable.Table) error {
raw = append(raw, []byte("\n")...)
_, err := out.Write(raw)
if err != nil {
- return errors.Wrap(err, "unable to write table output")
+ return fmt.Errorf("unable to write table output: %w", err)
}
return nil
}
diff --git a/pkg/cli/values/options.go b/pkg/cli/values/options.go
index 06631cd33..cd65fa885 100644
--- a/pkg/cli/values/options.go
+++ b/pkg/cli/values/options.go
@@ -17,16 +17,17 @@ limitations under the License.
package values
import (
+ "bytes"
+ "encoding/json"
+ "fmt"
"io"
"net/url"
"os"
"strings"
- "github.com/pkg/errors"
- "sigs.k8s.io/yaml"
-
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/strvals"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/strvals"
)
// Options captures the different ways to specify values
@@ -46,38 +47,47 @@ func (opts *Options) MergeValues(p getter.Providers) (map[string]interface{}, er
// User specified a values files via -f/--values
for _, filePath := range opts.ValueFiles {
- currentMap := map[string]interface{}{}
-
- bytes, err := readFile(filePath, p)
+ raw, err := readFile(filePath, p)
if err != nil {
return nil, err
}
-
- if err := yaml.Unmarshal(bytes, ¤tMap); err != nil {
- return nil, errors.Wrapf(err, "failed to parse %s", filePath)
+ currentMap, err := loader.LoadValues(bytes.NewReader(raw))
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse %s: %w", filePath, err)
}
// Merge with the previous map
- base = mergeMaps(base, currentMap)
+ base = loader.MergeMaps(base, currentMap)
}
// User specified a value via --set-json
for _, value := range opts.JSONValues {
- if err := strvals.ParseJSON(value, base); err != nil {
- return nil, errors.Errorf("failed parsing --set-json data %s", value)
+ trimmedValue := strings.TrimSpace(value)
+ if len(trimmedValue) > 0 && trimmedValue[0] == '{' {
+ // If value is JSON object format, parse it as map
+ var jsonMap map[string]interface{}
+ if err := json.Unmarshal([]byte(trimmedValue), &jsonMap); err != nil {
+ return nil, fmt.Errorf("failed parsing --set-json data JSON: %s", value)
+ }
+ base = loader.MergeMaps(base, jsonMap)
+ } else {
+ // Otherwise, parse it as key=value format
+ if err := strvals.ParseJSON(value, base); err != nil {
+ return nil, fmt.Errorf("failed parsing --set-json data %s", value)
+ }
}
}
// User specified a value via --set
for _, value := range opts.Values {
if err := strvals.ParseInto(value, base); err != nil {
- return nil, errors.Wrap(err, "failed parsing --set data")
+ return nil, fmt.Errorf("failed parsing --set data: %w", err)
}
}
// User specified a value via --set-string
for _, value := range opts.StringValues {
if err := strvals.ParseIntoString(value, base); err != nil {
- return nil, errors.Wrap(err, "failed parsing --set-string data")
+ return nil, fmt.Errorf("failed parsing --set-string data: %w", err)
}
}
@@ -91,39 +101,20 @@ func (opts *Options) MergeValues(p getter.Providers) (map[string]interface{}, er
return string(bytes), err
}
if err := strvals.ParseIntoFile(value, base, reader); err != nil {
- return nil, errors.Wrap(err, "failed parsing --set-file data")
+ return nil, fmt.Errorf("failed parsing --set-file data: %w", err)
}
}
// User specified a value via --set-literal
for _, value := range opts.LiteralValues {
if err := strvals.ParseLiteralInto(value, base); err != nil {
- return nil, errors.Wrap(err, "failed parsing --set-literal data")
+ return nil, fmt.Errorf("failed parsing --set-literal data: %w", err)
}
}
return base, nil
}
-func mergeMaps(a, b map[string]interface{}) map[string]interface{} {
- out := make(map[string]interface{}, len(a))
- for k, v := range a {
- out[k] = v
- }
- for k, v := range b {
- if v, ok := v.(map[string]interface{}); ok {
- if bv, ok := out[k]; ok {
- if bv, ok := bv.(map[string]interface{}); ok {
- out[k] = mergeMaps(bv, v)
- continue
- }
- }
- }
- out[k] = v
- }
- return out
-}
-
// readFile load a file from stdin, the local directory, or a remote file with a url.
func readFile(filePath string, p getter.Providers) ([]byte, error) {
if strings.TrimSpace(filePath) == "-" {
@@ -143,5 +134,5 @@ func readFile(filePath string, p getter.Providers) ([]byte, error) {
if err != nil {
return nil, err
}
- return data.Bytes(), err
+ return data.Bytes(), nil
}
diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go
index 54124c0fa..4dbc709f1 100644
--- a/pkg/cli/values/options_test.go
+++ b/pkg/cli/values/options_test.go
@@ -17,68 +17,275 @@ limitations under the License.
package values
import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
"reflect"
+ "strings"
"testing"
- "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v4/pkg/getter"
)
-func TestMergeValues(t *testing.T) {
- nestedMap := map[string]interface{}{
- "foo": "bar",
- "baz": map[string]string{
- "cool": "stuff",
- },
+// mockGetter implements getter.Getter for testing
+type mockGetter struct {
+ content []byte
+ err error
+}
+
+func (m *mockGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) {
+ if m.err != nil {
+ return nil, m.err
}
- anotherNestedMap := map[string]interface{}{
- "foo": "bar",
- "baz": map[string]string{
- "cool": "things",
- "awesome": "stuff",
+ return bytes.NewBuffer(m.content), nil
+}
+
+// mockProvider creates a test provider
+func mockProvider(schemes []string, content []byte, err error) getter.Provider {
+ return getter.Provider{
+ Schemes: schemes,
+ New: func(_ ...getter.Option) (getter.Getter, error) {
+ return &mockGetter{content: content, err: err}, nil
},
}
- flatMap := map[string]interface{}{
- "foo": "bar",
- "baz": "stuff",
- }
- anotherFlatMap := map[string]interface{}{
- "testing": "fun",
- }
+}
- testMap := mergeMaps(flatMap, nestedMap)
- equal := reflect.DeepEqual(testMap, nestedMap)
- if !equal {
- t.Errorf("Expected a nested map to overwrite a flat value. Expected: %v, got %v", nestedMap, testMap)
+func TestReadFile(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ providers getter.Providers
+ setupFunc func(*testing.T) (string, func()) // setup temp files, return cleanup
+ expectError bool
+ expectStdin bool
+ expectedData []byte
+ }{
+ {
+ name: "stdin input with dash",
+ filePath: "-",
+ providers: getter.Providers{},
+ expectStdin: true,
+ expectError: false,
+ },
+ {
+ name: "stdin input with whitespace",
+ filePath: " - ",
+ providers: getter.Providers{},
+ expectStdin: true,
+ expectError: false,
+ },
+ {
+ name: "invalid URL parsing",
+ filePath: "://invalid-url",
+ providers: getter.Providers{},
+ expectError: true,
+ },
+ {
+ name: "local file - existing",
+ filePath: "test.txt",
+ providers: getter.Providers{},
+ setupFunc: func(t *testing.T) (string, func()) {
+ t.Helper()
+ 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)
+ }
+ return filePath, func() {} // cleanup handled by t.TempDir()
+ },
+ expectError: false,
+ expectedData: []byte("local file content"),
+ },
+ {
+ name: "local file - non-existent",
+ filePath: "/non/existent/file.txt",
+ providers: getter.Providers{},
+ expectError: true,
+ },
+ {
+ name: "remote file with http scheme - success",
+ filePath: "http://example.com/values.yaml",
+ providers: getter.Providers{
+ mockProvider([]string{"http", "https"}, []byte("remote content"), nil),
+ },
+ expectError: false,
+ expectedData: []byte("remote content"),
+ },
+ {
+ name: "remote file with https scheme - success",
+ filePath: "https://example.com/values.yaml",
+ providers: getter.Providers{
+ mockProvider([]string{"http", "https"}, []byte("https content"), nil),
+ },
+ expectError: false,
+ expectedData: []byte("https content"),
+ },
+ {
+ name: "remote file with custom scheme - success",
+ filePath: "oci://registry.example.com/chart",
+ providers: getter.Providers{
+ mockProvider([]string{"oci"}, []byte("oci content"), nil),
+ },
+ expectError: false,
+ expectedData: []byte("oci content"),
+ },
+ {
+ name: "remote file - getter error",
+ filePath: "http://example.com/values.yaml",
+ providers: getter.Providers{
+ mockProvider([]string{"http"}, nil, errors.New("network error")),
+ },
+ expectError: true,
+ },
+ {
+ name: "unsupported scheme fallback to local file",
+ filePath: "ftp://example.com/file.txt",
+ providers: getter.Providers{
+ mockProvider([]string{"http"}, []byte("should not be used"), nil),
+ },
+ setupFunc: func(t *testing.T) (string, func()) {
+ t.Helper()
+ // Create a local file named "ftp://example.com/file.txt"
+ // This tests the fallback behavior when scheme is not supported
+ tmpDir := t.TempDir()
+ 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)
+ }
+ return filePath, func() {}
+ },
+ expectError: false,
+ expectedData: []byte("local fallback content"),
+ },
+ {
+ name: "empty file path",
+ filePath: "",
+ providers: getter.Providers{},
+ expectError: true, // Empty path should cause error
+ },
+ {
+ name: "multiple providers - correct selection",
+ filePath: "custom://example.com/resource",
+ providers: getter.Providers{
+ mockProvider([]string{"http", "https"}, []byte("wrong content"), nil),
+ mockProvider([]string{"custom"}, []byte("correct content"), nil),
+ mockProvider([]string{"oci"}, []byte("also wrong"), nil),
+ },
+ expectError: false,
+ expectedData: []byte("correct content"),
+ },
}
- testMap = mergeMaps(nestedMap, flatMap)
- equal = reflect.DeepEqual(testMap, flatMap)
- if !equal {
- t.Errorf("Expected a flat value to overwrite a map. Expected: %v, got %v", flatMap, testMap)
- }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var actualFilePath string
+ var cleanup func()
+
+ if tt.setupFunc != nil {
+ actualFilePath, cleanup = tt.setupFunc(t)
+ defer cleanup()
+ } else {
+ actualFilePath = tt.filePath
+ }
- testMap = mergeMaps(nestedMap, anotherNestedMap)
- equal = reflect.DeepEqual(testMap, anotherNestedMap)
- if !equal {
- t.Errorf("Expected a nested map to overwrite another nested map. Expected: %v, got %v", anotherNestedMap, testMap)
+ // Handle stdin test case
+ if tt.expectStdin {
+ // Save original stdin
+ originalStdin := os.Stdin
+ defer func() { os.Stdin = originalStdin }()
+
+ // Create a pipe for stdin
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer r.Close()
+ defer w.Close()
+
+ // Replace stdin with our pipe
+ os.Stdin = r
+
+ // Write test data to stdin
+ testData := []byte("stdin test data")
+ go func() {
+ defer w.Close()
+ w.Write(testData)
+ }()
+
+ // 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)
+ }
+ 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)
+ return
+ }
+
+ if !tt.expectError && tt.expectedData != nil {
+ if !bytes.Equal(got, tt.expectedData) {
+ t.Errorf("readFile() = %v, want %v", got, tt.expectedData)
+ }
+ }
+ })
}
+}
- testMap = mergeMaps(anotherFlatMap, anotherNestedMap)
- expectedMap := map[string]interface{}{
- "testing": "fun",
- "foo": "bar",
- "baz": map[string]string{
- "cool": "things",
- "awesome": "stuff",
+// TestReadFileErrorMessages tests specific error scenarios and their messages
+func TestReadFileErrorMessages(t *testing.T) {
+ tests := []struct {
+ name string
+ filePath string
+ providers getter.Providers
+ wantErr string
+ }{
+ {
+ name: "URL parse error",
+ filePath: "://invalid",
+ providers: getter.Providers{},
+ wantErr: "missing protocol scheme",
+ },
+ {
+ name: "getter error with message",
+ filePath: "http://example.com/file",
+ providers: getter.Providers{mockProvider([]string{"http"}, nil, fmt.Errorf("connection refused"))},
+ wantErr: "connection refused",
},
}
- equal = reflect.DeepEqual(testMap, expectedMap)
- if !equal {
- t.Errorf("Expected a map with different keys to merge properly with another map. Expected: %v, got %v", expectedMap, testMap)
+
+ 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)
+ }
+ })
}
}
-func TestReadFile(t *testing.T) {
+// Original test case - keeping for backward compatibility
+func TestReadFileOriginal(t *testing.T) {
var p getter.Providers
filePath := "%a.txt"
_, err := readFile(filePath, p)
@@ -86,3 +293,97 @@ func TestReadFile(t *testing.T) {
t.Errorf("Expected error when has special strings")
}
}
+
+func TestMergeValues(t *testing.T) {
+ tests := []struct {
+ name string
+ opts Options
+ expected map[string]interface{}
+ wantErr bool
+ }{
+ {
+ name: "set-json object",
+ opts: Options{
+ JSONValues: []string{`{"foo": {"bar": "baz"}}`},
+ },
+ expected: map[string]interface{}{
+ "foo": map[string]interface{}{
+ "bar": "baz",
+ },
+ },
+ },
+ {
+ name: "set-json key=value",
+ 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},
+ },
+ },
+ },
+ {
+ name: "set regular value",
+ opts: Options{
+ Values: []string{"foo=bar"},
+ },
+ expected: map[string]interface{}{
+ "foo": "bar",
+ },
+ },
+ {
+ name: "set string value",
+ opts: Options{
+ StringValues: []string{"foo=123"},
+ },
+ expected: map[string]interface{}{
+ "foo": "123",
+ },
+ },
+ {
+ name: "set literal value",
+ opts: Options{
+ LiteralValues: []string{"foo=true"},
+ },
+ expected: map[string]interface{}{
+ "foo": "true",
+ },
+ },
+ {
+ name: "multiple options",
+ opts: Options{
+ Values: []string{"a=foo"},
+ StringValues: []string{"b=bar"},
+ JSONValues: []string{`{"c": "foo1"}`},
+ LiteralValues: []string{"d=bar1"},
+ },
+ expected: map[string]interface{}{
+ "a": "foo",
+ "b": "bar",
+ "c": "foo1",
+ "d": "bar1",
+ },
+ },
+ {
+ name: "invalid json",
+ opts: Options{
+ JSONValues: []string{`{invalid`},
+ },
+ wantErr: true,
+ },
+ }
+
+ 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)
+ return
+ }
+ if !tt.wantErr && !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("MergeValues() = %v, want %v", got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/cmd/helm/completion.go b/pkg/cmd/completion.go
similarity index 83%
rename from cmd/helm/completion.go
rename to pkg/cmd/completion.go
index 310c915b8..6f6dbd25d 100644
--- a/cmd/helm/completion.go
+++ b/pkg/cmd/completion.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,7 +23,7 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const completionDesc = `
@@ -102,8 +102,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command {
Short: "generate autocompletion script for bash",
Long: bashCompDesc,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
return runCompletionBash(out, cmd)
},
}
@@ -114,8 +114,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command {
Short: "generate autocompletion script for zsh",
Long: zshCompDesc,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
return runCompletionZsh(out, cmd)
},
}
@@ -126,8 +126,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command {
Short: "generate autocompletion script for fish",
Long: fishCompDesc,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
return runCompletionFish(out, cmd)
},
}
@@ -138,8 +138,8 @@ func newCompletionCmd(out io.Writer) *cobra.Command {
Short: "generate autocompletion script for powershell",
Long: powershellCompDesc,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
return runCompletionPowershell(out, cmd)
},
}
@@ -209,7 +209,15 @@ func runCompletionPowershell(out io.Writer, cmd *cobra.Command) error {
return cmd.Root().GenPowerShellCompletionWithDesc(out)
}
-// Function to disable file completion
-func noCompletions(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return nil, cobra.ShellCompDirectiveNoFileComp
+// noMoreArgsCompFunc deactivates file completion when doing argument shell completion.
+// It also provides some ActiveHelp to indicate no more arguments are accepted.
+func noMoreArgsCompFunc(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+ return noMoreArgsComp()
+}
+
+// noMoreArgsComp deactivates file completion when doing argument shell completion.
+// It also provides some ActiveHelp to indicate no more arguments are accepted.
+func noMoreArgsComp() ([]string, cobra.ShellCompDirective) {
+ activeHelpMsg := "This command does not take any more arguments (but may accept flags)."
+ return cobra.AppendActiveHelp(nil, activeHelpMsg), cobra.ShellCompDirectiveNoFileComp
}
diff --git a/cmd/helm/completion_test.go b/pkg/cmd/completion_test.go
similarity index 96%
rename from cmd/helm/completion_test.go
rename to pkg/cmd/completion_test.go
index 1143d6445..375a9a97d 100644
--- a/cmd/helm/completion_test.go
+++ b/pkg/cmd/completion_test.go
@@ -14,19 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/release"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// Check if file completion should be performed according to parameter 'shouldBePerformed'
func checkFileCompletion(t *testing.T, cmdName string, shouldBePerformed bool) {
+ t.Helper()
storage := storageFixture()
storage.Create(&release.Release{
Name: "myrelease",
@@ -64,6 +65,7 @@ func TestCompletionFileCompletion(t *testing.T) {
}
func checkReleaseCompletion(t *testing.T, cmdName string, multiReleasesAllowed bool) {
+ t.Helper()
multiReleaseTestGolden := "output/empty_nofile_comp.txt"
if multiReleasesAllowed {
multiReleaseTestGolden = "output/release_list_repeat_comp.txt"
diff --git a/cmd/helm/create.go b/pkg/cmd/create.go
similarity index 89%
rename from cmd/helm/create.go
rename to pkg/cmd/create.go
index fe5cc540a..435c8ca82 100644
--- a/cmd/helm/create.go
+++ b/pkg/cmd/create.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,10 +23,10 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/helmpath"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/helmpath"
)
const createDesc = `
@@ -64,16 +64,16 @@ func newCreateCmd(out io.Writer) *cobra.Command {
Short: "create a new chart with the given name",
Long: createDesc,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// Allow file completion when completing the argument for the name
// which could be a path
return nil, cobra.ShellCompDirectiveDefault
}
// No more completions, so disable file completion
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
o.name = args[0]
o.starterDir = helmpath.DataPath("starters")
return o.run(out)
diff --git a/cmd/helm/create_test.go b/pkg/cmd/create_test.go
similarity index 87%
rename from cmd/helm/create_test.go
rename to pkg/cmd/create_test.go
index 1a22d058f..90ed90eff 100644
--- a/cmd/helm/create_test.go
+++ b/pkg/cmd/create_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,18 +22,17 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/helmpath"
+ "helm.sh/helm/v4/internal/test/ensure"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/helmpath"
)
func TestCreateCmd(t *testing.T) {
+ t.Chdir(t.TempDir())
ensure.HelmHome(t)
cname := "testchart"
- dir := t.TempDir()
- defer testChdir(t, dir)()
// Run a create
if _, _, err := executeActionCommand("create " + cname); err != nil {
@@ -61,22 +60,20 @@ func TestCreateCmd(t *testing.T) {
}
func TestCreateStarterCmd(t *testing.T) {
+ t.Chdir(t.TempDir())
ensure.HelmHome(t)
cname := "testchart"
defer resetEnv()()
- os.MkdirAll(helmpath.CachePath(), 0755)
- defer testChdir(t, helmpath.CachePath())()
-
// Create a starter.
starterchart := helmpath.DataPath("starters")
- os.MkdirAll(starterchart, 0755)
+ os.MkdirAll(starterchart, 0o755)
if dest, err := chartutil.Create("starterchart", starterchart); err != nil {
t.Fatalf("Could not create chart: %s", err)
} else {
t.Logf("Created %s", dest)
}
tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl")
- if err := os.WriteFile(tplpath, []byte("test"), 0644); err != nil {
+ if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil {
t.Fatalf("Could not write template: %s", err)
}
@@ -105,7 +102,7 @@ func TestCreateStarterCmd(t *testing.T) {
t.Errorf("Wrong API version: %q", c.Metadata.APIVersion)
}
- expectedNumberOfTemplates := 9
+ expectedNumberOfTemplates := 10
if l := len(c.Templates); l != expectedNumberOfTemplates {
t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l)
}
@@ -122,30 +119,27 @@ func TestCreateStarterCmd(t *testing.T) {
if !found {
t.Error("Did not find foo.tpl")
}
-
}
func TestCreateStarterAbsoluteCmd(t *testing.T) {
+ t.Chdir(t.TempDir())
defer resetEnv()()
ensure.HelmHome(t)
cname := "testchart"
// Create a starter.
starterchart := helmpath.DataPath("starters")
- os.MkdirAll(starterchart, 0755)
+ os.MkdirAll(starterchart, 0o755)
if dest, err := chartutil.Create("starterchart", starterchart); err != nil {
t.Fatalf("Could not create chart: %s", err)
} else {
t.Logf("Created %s", dest)
}
tplpath := filepath.Join(starterchart, "starterchart", "templates", "foo.tpl")
- if err := os.WriteFile(tplpath, []byte("test"), 0644); err != nil {
+ if err := os.WriteFile(tplpath, []byte("test"), 0o644); err != nil {
t.Fatalf("Could not write template: %s", err)
}
- os.MkdirAll(helmpath.CachePath(), 0755)
- defer testChdir(t, helmpath.CachePath())()
-
starterChartPath := filepath.Join(starterchart, "starterchart")
// Run a create
@@ -173,7 +167,7 @@ func TestCreateStarterAbsoluteCmd(t *testing.T) {
t.Errorf("Wrong API version: %q", c.Metadata.APIVersion)
}
- expectedNumberOfTemplates := 9
+ expectedNumberOfTemplates := 10
if l := len(c.Templates); l != expectedNumberOfTemplates {
t.Errorf("Expected %d templates, got %d", expectedNumberOfTemplates, l)
}
diff --git a/cmd/helm/dependency.go b/pkg/cmd/dependency.go
similarity index 71%
rename from cmd/helm/dependency.go
rename to pkg/cmd/dependency.go
index 03874742c..34bbff6be 100644
--- a/cmd/helm/dependency.go
+++ b/pkg/cmd/dependency.go
@@ -13,16 +13,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
"path/filepath"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const dependencyDesc = `
@@ -93,7 +94,7 @@ func newDependencyCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
cmd.AddCommand(newDependencyListCmd(out))
cmd.AddCommand(newDependencyUpdateCmd(cfg, out))
- cmd.AddCommand(newDependencyBuildCmd(cfg, out))
+ cmd.AddCommand(newDependencyBuildCmd(out))
return cmd
}
@@ -106,7 +107,7 @@ func newDependencyListCmd(out io.Writer) *cobra.Command {
Short: "list the dependencies for the given chart",
Long: dependencyListDesc,
Args: require.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
chartpath := "."
if len(args) > 0 {
chartpath = filepath.Clean(args[0])
@@ -120,3 +121,16 @@ func newDependencyListCmd(out io.Writer) *cobra.Command {
f.UintVar(&client.ColumnWidth, "max-col-width", 80, "maximum column width for output table")
return cmd
}
+
+func addDependencySubcommandFlags(f *pflag.FlagSet, client *action.Dependency) {
+ f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures")
+ f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys")
+ f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache")
+ f.StringVar(&client.Username, "username", "", "chart repository username where to locate the requested chart")
+ f.StringVar(&client.Password, "password", "", "chart repository password where to locate the requested chart")
+ f.StringVar(&client.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file")
+ f.StringVar(&client.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file")
+ f.BoolVar(&client.InsecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download")
+ f.BoolVar(&client.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download")
+ f.StringVar(&client.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
+}
diff --git a/cmd/helm/dependency_build.go b/pkg/cmd/dependency_build.go
similarity index 77%
rename from cmd/helm/dependency_build.go
rename to pkg/cmd/dependency_build.go
index 1ee46d3d2..320fe12ae 100644
--- a/cmd/helm/dependency_build.go
+++ b/pkg/cmd/dependency_build.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -24,10 +24,10 @@ import (
"github.com/spf13/cobra"
"k8s.io/client-go/util/homedir"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
)
const dependencyBuildDesc = `
@@ -41,7 +41,7 @@ If no lock file is found, 'helm dependency build' will mirror the behavior
of 'helm dependency update'.
`
-func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
+func newDependencyBuildCmd(out io.Writer) *cobra.Command {
client := action.NewDependency()
cmd := &cobra.Command{
@@ -49,26 +49,33 @@ func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Comm
Short: "rebuild the charts/ directory based on the Chart.lock file",
Long: dependencyBuildDesc,
Args: require.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
chartpath := "."
if len(args) > 0 {
chartpath = filepath.Clean(args[0])
}
+ registryClient, err := newRegistryClient(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)
+ }
+
man := &downloader.Manager{
Out: out,
ChartPath: chartpath,
Keyring: client.Keyring,
SkipUpdate: client.SkipRefresh,
Getters: getter.All(settings),
- RegistryClient: cfg.RegistryClient,
+ RegistryClient: registryClient,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
Debug: settings.Debug,
}
if client.Verify {
man.Verify = downloader.VerifyIfPossible
}
- err := man.Build()
+ err = man.Build()
if e, ok := err.(downloader.ErrRepoNotFound); ok {
return fmt.Errorf("%s. Please add the missing repos via 'helm repo add'", e.Error())
}
@@ -77,9 +84,7 @@ func newDependencyBuildCmd(cfg *action.Configuration, out io.Writer) *cobra.Comm
}
f := cmd.Flags()
- f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures")
- f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys")
- f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache")
+ addDependencySubcommandFlags(f, client)
return cmd
}
diff --git a/cmd/helm/dependency_build_test.go b/pkg/cmd/dependency_build_test.go
similarity index 88%
rename from cmd/helm/dependency_build_test.go
rename to pkg/cmd/dependency_build_test.go
index 37e3242c4..a4a89b7a9 100644
--- a/cmd/helm/dependency_build_test.go
+++ b/pkg/cmd/dependency_build_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,18 +22,18 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/provenance"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/provenance"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestDependencyBuildCmd(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz")
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"),
+ )
defer srv.Stop()
- if err != nil {
- t.Fatal(err)
- }
rootDir := srv.Root()
srv.LinkIndices()
@@ -58,7 +58,7 @@ func TestDependencyBuildCmd(t *testing.T) {
createTestingChart(t, rootDir, chartname, srv.URL())
repoFile := filepath.Join(rootDir, "repositories.yaml")
- cmd := fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir)
+ cmd := fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --plain-http", filepath.Join(rootDir, chartname), repoFile, rootDir)
_, out, err := executeActionCommand(cmd)
// In the first pass, we basically want the same results as an update.
@@ -117,7 +117,7 @@ func TestDependencyBuildCmd(t *testing.T) {
t.Errorf("mismatched versions. Expected %q, got %q", "0.1.0", v)
}
- skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s", filepath.Join(rootDir, chartname), repoFile, rootDir)
+ skipRefreshCmd := fmt.Sprintf("dependency build '%s' --skip-refresh --repository-config %s --repository-cache %s --plain-http", filepath.Join(rootDir, chartname), repoFile, rootDir)
_, out, err = executeActionCommand(skipRefreshCmd)
// In this pass, we check --skip-refresh option becomes effective.
@@ -134,7 +134,7 @@ func TestDependencyBuildCmd(t *testing.T) {
if err := chartutil.SaveDir(c, dir()); err != nil {
t.Fatal(err)
}
- cmd = fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json",
+ cmd = fmt.Sprintf("dependency build '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --plain-http",
dir(ociChartName),
dir("repositories.yaml"),
dir(),
diff --git a/cmd/helm/dependency_test.go b/pkg/cmd/dependency_test.go
similarity index 99%
rename from cmd/helm/dependency_test.go
rename to pkg/cmd/dependency_test.go
index 34c6a25e1..d6bcebf1b 100644
--- a/cmd/helm/dependency_test.go
+++ b/pkg/cmd/dependency_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"runtime"
diff --git a/cmd/helm/dependency_update.go b/pkg/cmd/dependency_update.go
similarity index 76%
rename from cmd/helm/dependency_update.go
rename to pkg/cmd/dependency_update.go
index ad0188f17..b534fb48a 100644
--- a/cmd/helm/dependency_update.go
+++ b/pkg/cmd/dependency_update.go
@@ -13,18 +13,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "fmt"
"io"
"path/filepath"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
)
const dependencyUpDesc = `
@@ -43,7 +44,7 @@ in the Chart.yaml file, but (b) at the wrong version.
`
// newDependencyUpdateCmd creates a new dependency update command.
-func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
+func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Command {
client := action.NewDependency()
cmd := &cobra.Command{
@@ -52,20 +53,27 @@ func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Com
Short: "update charts/ based on the contents of Chart.yaml",
Long: dependencyUpDesc,
Args: require.MaximumNArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
chartpath := "."
if len(args) > 0 {
chartpath = filepath.Clean(args[0])
}
+ registryClient, err := newRegistryClient(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)
+ }
+
man := &downloader.Manager{
Out: out,
ChartPath: chartpath,
Keyring: client.Keyring,
SkipUpdate: client.SkipRefresh,
Getters: getter.All(settings),
- RegistryClient: cfg.RegistryClient,
+ RegistryClient: registryClient,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
Debug: settings.Debug,
}
if client.Verify {
@@ -76,9 +84,7 @@ func newDependencyUpdateCmd(cfg *action.Configuration, out io.Writer) *cobra.Com
}
f := cmd.Flags()
- f.BoolVar(&client.Verify, "verify", false, "verify the packages against signatures")
- f.StringVar(&client.Keyring, "keyring", defaultKeyring(), "keyring containing public keys")
- f.BoolVar(&client.SkipRefresh, "skip-refresh", false, "do not refresh the local repository cache")
+ addDependencySubcommandFlags(f, client)
return cmd
}
diff --git a/cmd/helm/dependency_update_test.go b/pkg/cmd/dependency_update_test.go
similarity index 82%
rename from cmd/helm/dependency_update_test.go
rename to pkg/cmd/dependency_update_test.go
index 967786b9a..f1b39c4b7 100644
--- a/cmd/helm/dependency_update_test.go
+++ b/pkg/cmd/dependency_update_test.go
@@ -13,29 +13,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
"testing"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/provenance"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/internal/test/ensure"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/provenance"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestDependencyUpdateCmd(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"),
+ )
defer srv.Stop()
t.Logf("Listening on directory %s", srv.Root())
@@ -43,6 +45,7 @@ func TestDependencyUpdateCmd(t *testing.T) {
if err != nil {
t.Fatal(err)
}
+ contentCache := t.TempDir()
ociChartName := "oci-depending-chart"
c := createTestingMetadataForOCI(ociChartName, ociSrv.RegistryURL)
@@ -67,7 +70,7 @@ func TestDependencyUpdateCmd(t *testing.T) {
}
_, out, err := executeActionCommand(
- fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()),
+ fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache),
)
if err != nil {
t.Logf("Output: %s", out)
@@ -110,7 +113,7 @@ func TestDependencyUpdateCmd(t *testing.T) {
t.Fatal(err)
}
- _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()))
+ _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache))
if err != nil {
t.Logf("Output: %s", out)
t.Fatal(err)
@@ -131,11 +134,12 @@ func TestDependencyUpdateCmd(t *testing.T) {
if err := chartutil.SaveDir(c, dir()); err != nil {
t.Fatal(err)
}
- cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json",
+ cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --content-cache %s --plain-http",
dir(ociChartName),
dir("repositories.yaml"),
dir(),
- dir())
+ dir(),
+ contentCache)
_, out, err = executeActionCommand(cmd)
if err != nil {
t.Logf("Output: %s", out)
@@ -151,10 +155,10 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) {
defer resetEnv()()
ensure.HelmHome(t)
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"),
+ )
defer srv.Stop()
t.Logf("Listening on directory %s", srv.Root())
@@ -169,7 +173,7 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) {
}
createTestingChart(t, dir(), chartname, srv.URL())
- _, output, err := executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()))
+ _, output, err := executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir()))
if err != nil {
t.Logf("Output: %s", output)
t.Fatal(err)
@@ -177,8 +181,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) {
// Chart repo is down
srv.Stop()
+ contentCache := t.TempDir()
- _, output, err = executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s", dir(chartname), dir("repositories.yaml"), dir()))
+ _, output, err = executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache))
if err == nil {
t.Logf("Output: %s", output)
t.Fatal("Expected error, got nil")
@@ -200,8 +205,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) {
}
}
- // Make sure tmpcharts is deleted
- if _, err := os.Stat(filepath.Join(dir(chartname), "tmpcharts")); !os.IsNotExist(err) {
+ // 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")
}
}
@@ -229,9 +235,11 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) {
t.Fatal(err)
}
+ contentCache := t.TempDir()
+
_, out, err := executeActionCommand(
- fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname),
- dir("repositories.yaml"), dir()),
+ fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s", dir(chartname),
+ dir("repositories.yaml"), dir(), contentCache),
)
if err != nil {
@@ -247,10 +255,11 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) {
}
func setupMockRepoServer(t *testing.T) *repotest.Server {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz")
- if err != nil {
- t.Fatal(err)
- }
+ t.Helper()
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz"),
+ )
t.Logf("Listening on directory %s", srv.Root())
diff --git a/cmd/helm/docs.go b/pkg/cmd/docs.go
similarity index 84%
rename from cmd/helm/docs.go
rename to pkg/cmd/docs.go
index 523a96022..7fae60743 100644
--- a/cmd/helm/docs.go
+++ b/pkg/cmd/docs.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,13 +22,12 @@ import (
"path/filepath"
"strings"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
"golang.org/x/text/cases"
"golang.org/x/text/language"
- "helm.sh/helm/v3/cmd/helm/require"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const docsDesc = `
@@ -58,8 +57,8 @@ func newDocsCmd(out io.Writer) *cobra.Command {
Long: docsDesc,
Hidden: true,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
o.topCmd = cmd.Root()
return o.run(out)
},
@@ -70,14 +69,14 @@ func newDocsCmd(out io.Writer) *cobra.Command {
f.StringVar(&o.docTypeString, "type", "markdown", "the type of documentation to generate (markdown, man, bash)")
f.BoolVar(&o.generateHeaders, "generate-headers", false, "generate standard headers for markdown files")
- cmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ cmd.RegisterFlagCompletionFunc("type", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"bash", "man", "markdown"}, cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
-func (o *docsOptions) run(out io.Writer) error {
+func (o *docsOptions) run(_ io.Writer) error {
switch o.docTypeString {
case "markdown", "mdown", "md":
if o.generateHeaders {
@@ -86,7 +85,7 @@ func (o *docsOptions) run(out io.Writer) error {
hdrFunc := func(filename string) string {
base := filepath.Base(filename)
name := strings.TrimSuffix(base, path.Ext(base))
- title := cases.Title(language.Und, cases.NoLower).String(strings.Replace(name, "_", " ", -1))
+ title := cases.Title(language.Und, cases.NoLower).String(strings.ReplaceAll(name, "_", " "))
return fmt.Sprintf("---\ntitle: \"%s\"\n---\n\n", title)
}
@@ -99,6 +98,6 @@ func (o *docsOptions) run(out io.Writer) error {
case "bash":
return o.topCmd.GenBashCompletionFile(filepath.Join(o.dest, "completions.bash"))
default:
- return errors.Errorf("unknown doc type %q. Try 'markdown' or 'man'", o.docTypeString)
+ return fmt.Errorf("unknown doc type %q. Try 'markdown' or 'man'", o.docTypeString)
}
}
diff --git a/cmd/helm/docs_test.go b/pkg/cmd/docs_test.go
similarity index 98%
rename from cmd/helm/docs_test.go
rename to pkg/cmd/docs_test.go
index fe5864d5e..4a8a8c687 100644
--- a/cmd/helm/docs_test.go
+++ b/pkg/cmd/docs_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/env.go b/pkg/cmd/env.go
similarity index 85%
rename from cmd/helm/env.go
rename to pkg/cmd/env.go
index 3754b748d..8da201031 100644
--- a/cmd/helm/env.go
+++ b/pkg/cmd/env.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,7 +23,7 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var envHelp = `
@@ -36,15 +36,15 @@ func newEnvCmd(out io.Writer) *cobra.Command {
Short: "helm client environment information",
Long: envHelp,
Args: require.MaximumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
keys := getSortedEnvVarKeys()
return keys, cobra.ShellCompDirectiveNoFileComp
}
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, args []string) {
envVars := settings.EnvVars()
if len(args) == 0 {
diff --git a/cmd/helm/env_test.go b/pkg/cmd/env_test.go
similarity index 98%
rename from cmd/helm/env_test.go
rename to pkg/cmd/env_test.go
index 01ef25933..c5d7af1b7 100644
--- a/cmd/helm/env_test.go
+++ b/pkg/cmd/env_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/flags.go b/pkg/cmd/flags.go
similarity index 77%
rename from cmd/helm/flags.go
rename to pkg/cmd/flags.go
index a8f25cb35..d11073e5f 100644
--- a/cmd/helm/flags.go
+++ b/pkg/cmd/flags.go
@@ -14,26 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"flag"
"fmt"
"log"
+ "log/slog"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
+
"k8s.io/klog/v2"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/postrender"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/kube"
+ "helm.sh/helm/v4/pkg/postrender"
+ "helm.sh/helm/v4/pkg/repo"
)
const (
@@ -47,10 +50,56 @@ func addValueOptionsFlags(f *pflag.FlagSet, v *values.Options) {
f.StringArrayVar(&v.Values, "set", []string{}, "set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.StringValues, "set-string", []string{}, "set STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)")
f.StringArrayVar(&v.FileValues, "set-file", []string{}, "set values from respective files specified via the command line (can specify multiple or separate values with commas: key1=path1,key2=path2)")
- f.StringArrayVar(&v.JSONValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2)")
+ f.StringArrayVar(&v.JSONValues, "set-json", []string{}, "set JSON values on the command line (can specify multiple or separate values with commas: key1=jsonval1,key2=jsonval2 or using json format: {\"key1\": jsonval1, \"key2\": \"jsonval2\"})")
f.StringArrayVar(&v.LiteralValues, "set-literal", []string{}, "set a literal STRING value on the command line")
}
+func AddWaitFlag(cmd *cobra.Command, wait *kube.WaitStrategy) {
+ cmd.Flags().Var(
+ newWaitValue(kube.HookOnlyStrategy, wait),
+ "wait",
+ "if specified, will wait until all resources are in the expected state before marking the operation as successful. It will wait for as long as --timeout. Valid inputs are 'watcher' and 'legacy'",
+ )
+ // Sets the strategy to use the watcher strategy if `--wait` is used without an argument
+ cmd.Flags().Lookup("wait").NoOptDefVal = string(kube.StatusWatcherStrategy)
+}
+
+type waitValue kube.WaitStrategy
+
+func newWaitValue(defaultValue kube.WaitStrategy, ws *kube.WaitStrategy) *waitValue {
+ *ws = defaultValue
+ return (*waitValue)(ws)
+}
+
+func (ws *waitValue) String() string {
+ if ws == nil {
+ return ""
+ }
+ return string(*ws)
+}
+
+func (ws *waitValue) Set(s string) error {
+ switch s {
+ case string(kube.StatusWatcherStrategy), string(kube.LegacyStrategy):
+ *ws = waitValue(s)
+ return nil
+ case "true":
+ slog.Warn("--wait=true is deprecated (boolean value) and can be replaced with --wait=watcher")
+ *ws = waitValue(kube.StatusWatcherStrategy)
+ return nil
+ case "false":
+ slog.Warn("--wait=false is deprecated (boolean value) and can be replaced by omitting the --wait flag")
+ *ws = waitValue(kube.HookOnlyStrategy)
+ return nil
+ default:
+ return fmt.Errorf("invalid wait input %q. Valid inputs are %s, and %s", s, kube.StatusWatcherStrategy, kube.LegacyStrategy)
+ }
+}
+
+func (ws *waitValue) Type() string {
+ return "WaitStrategy"
+}
+
func addChartPathOptionsFlags(f *pflag.FlagSet, c *action.ChartPathOptions) {
f.StringVar(&c.Version, "version", "", "specify a version constraint for the chart version to use. This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used")
f.BoolVar(&c.Verify, "verify", false, "verify the package before using it")
@@ -72,7 +121,7 @@ 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(), ", ")))
- err := cmd.RegisterFlagCompletionFunc(outputFlag, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc(outputFlag, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
var formatNames []string
for format, desc := range output.FormatsWithDesc() {
formatNames = append(formatNames, fmt.Sprintf("%s\t%s", format, desc))
@@ -143,6 +192,9 @@ func (p *postRendererString) Set(val string) error {
if val == "" {
return nil
}
+ if p.options.binaryPath != "" {
+ return fmt.Errorf("cannot specify --post-renderer flag more than once")
+ }
p.options.binaryPath = val
pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...)
if err != nil {
@@ -195,7 +247,7 @@ func (p *postRendererArgsSlice) GetSlice() []string {
return p.options.args
}
-func compVersionFlag(chartRef string, toComplete string) ([]string, cobra.ShellCompDirective) {
+func compVersionFlag(chartRef string, _ string) ([]string, cobra.ShellCompDirective) {
chartInfo := strings.Split(chartRef, "/")
if len(chartInfo) != 2 {
return nil, cobra.ShellCompDirectiveNoFileComp
@@ -209,7 +261,7 @@ func compVersionFlag(chartRef string, toComplete string) ([]string, cobra.ShellC
var versions []string
if indexFile, err := repo.LoadIndexFile(path); err == nil {
for _, details := range indexFile.Entries[chartName] {
- appVersion := details.Metadata.AppVersion
+ appVersion := details.AppVersion
appVersionDesc := ""
if appVersion != "" {
appVersionDesc = fmt.Sprintf("App: %s, ", appVersion)
@@ -220,10 +272,10 @@ func compVersionFlag(chartRef string, toComplete string) ([]string, cobra.ShellC
createdDesc = fmt.Sprintf("Created: %s ", created)
}
deprecated := ""
- if details.Metadata.Deprecated {
+ if details.Deprecated {
deprecated = "(deprecated)"
}
- versions = append(versions, fmt.Sprintf("%s\t%s%s%s", details.Metadata.Version, appVersionDesc, createdDesc, deprecated))
+ versions = append(versions, fmt.Sprintf("%s\t%s%s%s", details.Version, appVersionDesc, createdDesc, deprecated))
}
}
diff --git a/cmd/helm/flags_test.go b/pkg/cmd/flags_test.go
similarity index 78%
rename from cmd/helm/flags_test.go
rename to pkg/cmd/flags_test.go
index 07d28c460..cbc2e6419 100644
--- a/cmd/helm/flags_test.go
+++ b/pkg/cmd/flags_test.go
@@ -14,18 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ "github.com/stretchr/testify/require"
+
+ "helm.sh/helm/v4/pkg/action"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
func outputFlagCompletionTest(t *testing.T, cmdName string) {
+ t.Helper()
releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release {
info.LastDeployed = helmtime.Unix(1452902400, 0).UTC()
return []*release.Release{{
@@ -93,3 +97,24 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) {
}}
runTestCmd(t, tests)
}
+
+func TestPostRendererFlagSetOnce(t *testing.T) {
+ cfg := action.Configuration{}
+ client := action.NewInstall(&cfg)
+ str := postRendererString{
+ options: &postRendererOptions{
+ renderer: &client.PostRenderer,
+ },
+ }
+ // Set the binary once
+ err := str.Set("echo")
+ require.NoError(t, err)
+
+ // Set the binary again to the same value is not ok
+ err = str.Set("echo")
+ require.Error(t, err)
+
+ // Set the binary again to a different value is not ok
+ err = str.Set("cat")
+ require.Error(t, err)
+}
diff --git a/cmd/helm/get.go b/pkg/cmd/get.go
similarity index 94%
rename from cmd/helm/get.go
rename to pkg/cmd/get.go
index 727cdaf88..1e672beea 100644
--- a/cmd/helm/get.go
+++ b/pkg/cmd/get.go
@@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var getHelp = `
diff --git a/cmd/helm/get_all.go b/pkg/cmd/get_all.go
similarity index 72%
rename from cmd/helm/get_all.go
rename to pkg/cmd/get_all.go
index e51d50536..32744796c 100644
--- a/cmd/helm/get_all.go
+++ b/pkg/cmd/get_all.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
@@ -22,9 +22,9 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var getAllHelp = `
@@ -41,13 +41,13 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "download all information for a named release",
Long: getAllHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
res, err := client.Run(args[0])
if err != nil {
return err
@@ -58,20 +58,24 @@ func newGetAllCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
return tpl(template, data, out)
}
-
- return output.Table.Write(out, &statusPrinter{res, true, false, false, true})
+ return output.Table.Write(out, &statusPrinter{
+ release: res,
+ debug: true,
+ showMetadata: true,
+ hideNotes: false,
+ noColor: settings.ShouldDisableColor(),
+ })
},
}
f := cmd.Flags()
f.IntVar(&client.Version, "revision", 0, "get the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
return nil, cobra.ShellCompDirectiveNoFileComp
})
-
if err != nil {
log.Fatal(err)
}
diff --git a/cmd/helm/get_all_test.go b/pkg/cmd/get_all_test.go
similarity index 96%
rename from cmd/helm/get_all_test.go
rename to pkg/cmd/get_all_test.go
index 948f0aa71..80bb7d332 100644
--- a/cmd/helm/get_all_test.go
+++ b/pkg/cmd/get_all_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetCmd(t *testing.T) {
diff --git a/cmd/helm/get_hooks.go b/pkg/cmd/get_hooks.go
similarity index 78%
rename from cmd/helm/get_hooks.go
rename to pkg/cmd/get_hooks.go
index 913e2c58a..7ffefd93c 100644
--- a/cmd/helm/get_hooks.go
+++ b/pkg/cmd/get_hooks.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const getHooksHelp = `
@@ -41,13 +41,13 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "download all hooks for a named release",
Long: getHooksHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
res, err := client.Run(args[0])
if err != nil {
return err
@@ -60,7 +60,7 @@ func newGetHooksCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
diff --git a/cmd/helm/get_hooks_test.go b/pkg/cmd/get_hooks_test.go
similarity index 96%
rename from cmd/helm/get_hooks_test.go
rename to pkg/cmd/get_hooks_test.go
index 251d5c731..3be1d8500 100644
--- a/cmd/helm/get_hooks_test.go
+++ b/pkg/cmd/get_hooks_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetHooks(t *testing.T) {
diff --git a/cmd/helm/get_manifest.go b/pkg/cmd/get_manifest.go
similarity index 78%
rename from cmd/helm/get_manifest.go
rename to pkg/cmd/get_manifest.go
index baeaf8d72..021495d8d 100644
--- a/cmd/helm/get_manifest.go
+++ b/pkg/cmd/get_manifest.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var getManifestHelp = `
@@ -43,13 +43,13 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
Short: "download the manifest for a named release",
Long: getManifestHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
res, err := client.Run(args[0])
if err != nil {
return err
@@ -60,7 +60,7 @@ func newGetManifestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
}
cmd.Flags().IntVar(&client.Version, "revision", 0, "get the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
diff --git a/cmd/helm/get_manifest_test.go b/pkg/cmd/get_manifest_test.go
similarity index 96%
rename from cmd/helm/get_manifest_test.go
rename to pkg/cmd/get_manifest_test.go
index 2f27476b6..cfb5215bf 100644
--- a/cmd/helm/get_manifest_test.go
+++ b/pkg/cmd/get_manifest_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetManifest(t *testing.T) {
diff --git a/cmd/helm/get_metadata.go b/pkg/cmd/get_metadata.go
similarity index 75%
rename from cmd/helm/get_metadata.go
rename to pkg/cmd/get_metadata.go
index adab891bd..aea149f5e 100644
--- a/cmd/helm/get_metadata.go
+++ b/pkg/cmd/get_metadata.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,10 +22,11 @@ import (
"log"
"github.com/spf13/cobra"
+ k8sLabels "k8s.io/apimachinery/pkg/labels"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
type metadataWriter struct {
@@ -40,13 +41,13 @@ func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
Use: "metadata RELEASE_NAME",
Short: "This command fetches metadata for a given release",
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
releaseMetadata, err := client.Run(args[0])
if err != nil {
return err
@@ -57,7 +58,7 @@ func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
f := cmd.Flags()
f.IntVar(&client.Version, "revision", 0, "specify release revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
@@ -78,10 +79,14 @@ func (w metadataWriter) WriteTable(out io.Writer) error {
_, _ = fmt.Fprintf(out, "CHART: %v\n", w.metadata.Chart)
_, _ = fmt.Fprintf(out, "VERSION: %v\n", w.metadata.Version)
_, _ = fmt.Fprintf(out, "APP_VERSION: %v\n", w.metadata.AppVersion)
+ _, _ = fmt.Fprintf(out, "ANNOTATIONS: %v\n", k8sLabels.Set(w.metadata.Annotations).String())
+ _, _ = fmt.Fprintf(out, "LABELS: %v\n", k8sLabels.Set(w.metadata.Labels).String())
+ _, _ = fmt.Fprintf(out, "DEPENDENCIES: %v\n", w.metadata.FormattedDepNames())
_, _ = fmt.Fprintf(out, "NAMESPACE: %v\n", w.metadata.Namespace)
_, _ = fmt.Fprintf(out, "REVISION: %v\n", w.metadata.Revision)
_, _ = fmt.Fprintf(out, "STATUS: %v\n", w.metadata.Status)
_, _ = fmt.Fprintf(out, "DEPLOYED_AT: %v\n", w.metadata.DeployedAt)
+
return nil
}
diff --git a/cmd/helm/get_metadata_test.go b/pkg/cmd/get_metadata_test.go
similarity index 84%
rename from cmd/helm/get_metadata_test.go
rename to pkg/cmd/get_metadata_test.go
index b6f0ab9f2..59fc3b82c 100644
--- a/cmd/helm/get_metadata_test.go
+++ b/pkg/cmd/get_metadata_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetMetadataCmd(t *testing.T) {
@@ -27,23 +27,23 @@ func TestGetMetadataCmd(t *testing.T) {
name: "get metadata with a release",
cmd: "get metadata thomas-guide",
golden: "output/get-metadata.txt",
- rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+ rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide", Labels: map[string]string{"key1": "value1"}})},
}, {
name: "get metadata requires release name arg",
cmd: "get metadata",
golden: "output/get-metadata-args.txt",
- rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+ rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide", Labels: map[string]string{"key1": "value1"}})},
wantError: true,
}, {
name: "get metadata to json",
cmd: "get metadata thomas-guide --output json",
golden: "output/get-metadata.json",
- rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+ rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide", Labels: map[string]string{"key1": "value1"}})},
}, {
name: "get metadata to yaml",
cmd: "get metadata thomas-guide --output yaml",
golden: "output/get-metadata.yaml",
- rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide"})},
+ rels: []*release.Release{release.Mock(&release.MockReleaseOptions{Name: "thomas-guide", Labels: map[string]string{"key1": "value1"}})},
}}
runTestCmd(t, tests)
}
diff --git a/cmd/helm/get_notes.go b/pkg/cmd/get_notes.go
similarity index 77%
rename from cmd/helm/get_notes.go
rename to pkg/cmd/get_notes.go
index b71bcbdf6..ae79d8bcc 100644
--- a/cmd/helm/get_notes.go
+++ b/pkg/cmd/get_notes.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var getNotesHelp = `
@@ -39,13 +39,13 @@ func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "download the notes for a named release",
Long: getNotesHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
res, err := client.Run(args[0])
if err != nil {
return err
@@ -59,7 +59,7 @@ func newGetNotesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f := cmd.Flags()
f.IntVar(&client.Version, "revision", 0, "get the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
diff --git a/cmd/helm/get_notes_test.go b/pkg/cmd/get_notes_test.go
similarity index 96%
rename from cmd/helm/get_notes_test.go
rename to pkg/cmd/get_notes_test.go
index 8be9a3f7c..b451dfa05 100644
--- a/cmd/helm/get_notes_test.go
+++ b/pkg/cmd/get_notes_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetNotesCmd(t *testing.T) {
diff --git a/cmd/helm/get_test.go b/pkg/cmd/get_test.go
similarity index 98%
rename from cmd/helm/get_test.go
rename to pkg/cmd/get_test.go
index 79f914bea..cf81e4df7 100644
--- a/cmd/helm/get_test.go
+++ b/pkg/cmd/get_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/get_values.go b/pkg/cmd/get_values.go
similarity index 81%
rename from cmd/helm/get_values.go
rename to pkg/cmd/get_values.go
index 6124e1b33..02b195551 100644
--- a/cmd/helm/get_values.go
+++ b/pkg/cmd/get_values.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,9 +23,9 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var getValuesHelp = `
@@ -46,13 +46,13 @@ func newGetValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "download the values file for a named release",
Long: getValuesHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
vals, err := client.Run(args[0])
if err != nil {
return err
@@ -63,7 +63,7 @@ func newGetValuesCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f := cmd.Flags()
f.IntVar(&client.Version, "revision", 0, "get the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
diff --git a/cmd/helm/get_values_test.go b/pkg/cmd/get_values_test.go
similarity index 97%
rename from cmd/helm/get_values_test.go
rename to pkg/cmd/get_values_test.go
index 423c32859..7bbe109f6 100644
--- a/cmd/helm/get_values_test.go
+++ b/pkg/cmd/get_values_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestGetValuesCmd(t *testing.T) {
diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go
new file mode 100644
index 000000000..40478c30e
--- /dev/null
+++ b/pkg/cmd/helpers_test.go
@@ -0,0 +1,153 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "testing"
+
+ shellwords "github.com/mattn/go-shellwords"
+ "github.com/spf13/cobra"
+
+ "helm.sh/helm/v4/internal/test"
+ "helm.sh/helm/v4/pkg/action"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage"
+ "helm.sh/helm/v4/pkg/storage/driver"
+ "helm.sh/helm/v4/pkg/time"
+)
+
+func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() }
+
+func init() {
+ action.Timestamper = testTimestamper
+}
+
+func runTestCmd(t *testing.T, tests []cmdTestCase) {
+ t.Helper()
+ for _, tt := range tests {
+ for i := 0; i <= tt.repeat; i++ {
+ t.Run(tt.name, func(t *testing.T) {
+ defer resetEnv()()
+
+ storage := storageFixture()
+ for _, rel := range tt.rels {
+ if err := storage.Create(rel); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Logf("running cmd (attempt %d): %s", i+1, tt.cmd)
+ _, out, err := executeActionCommandC(storage, tt.cmd)
+ if tt.wantError && err == nil {
+ t.Errorf("expected error, got success with the following output:\n%s", out)
+ }
+ if !tt.wantError && err != nil {
+ t.Errorf("expected no error, got: '%v'", err)
+ }
+ if tt.golden != "" {
+ test.AssertGoldenString(t, out, tt.golden)
+ }
+ })
+ }
+ }
+}
+
+func storageFixture() *storage.Storage {
+ return storage.Init(driver.NewMemory())
+}
+
+func executeActionCommandC(store *storage.Storage, cmd string) (*cobra.Command, string, error) {
+ return executeActionCommandStdinC(store, nil, cmd)
+}
+
+func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) (*cobra.Command, string, error) {
+ args, err := shellwords.Parse(cmd)
+ if err != nil {
+ return nil, "", err
+ }
+
+ buf := new(bytes.Buffer)
+
+ actionConfig := &action.Configuration{
+ Releases: store,
+ KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard},
+ Capabilities: chartutil.DefaultCapabilities,
+ }
+
+ root, err := newRootCmdWithConfig(actionConfig, buf, args, SetupLogging)
+ if err != nil {
+ return nil, "", err
+ }
+
+ root.SetOut(buf)
+ root.SetErr(buf)
+ root.SetArgs(args)
+
+ oldStdin := os.Stdin
+ defer func() {
+ os.Stdin = oldStdin
+ }()
+
+ if in != nil {
+ root.SetIn(in)
+ os.Stdin = in
+ }
+
+ if mem, ok := store.Driver.(*driver.Memory); ok {
+ mem.SetNamespace(settings.Namespace())
+ }
+ c, err := root.ExecuteC()
+
+ result := buf.String()
+
+ return c, result, err
+}
+
+// cmdTestCase describes a test case that works with releases.
+type cmdTestCase struct {
+ name string
+ cmd string
+ golden string
+ wantError bool
+ // Rels are the available releases at the start of the test.
+ rels []*release.Release
+ // Number of repeats (in case a feature was previously flaky and the test checks
+ // it's now stably producing identical results). 0 means test is run exactly once.
+ repeat int
+}
+
+func executeActionCommand(cmd string) (*cobra.Command, string, error) {
+ return executeActionCommandC(storageFixture(), cmd)
+}
+
+func resetEnv() func() {
+ origEnv := os.Environ()
+ return func() {
+ os.Clearenv()
+ for _, pair := range origEnv {
+ kv := strings.SplitN(pair, "=", 2)
+ os.Setenv(kv[0], kv[1])
+ }
+ settings = cli.New()
+ }
+}
diff --git a/cmd/helm/history.go b/pkg/cmd/history.go
similarity index 82%
rename from cmd/helm/history.go
rename to pkg/cmd/history.go
index ee6f391e4..ec2a1bc12 100644
--- a/cmd/helm/history.go
+++ b/pkg/cmd/history.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -25,13 +25,13 @@ import (
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/releaseutil"
- helmtime "helm.sh/helm/v3/pkg/time"
+ "helm.sh/helm/v4/pkg/action"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
var historyHelp = `
@@ -60,13 +60,13 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "fetch release history",
Aliases: []string{"hist"},
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
history, err := getHistory(client, args[0])
if err != nil {
return err
@@ -136,7 +136,7 @@ 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]
- c := formatChartname(r.Chart)
+ c := formatChartName(r.Chart)
s := r.Info.Status.String()
v := r.Version
d := r.Info.Description
@@ -159,7 +159,7 @@ func getReleaseHistory(rls []*release.Release) (history releaseHistory) {
return history
}
-func formatChartname(c *chart.Chart) string {
+func formatChartName(c *chart.Chart) string {
if c == nil || c.Metadata == nil {
// This is an edge case that has happened in prod, though we don't
// know how: https://github.com/helm/helm/issues/1347
@@ -177,22 +177,15 @@ func formatAppVersion(c *chart.Chart) string {
return c.AppVersion()
}
-func min(x, y int) int {
- if x < y {
- return x
- }
- return y
-}
-
-func compListRevisions(toComplete string, cfg *action.Configuration, releaseName string) ([]string, cobra.ShellCompDirective) {
+func compListRevisions(_ string, cfg *action.Configuration, releaseName string) ([]string, cobra.ShellCompDirective) {
client := action.NewHistory(cfg)
var revisions []string
if hist, err := client.Run(releaseName); err == nil {
- for _, release := range hist {
- appVersion := fmt.Sprintf("App: %s", release.Chart.Metadata.AppVersion)
- chartDesc := fmt.Sprintf("Chart: %s-%s", release.Chart.Metadata.Name, release.Chart.Metadata.Version)
- revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(release.Version), appVersion, chartDesc))
+ for _, version := range hist {
+ appVersion := fmt.Sprintf("App: %s", version.Chart.Metadata.AppVersion)
+ chartDesc := fmt.Sprintf("Chart: %s-%s", version.Chart.Metadata.Name, version.Chart.Metadata.Version)
+ revisions = append(revisions, fmt.Sprintf("%s\t%s, %s", strconv.Itoa(version.Version), appVersion, chartDesc))
}
return revisions, cobra.ShellCompDirectiveNoFileComp
}
diff --git a/cmd/helm/history_test.go b/pkg/cmd/history_test.go
similarity index 98%
rename from cmd/helm/history_test.go
rename to pkg/cmd/history_test.go
index 07f2d85df..d26ed9ecf 100644
--- a/cmd/helm/history_test.go
+++ b/pkg/cmd/history_test.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestHistoryCmd(t *testing.T) {
@@ -75,6 +75,7 @@ func TestHistoryOutputCompletion(t *testing.T) {
}
func revisionFlagCompletionTest(t *testing.T, cmdName string) {
+ t.Helper()
mk := func(name string, vers int, status release.Status) *release.Release {
return release.Mock(&release.MockReleaseOptions{
Name: name,
diff --git a/cmd/helm/install.go b/pkg/cmd/install.go
similarity index 74%
rename from cmd/helm/install.go
rename to pkg/cmd/install.go
index d987d300f..869163a2a 100644
--- a/cmd/helm/install.go
+++ b/pkg/cmd/install.go
@@ -14,31 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"context"
+ "errors"
"fmt"
"io"
"log"
+ "log/slog"
"os"
"os/signal"
+ "slices"
"syscall"
"time"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/release"
+ "helm.sh/helm/v4/pkg/action"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
const installDesc = `
@@ -52,7 +54,7 @@ or use the '--set' flag and pass configuration from the command line, to force
a string value use '--set-string'. You can use '--set-file' to set individual
values from a file when the value itself is too long for the command line
or is dynamically generated. You can also use '--set-json' to set json values
-(scalars/objects/arrays) from the command line.
+(scalars/objects/arrays) from the command line. Additionally, you can use '--set-json' and passing json object as a string.
$ helm install -f myvalues.yaml myredis ./redis
@@ -72,6 +74,9 @@ or
$ helm install --set-json 'master.sidecars=[{"name":"sidecar","image":"myImage","imagePullPolicy":"Always","ports":[{"name":"portname","containerPort":1234}]}]' myredis ./redis
+or
+
+ $ helm install --set-json '{"master":{"sidecars":[{"name":"sidecar","image":"myImage","imagePullPolicy":"Always","ports":[{"name":"portname","containerPort":1234}]}]}}' myredis ./redis
You can specify the '--values'/'-f' flag multiple times. The priority will be given to the
last (right-most) file specified. For example, if both myvalues.yaml and override.yaml
@@ -85,7 +90,7 @@ set for a key called 'foo', the 'newbar' value would take precedence:
$ helm install --set foo=bar --set foo=newbar myredis ./redis
-Similarly, in the following example 'foo' is set to '["four"]':
+Similarly, in the following example 'foo' is set to '["four"]':
$ helm install --set-json='foo=["one", "two", "three"]' --set-json='foo=["four"]' myredis ./redis
@@ -94,7 +99,11 @@ And in the following example, 'foo' is set to '{"key1":"value1","key2":"bar"}':
$ helm install --set-json='foo={"key1":"value1","key2":"value2"}' --set-json='foo.key2="bar"' myredis ./redis
To check the generated manifests of a release without installing the chart,
-the '--debug' and '--dry-run' flags can be combined.
+the --debug and --dry-run flags can be combined.
+
+The --dry-run flag will output all generated chart manifests, including Secrets
+which can contain sensitive values. To hide Kubernetes Secrets use the
+--hide-secret flag. Please carefully consider how and when these flags are used.
If --verify is set, the chart MUST have a provenance file, and the provenance
file MUST pass all verification steps.
@@ -132,12 +141,12 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "install a chart",
Long: installDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compInstall(args, toComplete, client)
},
RunE: func(_ *cobra.Command, args []string) error {
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
- client.InsecureSkipTLSverify, client.PlainHTTP)
+ client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
@@ -151,14 +160,24 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
rel, err := runInstall(args, client, valueOpts, out)
if err != nil {
- return errors.Wrap(err, "INSTALLATION FAILED")
+ return fmt.Errorf("INSTALLATION FAILED: %w", err)
}
- return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false})
+ return outfmt.Write(out, &statusPrinter{
+ release: rel,
+ debug: settings.Debug,
+ showMetadata: false,
+ hideNotes: client.HideNotes,
+ noColor: settings.ShouldDisableColor(),
+ })
},
}
addInstallFlags(cmd, cmd.Flags(), client, valueOpts)
+ // hide-secret is not available in all places the install flags are used so
+ // it is added separately
+ f := cmd.Flags()
+ f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag")
bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer)
@@ -174,11 +193,12 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal
// The true/false part is meant to reflect some legacy behavior while none is equal to "".
f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.")
f.Lookup("dry-run").NoOptDefVal = "client"
- f.BoolVar(&client.Force, "force", false, "force resource updates through a replacement strategy")
+ f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement")
+ f.BoolVar(&client.ForceReplace, "force", false, "deprecated")
+ f.MarkDeprecated("force", "use --force-replace instead")
f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during install")
- f.BoolVar(&client.Replace, "replace", false, "re-use the given name, only if that name is a deleted release which remains in the history. This is unsafe in production")
+ f.BoolVar(&client.Replace, "replace", false, "reuse the given name, only if that name is a deleted release which remains in the history. This is unsafe in production")
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
- f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout")
f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout")
f.BoolVarP(&client.GenerateName, "generate-name", "g", false, "generate the name (and omit the NAME parameter)")
f.StringVar(&client.NameTemplate, "name-template", "", "specify template used to name the release")
@@ -186,15 +206,20 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal
f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored")
f.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.Atomic, "atomic", false, "if set, the installation process deletes the installation on failure. The --wait flag will be set automatically if --atomic is used")
+ 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.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")
+ f.BoolVar(&client.SkipSchemaValidation, "skip-schema-validation", false, "if set, disables JSON schema validation")
f.StringToStringVarP(&client.Labels, "labels", "l", nil, "Labels that would be added to release metadata. Should be divided by comma.")
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")
addValueOptionsFlags(f, valueOpts)
addChartPathOptionsFlags(f, &client.ChartPathOptions)
+ AddWaitFlag(cmd, &client.WaitStrategy)
- err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
requiredArgs := 2
if client.GenerateName {
requiredArgs = 1
@@ -204,31 +229,30 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal
}
return compVersionFlag(args[requiredArgs-1], toComplete)
})
-
if err != nil {
log.Fatal(err)
}
}
func runInstall(args []string, client *action.Install, valueOpts *values.Options, out io.Writer) (*release.Release, error) {
- debug("Original chart version: %q", client.Version)
+ slog.Debug("Original chart version", "version", client.Version)
if client.Version == "" && client.Devel {
- debug("setting version to >0.0.0-0")
+ slog.Debug("setting version to >0.0.0-0")
client.Version = ">0.0.0-0"
}
- name, chart, err := client.NameAndChart(args)
+ name, chartRef, err := client.NameAndChart(args)
if err != nil {
return nil, err
}
client.ReleaseName = name
- cp, err := client.ChartPathOptions.LocateChart(chart, settings)
+ cp, err := client.LocateChart(chartRef, settings)
if err != nil {
return nil, err
}
- debug("CHART PATH: %s\n", cp)
+ slog.Debug("Chart path", "path", cp)
p := getter.All(settings)
vals, err := valueOpts.MergeValues(p)
@@ -247,7 +271,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
}
if chartRequested.Metadata.Deprecated {
- warning("This chart is deprecated")
+ slog.Warn("this chart is deprecated")
}
if req := chartRequested.Metadata.Dependencies; req != nil {
@@ -255,16 +279,16 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
// As of Helm 2.4.0, this is treated as a stopping condition:
// https://github.com/helm/helm/issues/2209
if err := action.CheckDependencies(chartRequested, req); err != nil {
- err = errors.Wrap(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies")
if client.DependencyUpdate {
man := &downloader.Manager{
Out: out,
ChartPath: cp,
- Keyring: client.ChartPathOptions.Keyring,
+ Keyring: client.Keyring,
SkipUpdate: false,
Getters: p,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
Debug: settings.Debug,
RegistryClient: client.GetRegistryClient(),
}
@@ -273,10 +297,10 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options
}
// Reload the chart with the updated Chart.lock file.
if chartRequested, err = loader.Load(cp); err != nil {
- return nil, errors.Wrap(err, "failed reloading chart after repo update")
+ return nil, fmt.Errorf("failed reloading chart after repo update: %w", err)
}
} else {
- return nil, 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)
}
}
}
@@ -314,7 +338,7 @@ func checkIfInstallable(ch *chart.Chart) error {
case "", "application":
return nil
}
- return errors.Errorf("%s charts are not installable", ch.Metadata.Type)
+ return fmt.Errorf("%s charts are not installable", ch.Metadata.Type)
}
// Provide dynamic auto-completion for the install and template commands
@@ -332,15 +356,9 @@ func compInstall(args []string, toComplete string, client *action.Install) ([]st
func validateDryRunOptionFlag(dryRunOptionFlagValue string) error {
// Validate dry-run flag value with a set of allowed value
allowedDryRunValues := []string{"false", "true", "none", "client", "server"}
- isAllowed := false
- for _, v := range allowedDryRunValues {
- if dryRunOptionFlagValue == v {
- isAllowed = true
- break
- }
- }
+ isAllowed := slices.Contains(allowedDryRunValues, dryRunOptionFlagValue)
if !isAllowed {
- return errors.New("Invalid dry-run flag. Flag must one of the following: false, true, none, client, server")
+ return errors.New("invalid dry-run flag. Flag must one of the following: false, true, none, client, server")
}
return nil
}
diff --git a/cmd/helm/install_test.go b/pkg/cmd/install_test.go
similarity index 88%
rename from cmd/helm/install_test.go
rename to pkg/cmd/install_test.go
index b34d1455c..9cd244e84 100644
--- a/cmd/helm/install_test.go
+++ b/pkg/cmd/install_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,23 +23,17 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestInstall(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"),
+ repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)),
+ )
defer srv.Stop()
- srv.WithMiddleware(http.HandlerFunc(func(w 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)
- }
- }))
-
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.Dir(srv.Root())).ServeHTTP(w, r)
}))
@@ -96,12 +90,18 @@ func TestInstall(t *testing.T) {
golden: "output/install-no-args.txt",
wantError: true,
},
- // Install, re-use name
+ // Install, reuse name
{
name: "install and replace release",
cmd: "install aeneas testdata/testcharts/empty --replace",
golden: "output/install-and-replace.txt",
},
+ // Install, take ownership
+ {
+ name: "install and replace release",
+ cmd: "install aeneas-take-ownership testdata/testcharts/empty --take-ownership",
+ golden: "output/install-and-take-ownership.txt",
+ },
// Install, with timeout
{
name: "install with a timeout",
@@ -225,6 +225,12 @@ func TestInstall(t *testing.T) {
wantError: true,
golden: "output/subchart-schema-cli-negative.txt",
},
+ // Install, values from yaml, schematized with errors but skip schema validation, expect success
+ {
+ name: "install with schema file and schematized subchart, extra values from cli, skip schema validation",
+ cmd: "install schema testdata/testcharts/chart-with-schema-and-subchart --set lastname=doe --set subchart-with-schema.age=-25 --skip-schema-validation",
+ golden: "output/schema.txt",
+ },
// Install deprecated chart
{
name: "install with warning about deprecated chart",
@@ -252,6 +258,22 @@ func TestInstall(t *testing.T) {
cmd: fmt.Sprintf("install aeneas test/reqtest --username username --password password --repository-config %s --repository-cache %s", repoFile, srv.Root()),
golden: "output/install.txt",
},
+ {
+ name: "dry-run displaying secret",
+ cmd: "install secrets testdata/testcharts/chart-with-secret --dry-run",
+ golden: "output/install-dry-run-with-secret.txt",
+ },
+ {
+ name: "dry-run hiding secret",
+ cmd: "install secrets testdata/testcharts/chart-with-secret --dry-run --hide-secret",
+ golden: "output/install-dry-run-with-secret-hidden.txt",
+ },
+ {
+ name: "hide-secret error without dry-run",
+ cmd: "install secrets testdata/testcharts/chart-with-secret --hide-secret",
+ wantError: true,
+ golden: "output/install-hide-secret.txt",
+ },
}
runTestCmd(t, tests)
diff --git a/cmd/helm/lint.go b/pkg/cmd/lint.go
similarity index 82%
rename from cmd/helm/lint.go
rename to pkg/cmd/lint.go
index 73a37b6fe..78083a7ea 100644
--- a/cmd/helm/lint.go
+++ b/pkg/cmd/lint.go
@@ -14,22 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/lint/support"
+ "helm.sh/helm/v4/pkg/action"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/lint/support"
)
var longLintHelp = `
@@ -44,19 +45,29 @@ or recommendation, it will emit [WARNING] messages.
func newLintCmd(out io.Writer) *cobra.Command {
client := action.NewLint()
valueOpts := &values.Options{}
+ var kubeVersion string
cmd := &cobra.Command{
Use: "lint PATH",
Short: "examine a chart for possible issues",
Long: longLintHelp,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
paths := []string{"."}
if len(args) > 0 {
paths = args
}
+
+ if kubeVersion != "" {
+ parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion)
+ if err != nil {
+ return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err)
+ }
+ client.KubeVersion = parsedKubeVersion
+ }
+
if client.WithSubcharts {
for _, p := range paths {
- filepath.Walk(filepath.Join(p, "charts"), func(path string, info os.FileInfo, err error) error {
+ filepath.Walk(filepath.Join(p, "charts"), func(path string, info os.FileInfo, _ error) error {
if info != nil {
if info.Name() == "Chart.yaml" {
paths = append(paths, filepath.Dir(path))
@@ -137,6 +148,8 @@ func newLintCmd(out io.Writer) *cobra.Command {
f.BoolVar(&client.Strict, "strict", false, "fail on lint warnings")
f.BoolVar(&client.WithSubcharts, "with-subcharts", false, "lint dependent charts")
f.BoolVar(&client.Quiet, "quiet", false, "print only warnings and errors")
+ f.BoolVar(&client.SkipSchemaValidation, "skip-schema-validation", false, "if set, disables JSON schema validation")
+ f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for capabilities and deprecation checks")
addValueOptionsFlags(f, valueOpts)
return cmd
diff --git a/cmd/helm/lint_test.go b/pkg/cmd/lint_test.go
similarity index 63%
rename from cmd/helm/lint_test.go
rename to pkg/cmd/lint_test.go
index 314b54c35..401c84d74 100644
--- a/cmd/helm/lint_test.go
+++ b/pkg/cmd/lint_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -63,6 +63,34 @@ func TestLintCmdWithQuietFlag(t *testing.T) {
}
+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),
+ 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),
+ golden: "output/lint-chart-with-deprecated-api-strict.txt",
+ wantError: true,
+ }, {
+ // the test builds will use the default k8sVersionMinor const in deprecations.go and capabilities.go
+ // which is "20"
+ name: "lint chart with deprecated api version without kube version",
+ cmd: fmt.Sprintf("lint %s", 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),
+ golden: "output/lint-chart-with-deprecated-api-old-k8s.txt",
+ wantError: false,
+ }}
+ runTestCmd(t, tests)
+}
+
func TestLintFileCompletion(t *testing.T) {
checkFileCompletion(t, "lint", true)
checkFileCompletion(t, "lint mypath", true) // Multiple paths can be given
diff --git a/cmd/helm/list.go b/pkg/cmd/list.go
similarity index 77%
rename from cmd/helm/list.go
rename to pkg/cmd/list.go
index 5ca3de18e..55d828036 100644
--- a/cmd/helm/list.go
+++ b/pkg/cmd/list.go
@@ -14,21 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"io"
"os"
+ "slices"
"strconv"
"github.com/gosuri/uitable"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/release"
+ coloroutput "helm.sh/helm/v4/internal/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
var listHelp = `
@@ -68,10 +70,10 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: listHelp,
Aliases: []string{"ls"},
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
if client.AllNamespaces {
- if err := cfg.Init(settings.RESTClientGetter(), "", os.Getenv("HELM_DRIVER"), debug); err != nil {
+ if err := cfg.Init(settings.RESTClientGetter(), "", os.Getenv("HELM_DRIVER")); err != nil {
return err
}
}
@@ -105,7 +107,7 @@ func newListCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
}
- return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders))
+ return outfmt.Write(out, newReleaseListWriter(results, client.TimeFormat, client.NoHeaders, settings.ShouldDisableColor()))
},
}
@@ -145,9 +147,10 @@ type releaseElement struct {
type releaseListWriter struct {
releases []releaseElement
noHeaders bool
+ noColor bool
}
-func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool) *releaseListWriter {
+func newReleaseListWriter(releases []*release.Release, timeFormat string, noHeaders bool, noColor bool) *releaseListWriter {
// Initialize the array so no results returns an empty array instead of null
elements := make([]releaseElement, 0, len(releases))
for _, r := range releases {
@@ -156,7 +159,7 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead
Namespace: r.Namespace,
Revision: strconv.Itoa(r.Version),
Status: r.Info.Status.String(),
- Chart: formatChartname(r.Chart),
+ Chart: formatChartName(r.Chart),
AppVersion: formatAppVersion(r.Chart),
}
@@ -172,26 +175,58 @@ func newReleaseListWriter(releases []*release.Release, timeFormat string, noHead
elements = append(elements, element)
}
- return &releaseListWriter{elements, noHeaders}
+ return &releaseListWriter{elements, noHeaders, noColor}
}
-func (r *releaseListWriter) WriteTable(out io.Writer) error {
+func (w *releaseListWriter) WriteTable(out io.Writer) error {
table := uitable.New()
- if !r.noHeaders {
- table.AddRow("NAME", "NAMESPACE", "REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION")
+ if !w.noHeaders {
+ table.AddRow(
+ coloroutput.ColorizeHeader("NAME", w.noColor),
+ coloroutput.ColorizeHeader("NAMESPACE", w.noColor),
+ coloroutput.ColorizeHeader("REVISION", w.noColor),
+ coloroutput.ColorizeHeader("UPDATED", w.noColor),
+ coloroutput.ColorizeHeader("STATUS", w.noColor),
+ coloroutput.ColorizeHeader("CHART", w.noColor),
+ coloroutput.ColorizeHeader("APP VERSION", w.noColor),
+ )
}
- for _, r := range r.releases {
- table.AddRow(r.Name, r.Namespace, r.Revision, r.Updated, r.Status, r.Chart, r.AppVersion)
+ for _, r := range w.releases {
+ // Parse the status string back to a release.Status to use color
+ var status release.Status
+ switch r.Status {
+ case "deployed":
+ status = release.StatusDeployed
+ case "failed":
+ status = release.StatusFailed
+ case "pending-install":
+ status = release.StatusPendingInstall
+ case "pending-upgrade":
+ status = release.StatusPendingUpgrade
+ case "pending-rollback":
+ status = release.StatusPendingRollback
+ case "uninstalling":
+ status = release.StatusUninstalling
+ case "uninstalled":
+ status = release.StatusUninstalled
+ case "superseded":
+ status = release.StatusSuperseded
+ case "unknown":
+ status = release.StatusUnknown
+ default:
+ status = release.Status(r.Status)
+ }
+ table.AddRow(r.Name, coloroutput.ColorizeNamespace(r.Namespace, w.noColor), r.Revision, r.Updated, coloroutput.ColorizeStatus(status, w.noColor), r.Chart, r.AppVersion)
}
return output.EncodeTable(out, table)
}
-func (r *releaseListWriter) WriteJSON(out io.Writer) error {
- return output.EncodeJSON(out, r.releases)
+func (w *releaseListWriter) WriteJSON(out io.Writer) error {
+ return output.EncodeJSON(out, w.releases)
}
-func (r *releaseListWriter) WriteYAML(out io.Writer) error {
- return output.EncodeYAML(out, r.releases)
+func (w *releaseListWriter) WriteYAML(out io.Writer) error {
+ return output.EncodeYAML(out, w.releases)
}
// Returns all releases from 'releases', except those with names matching 'ignoredReleases'
@@ -203,13 +238,7 @@ func filterReleases(releases []*release.Release, ignoredReleaseNames []string) [
var filteredReleases []*release.Release
for _, rel := range releases {
- found := false
- for _, ignoredName := range ignoredReleaseNames {
- if rel.Name == ignoredName {
- found = true
- break
- }
- }
+ found := slices.Contains(ignoredReleaseNames, rel.Name)
if !found {
filteredReleases = append(filteredReleases, rel)
}
diff --git a/cmd/helm/list_test.go b/pkg/cmd/list_test.go
similarity index 98%
rename from cmd/helm/list_test.go
rename to pkg/cmd/list_test.go
index 97a1e284f..82b25a768 100644
--- a/cmd/helm/list_test.go
+++ b/pkg/cmd/list_test.go
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/time"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/time"
)
func TestListCmd(t *testing.T) {
diff --git a/cmd/helm/load_plugins.go b/pkg/cmd/load_plugins.go
similarity index 70%
rename from cmd/helm/load_plugins.go
rename to pkg/cmd/load_plugins.go
index 001a084ed..5057c1033 100644
--- a/cmd/helm/load_plugins.go
+++ b/pkg/cmd/load_plugins.go
@@ -13,50 +13,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
+ "context"
"fmt"
"io"
"log"
"os"
- "os/exec"
"path/filepath"
+ "slices"
"strconv"
"strings"
- "syscall"
- "github.com/pkg/errors"
+ "helm.sh/helm/v4/internal/plugin/schema"
+
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/plugin"
+ "helm.sh/helm/v4/internal/plugin"
)
+// TODO: move pluginDynamicCompletionExecutable pkg/plugin/runtime_subprocess.go
+// any references to executables should be for [plugin.SubprocessPluginRuntime] only
+// this should also be for backwards compatibility in [plugin.Legacy] only
+//
+// TODO: for v1 make this configurable with a new CompletionCommand field for
+// [plugin.RuntimeConfigSubprocess]
const (
pluginStaticCompletionFile = "completion.yaml"
pluginDynamicCompletionExecutable = "plugin.complete"
)
-type pluginError struct {
+type PluginError struct {
error
- code int
+ Code int
}
-// loadPlugins loads plugins into the command list.
+// loadCLIPlugins loads CLI plugins into the command list.
//
// This follows a different pattern than the other commands because it has
// to inspect its environment and then add commands to the base command
// as it finds them.
-func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
-
+func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) {
// If HELM_NO_PLUGINS is set to 1, do not load plugins.
if os.Getenv("HELM_NO_PLUGINS") == "1" {
return
}
- found, err := plugin.FindPlugins(settings.PluginsDirectory)
+ dirs := filepath.SplitList(settings.PluginsDirectory)
+ descriptor := plugin.Descriptor{
+ Type: "cli/v1",
+ }
+ found, err := plugin.FindPlugins(dirs, descriptor)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err)
return
@@ -64,33 +74,69 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) {
// Now we create commands for all of these.
for _, plug := range found {
- plug := plug
- md := plug.Metadata
- if md.Usage == "" {
- md.Usage = fmt.Sprintf("the %q plugin", md.Name)
+ var use, short, long string
+ var ignoreFlags bool
+ if cliConfig, ok := plug.Metadata().Config.(*plugin.ConfigCLI); ok {
+ use = cliConfig.Usage
+ short = cliConfig.ShortHelp
+ long = cliConfig.LongHelp
+ ignoreFlags = cliConfig.IgnoreFlags
}
+ // Set defaults
+ if use == "" {
+ use = plug.Metadata().Name
+ }
+ if short == "" {
+ short = fmt.Sprintf("the %q plugin", plug.Metadata().Name)
+ }
+ // long has no default, empty is ok
+
c := &cobra.Command{
- Use: md.Name,
- Short: md.Usage,
- Long: md.Description,
+ Use: use,
+ Short: short,
+ Long: long,
RunE: func(cmd *cobra.Command, args []string) error {
u, err := processParent(cmd, args)
if err != nil {
return err
}
+ // Setup plugin environment
+ plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir())
+
+ // For CLI plugin types runtime, set extra args and settings
+ extraArgs := []string{}
+ if !ignoreFlags {
+ extraArgs = u
+ }
- // Call setupEnv before PrepareCommand because
- // PrepareCommand uses os.ExpandEnv and expects the
- // setupEnv vars.
- plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
- main, argv, prepCmdErr := plug.PrepareCommand(u)
- if prepCmdErr != nil {
- os.Stderr.WriteString(prepCmdErr.Error())
- return errors.Errorf("plugin %q exited with error", md.Name)
+ // Prepare environment
+ env := os.Environ()
+ for k, v := range settings.EnvVars() {
+ env = append(env, fmt.Sprintf("%s=%s", k, v))
}
- return callPluginExecutable(md.Name, main, argv, out)
+ // Invoke plugin
+ input := &plugin.Input{
+ Message: schema.InputMessageCLIV1{
+ ExtraArgs: extraArgs,
+ Settings: settings,
+ },
+ Env: env,
+ Stdin: os.Stdin,
+ Stdout: out,
+ Stderr: os.Stderr,
+ }
+ _, err = plug.Invoke(context.Background(), input)
+ // TODO do we want to keep execErr here?
+ if execErr, ok := err.(*plugin.InvokeExecError); ok {
+ // TODO can we replace cmd.PluginError with plugin.Error?
+ return PluginError{
+ error: execErr.Err,
+ Code: execErr.Code,
+ }
+ }
+ return err
},
// This passes all the flags to the subcommand.
DisableFlagParsing: true,
@@ -120,41 +166,13 @@ func processParent(cmd *cobra.Command, args []string) ([]string, error) {
return u, nil
}
-// This function is used to setup the environment for the plugin and then
-// call the executable specified by the parameter 'main'
-func callPluginExecutable(pluginName string, main string, argv []string, out io.Writer) error {
- env := os.Environ()
- for k, v := range settings.EnvVars() {
- env = append(env, fmt.Sprintf("%s=%s", k, v))
- }
-
- mainCmdExp := os.ExpandEnv(main)
- prog := exec.Command(mainCmdExp, argv...)
- prog.Env = env
- prog.Stdin = os.Stdin
- prog.Stdout = out
- prog.Stderr = os.Stderr
- if err := prog.Run(); err != nil {
- if eerr, ok := err.(*exec.ExitError); ok {
- os.Stderr.Write(eerr.Stderr)
- status := eerr.Sys().(syscall.WaitStatus)
- return pluginError{
- error: errors.Errorf("plugin %q exited with error", pluginName),
- code: status.ExitStatus(),
- }
- }
- return err
- }
- return nil
-}
-
// manuallyProcessArgs processes an arg array, removing special args.
//
// Returns two sets of args: known and unknown (in that order)
func manuallyProcessArgs(args []string) ([]string, []string) {
known := []string{}
unknown := []string{}
- kvargs := []string{"--kube-context", "--namespace", "-n", "--kubeconfig", "--kube-apiserver", "--kube-token", "--kube-as-user", "--kube-as-group", "--kube-ca-file", "--registry-config", "--repository-cache", "--repository-config", "--insecure-skip-tls-verify", "--tls-server-name"}
+ kvargs := []string{"--kube-context", "--namespace", "-n", "--kubeconfig", "--kube-apiserver", "--kube-token", "--kube-as-user", "--kube-as-group", "--kube-ca-file", "--registry-config", "--repository-cache", "--repository-config", "--kube-insecure-skip-tls-verify", "--kube-tls-server-name"}
knownArg := func(a string) bool {
for _, pre := range kvargs {
if strings.HasPrefix(a, pre+"=") {
@@ -165,10 +183,8 @@ func manuallyProcessArgs(args []string) ([]string, []string) {
}
isKnown := func(v string) string {
- for _, i := range kvargs {
- if i == v {
- return v
- }
+ if slices.Contains(kvargs, v) {
+ return v
}
return ""
}
@@ -204,10 +220,10 @@ type pluginCommand struct {
// loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin
// and add the dynamic completion hook to call the optional plugin.complete
-func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
+func loadCompletionForPlugin(pluginCmd *cobra.Command, plug plugin.Plugin) {
// Parse the yaml file providing the plugin's sub-commands and flags
cmds, err := loadFile(strings.Join(
- []string{plugin.Dir, pluginStaticCompletionFile}, string(filepath.Separator)))
+ []string{plug.Dir(), pluginStaticCompletionFile}, string(filepath.Separator)))
if err != nil {
// The file could be missing or invalid. No static completion for this plugin.
@@ -221,12 +237,12 @@ func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) {
// Preserve the Usage string specified for the plugin
cmds.Name = pluginCmd.Use
- addPluginCommands(plugin, pluginCmd, cmds)
+ addPluginCommands(plug, pluginCmd, cmds)
}
// addPluginCommands is a recursive method that adds each different level
// of sub-commands and flags for the plugins that have provided such information
-func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
+func addPluginCommands(plug plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) {
if cmds == nil {
return
}
@@ -249,7 +265,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug
// calling plugin.complete at every completion, which greatly simplifies
// development of plugin.complete for plugin developers.
baseCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return pluginDynamicComp(plugin, cmd, args, toComplete)
+ return pluginDynamicComp(plug, cmd, args, toComplete)
}
}
@@ -286,7 +302,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug
f.BoolP(longs[i], shorts[i], false, "")
} else {
// Create a long flag with the same name as the short flag.
- // Not a perfect solution, but its better than ignoring the extra short flags.
+ // Not a perfect solution, but it's better than ignoring the extra short flags.
f.BoolP(shorts[i], shorts[i], false, "")
}
}
@@ -301,10 +317,10 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug
// to the dynamic completion script of the plugin.
DisableFlagParsing: true,
// A Run is required for it to be a valid command without subcommands
- Run: func(cmd *cobra.Command, args []string) {},
+ Run: func(_ *cobra.Command, _ []string) {},
}
baseCmd.AddCommand(subCmd)
- addPluginCommands(plugin, subCmd, &cmd)
+ addPluginCommands(plug, subCmd, &cmd)
}
}
@@ -323,8 +339,19 @@ func loadFile(path string) (*pluginCommand, error) {
// pluginDynamicComp call the plugin.complete script of the plugin (if available)
// 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) {
- md := plug.Metadata
+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)
+ cobra.CompDebugln(fmt.Sprintf("Unsupported plugin runtime: %q", plug.Metadata().Runtime), settings.Debug)
+ return nil, cobra.ShellCompDirectiveDefault
+ }
+
+ var ignoreFlags bool
+ if cliConfig, ok := subprocessPlug.Metadata().Config.(*plugin.ConfigCLI); ok {
+ ignoreFlags = cliConfig.IgnoreFlags
+ }
u, err := processParent(cmd, args)
if err != nil {
@@ -332,28 +359,36 @@ func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, t
}
// We will call the dynamic completion script of the plugin
- main := strings.Join([]string{plug.Dir, pluginDynamicCompletionExecutable}, string(filepath.Separator))
+ main := strings.Join([]string{plug.Dir(), pluginDynamicCompletionExecutable}, string(filepath.Separator))
// We must include all sub-commands passed on the command-line.
// To do that, we pass-in the entire CommandPath, except the first two elements
// which are 'helm' and 'pluginName'.
argv := strings.Split(cmd.CommandPath(), " ")[2:]
- if !md.IgnoreFlags {
+ if !ignoreFlags {
argv = append(argv, u...)
argv = append(argv, toComplete)
}
- plugin.SetupPluginEnv(settings, md.Name, plug.Dir)
+ plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir())
cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug)
buf := new(bytes.Buffer)
- if err := callPluginExecutable(md.Name, main, argv, buf); err != nil {
+
+ // Prepare environment
+ env := os.Environ()
+ for k, v := range settings.EnvVars() {
+ env = append(env, fmt.Sprintf("%s=%s", k, v))
+ }
+
+ // For subprocess runtime, use InvokeWithEnv for dynamic completion
+ if err := subprocessPlug.InvokeWithEnv(main, argv, env, nil, buf, buf); err != nil {
// The dynamic completion file is optional for a plugin, so this error is ok.
cobra.CompDebugln(fmt.Sprintf("Unable to call %s: %v", main, err.Error()), settings.Debug)
return nil, cobra.ShellCompDirectiveDefault
}
var completions []string
- for _, comp := range strings.Split(buf.String(), "\n") {
+ for comp := range strings.SplitSeq(buf.String(), "\n") {
// Remove any empty lines
if len(comp) > 0 {
completions = append(completions, comp)
diff --git a/cmd/helm/package.go b/pkg/cmd/package.go
similarity index 71%
rename from cmd/helm/package.go
rename to pkg/cmd/package.go
index 822d3d56a..fc56e936a 100644
--- a/cmd/helm/package.go
+++ b/pkg/cmd/package.go
@@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
"os"
"path/filepath"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
)
const packageDesc = `
@@ -47,7 +47,7 @@ If '--keyring' is not specified, Helm usually defaults to the public keyring
unless your environment is otherwise configured.
`
-func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
+func newPackageCmd(out io.Writer) *cobra.Command {
client := action.NewPackage()
valueOpts := &values.Options{}
@@ -55,9 +55,9 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Use: "package [CHART_PATH] [...]",
Short: "package a chart directory into a chart archive",
Long: packageDesc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
if len(args) == 0 {
- return errors.Errorf("need at least one argument, the path to the chart")
+ return fmt.Errorf("need at least one argument, the path to the chart")
}
if client.Sign {
if client.Key == "" {
@@ -75,6 +75,12 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return err
}
+ registryClient, err := newRegistryClient(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)
+ }
+
for i := 0; i < len(args); i++ {
path, err := filepath.Abs(args[i])
if err != nil {
@@ -91,9 +97,10 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Keyring: client.Keyring,
Getters: p,
Debug: settings.Debug,
- RegistryClient: cfg.RegistryClient,
+ RegistryClient: registryClient,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
}
if err := downloadManager.Update(); err != nil {
@@ -119,6 +126,13 @@ func newPackageCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.StringVar(&client.AppVersion, "app-version", "", "set the appVersion on the chart to this version")
f.StringVarP(&client.Destination, "destination", "d", ".", "location to write the chart.")
f.BoolVarP(&client.DependencyUpdate, "dependency-update", "u", false, `update dependencies from "Chart.yaml" to dir "charts/" before packaging`)
+ f.StringVar(&client.Username, "username", "", "chart repository username where to locate the requested chart")
+ f.StringVar(&client.Password, "password", "", "chart repository password where to locate the requested chart")
+ f.StringVar(&client.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file")
+ f.StringVar(&client.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file")
+ f.BoolVar(&client.InsecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart download")
+ f.BoolVar(&client.PlainHTTP, "plain-http", false, "use insecure HTTP connections for the chart download")
+ f.StringVar(&client.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
return cmd
}
diff --git a/cmd/helm/package_test.go b/pkg/cmd/package_test.go
similarity index 95%
rename from cmd/helm/package_test.go
rename to pkg/cmd/package_test.go
index 9093b510c..db4a2523a 100644
--- a/cmd/helm/package_test.go
+++ b/pkg/cmd/package_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,9 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ "helm.sh/helm/v4/internal/test/ensure"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
func TestPackage(t *testing.T) {
@@ -110,10 +111,10 @@ func TestPackage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- cachePath := t.TempDir()
- defer testChdir(t, cachePath)()
+ t.Chdir(t.TempDir())
+ ensure.HelmHome(t)
- if err := os.MkdirAll("toot", 0777); err != nil {
+ if err := os.MkdirAll("toot", 0o777); err != nil {
t.Fatal(err)
}
diff --git a/cmd/helm/plugin.go b/pkg/cmd/plugin.go
similarity index 57%
rename from cmd/helm/plugin.go
rename to pkg/cmd/plugin.go
index 8e1044f54..b03000ad4 100644
--- a/cmd/helm/plugin.go
+++ b/pkg/cmd/plugin.go
@@ -13,17 +13,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
- "os"
- "os/exec"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/pkg/plugin"
+ "helm.sh/helm/v4/internal/plugin"
)
const pluginHelp = `
@@ -46,27 +43,12 @@ func newPluginCmd(out io.Writer) *cobra.Command {
}
// runHook will execute a plugin hook.
-func runHook(p *plugin.Plugin, event string) error {
- hook := p.Metadata.Hooks[event]
- if hook == "" {
- return nil
+func runHook(p plugin.Plugin, event string) error {
+ pluginHook, ok := p.(plugin.PluginHook)
+ if ok {
+ plugin.SetupPluginEnv(settings, p.Metadata().Name, p.Dir())
+ return pluginHook.InvokeHook(event)
}
- prog := exec.Command("sh", "-c", hook)
- // TODO make this work on windows
- // I think its ... ¯\_(ツ)_/¯
- // prog := exec.Command("cmd", "/C", p.Metadata.Hooks.Install())
-
- debug("running %s hook: %s", event, prog)
-
- plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir)
- prog.Stdout, prog.Stderr = os.Stdout, os.Stderr
- if err := prog.Run(); err != nil {
- if eerr, ok := err.(*exec.ExitError); ok {
- os.Stderr.Write(eerr.Stderr)
- return errors.Errorf("plugin %s hook for %q exited with error", event, p.Metadata.Name)
- }
- return err
- }
return nil
}
diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go
new file mode 100644
index 000000000..960404a76
--- /dev/null
+++ b/pkg/cmd/plugin_install.go
@@ -0,0 +1,132 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "helm.sh/helm/v4/internal/plugin"
+ "helm.sh/helm/v4/internal/plugin/installer"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/registry"
+)
+
+type pluginInstallOptions struct {
+ source string
+ version string
+ // OCI-specific options
+ certFile string
+ keyFile string
+ caFile string
+ insecureSkipTLSverify bool
+ plainHTTP bool
+ password string
+ username string
+}
+
+const pluginInstallDesc = `
+This command allows you to install a plugin from a url to a VCS repo or a local path.
+`
+
+func newPluginInstallCmd(out io.Writer) *cobra.Command {
+ o := &pluginInstallOptions{}
+ cmd := &cobra.Command{
+ Use: "install [options] ",
+ Short: "install a Helm plugin",
+ Long: pluginInstallDesc,
+ Aliases: []string{"add"},
+ Args: require.ExactArgs(1),
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
+ if len(args) == 0 {
+ // We do file completion, in case the plugin is local
+ return nil, cobra.ShellCompDirectiveDefault
+ }
+ // No more completion once the plugin path has been specified
+ return noMoreArgsComp()
+ },
+ PreRunE: func(_ *cobra.Command, args []string) error {
+ return o.complete(args)
+ },
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return o.run(out)
+ },
+ }
+ cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed")
+
+ // Add OCI-specific flags
+ cmd.Flags().StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file")
+ cmd.Flags().StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file")
+ cmd.Flags().StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
+ cmd.Flags().BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the plugin download")
+ cmd.Flags().BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the plugin download")
+ cmd.Flags().StringVar(&o.username, "username", "", "registry username")
+ cmd.Flags().StringVar(&o.password, "password", "", "registry password")
+ return cmd
+}
+
+func (o *pluginInstallOptions) complete(args []string) error {
+ o.source = args[0]
+ return nil
+}
+
+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)) {
+ // Build getter options for OCI
+ options := []getter.Option{
+ getter.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile),
+ getter.WithInsecureSkipVerifyTLS(o.insecureSkipTLSverify),
+ getter.WithPlainHTTP(o.plainHTTP),
+ getter.WithBasicAuth(o.username, o.password),
+ }
+
+ return installer.NewOCIInstaller(o.source, options...)
+ }
+
+ // For non-OCI sources, use the original logic
+ return installer.NewForSource(o.source, o.version)
+}
+
+func (o *pluginInstallOptions) run(out io.Writer) error {
+ installer.Debug = settings.Debug
+
+ i, err := o.newInstallerForSource()
+ if err != nil {
+ return err
+ }
+ if err := installer.Install(i); err != nil {
+ return err
+ }
+
+ slog.Debug("loading plugin", "path", i.Path())
+ p, err := plugin.LoadDir(i.Path())
+ if err != nil {
+ return fmt.Errorf("plugin is installed but unusable: %w", err)
+ }
+
+ if err := runHook(p, plugin.Install); err != nil {
+ return err
+ }
+
+ fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata().Name)
+ return nil
+}
diff --git a/pkg/cmd/plugin_list.go b/pkg/cmd/plugin_list.go
new file mode 100644
index 000000000..31a76330d
--- /dev/null
+++ b/pkg/cmd/plugin_list.go
@@ -0,0 +1,108 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "slices"
+
+ "github.com/gosuri/uitable"
+ "github.com/spf13/cobra"
+
+ "helm.sh/helm/v4/internal/plugin"
+)
+
+func newPluginListCmd(out io.Writer) *cobra.Command {
+ var pluginType string
+ cmd := &cobra.Command{
+ Use: "list",
+ Aliases: []string{"ls"},
+ Short: "list installed Helm plugins",
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ slog.Debug("pluginDirs", "directory", settings.PluginsDirectory)
+ dirs := filepath.SplitList(settings.PluginsDirectory)
+ descriptor := plugin.Descriptor{
+ Type: pluginType,
+ }
+ plugins, err := plugin.FindPlugins(dirs, descriptor)
+ if err != nil {
+ return err
+ }
+
+ table := uitable.New()
+ table.AddRow("NAME", "VERSION", "TYPE", "APIVERSION", "SOURCE")
+ for _, p := range plugins {
+ m := p.Metadata()
+ sourceURL := m.SourceURL
+ if sourceURL == "" {
+ sourceURL = "unknown"
+ }
+ table.AddRow(m.Name, m.Version, m.Type, m.APIVersion, sourceURL)
+ }
+ fmt.Fprintln(out, table)
+ return nil
+ },
+ }
+
+ f := cmd.Flags()
+ f.StringVar(&pluginType, "type", "", "Plugin type")
+
+ return cmd
+}
+
+// Returns all plugins from plugins, except those with names matching ignoredPluginNames
+func filterPlugins(plugins []plugin.Plugin, ignoredPluginNames []string) []plugin.Plugin {
+ // if ignoredPluginNames is nil or empty, just return plugins
+ if len(ignoredPluginNames) == 0 {
+ return plugins
+ }
+
+ var filteredPlugins []plugin.Plugin
+ for _, plugin := range plugins {
+ found := slices.Contains(ignoredPluginNames, plugin.Metadata().Name)
+ if !found {
+ filteredPlugins = append(filteredPlugins, plugin)
+ }
+ }
+
+ return filteredPlugins
+}
+
+// Provide dynamic auto-completion for plugin names
+func compListPlugins(_ string, ignoredPluginNames []string) []string {
+ var pNames []string
+ dirs := filepath.SplitList(settings.PluginsDirectory)
+ descriptor := plugin.Descriptor{
+ Type: "cli/v1",
+ }
+ plugins, err := plugin.FindPlugins(dirs, descriptor)
+ if err == nil && len(plugins) > 0 {
+ filteredPlugins := filterPlugins(plugins, ignoredPluginNames)
+ for _, p := range filteredPlugins {
+ m := p.Metadata()
+ var shortHelp string
+ if config, ok := m.Config.(*plugin.ConfigCLI); ok {
+ shortHelp = config.ShortHelp
+ }
+ pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata().Name, shortHelp))
+ }
+ }
+ return pNames
+}
diff --git a/cmd/helm/plugin_test.go b/pkg/cmd/plugin_test.go
similarity index 87%
rename from cmd/helm/plugin_test.go
rename to pkg/cmd/plugin_test.go
index e13ad26fb..b476b80d2 100644
--- a/cmd/helm/plugin_test.go
+++ b/pkg/cmd/plugin_test.go
@@ -13,20 +13,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
"os"
"runtime"
- "sort"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestManuallyProcessArgs(t *testing.T) {
@@ -79,10 +80,9 @@ func TestManuallyProcessArgs(t *testing.T) {
t.Errorf("expected unknown flag %d to be %q, got %q", i, expectUnknown[i], k)
}
}
-
}
-func TestLoadPlugins(t *testing.T) {
+func TestLoadCLIPlugins(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml"
settings.RepositoryCache = "testdata/helmhome/helm/repository"
@@ -91,7 +91,7 @@ func TestLoadPlugins(t *testing.T) {
out bytes.Buffer
cmd cobra.Command
)
- loadPlugins(&cmd, &out)
+ loadCLIPlugins(&cmd, &out)
envs := strings.Join([]string{
"fullenv",
@@ -120,9 +120,7 @@ func TestLoadPlugins(t *testing.T) {
plugins := cmd.Commands()
- if len(plugins) != len(tests) {
- t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins))
- }
+ require.Len(t, plugins, len(tests), "Expected %d plugins, got %d", len(tests), len(plugins))
for i := 0; i < len(plugins); i++ {
out.Reset()
@@ -143,20 +141,18 @@ func TestLoadPlugins(t *testing.T) {
if runtime.GOOS != "windows" {
if err := pp.RunE(pp, tt.args); err != nil {
if tt.code > 0 {
- perr, ok := err.(pluginError)
+ perr, ok := err.(PluginError)
if !ok {
t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err)
}
- if perr.code != tt.code {
- t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.code)
+ if perr.Code != tt.code {
+ t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.Code)
}
} else {
t.Errorf("Error running %s: %+v", tt.use, err)
}
}
- if out.String() != tt.expect {
- t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String())
- }
+ assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use)
}
}
}
@@ -170,7 +166,7 @@ func TestLoadPluginsWithSpace(t *testing.T) {
out bytes.Buffer
cmd cobra.Command
)
- loadPlugins(&cmd, &out)
+ loadCLIPlugins(&cmd, &out)
envs := strings.Join([]string{
"fullenv",
@@ -218,20 +214,18 @@ func TestLoadPluginsWithSpace(t *testing.T) {
if runtime.GOOS != "windows" {
if err := pp.RunE(pp, tt.args); err != nil {
if tt.code > 0 {
- perr, ok := err.(pluginError)
+ perr, ok := err.(PluginError)
if !ok {
t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err)
}
- if perr.code != tt.code {
- t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.code)
+ if perr.Code != tt.code {
+ t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.Code)
}
} else {
t.Errorf("Error running %s: %+v", tt.use, err)
}
}
- if out.String() != tt.expect {
- t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String())
- }
+ assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use)
}
}
}
@@ -243,7 +237,7 @@ type staticCompletionDetails struct {
next []staticCompletionDetails
}
-func TestLoadPluginsForCompletion(t *testing.T) {
+func TestLoadCLIPluginsForCompletion(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
var out bytes.Buffer
@@ -251,8 +245,7 @@ func TestLoadPluginsForCompletion(t *testing.T) {
cmd := &cobra.Command{
Use: "completion",
}
-
- loadPlugins(cmd, &out)
+ loadCLIPlugins(cmd, &out)
tests := []staticCompletionDetails{
{"args", []string{}, []string{}, []staticCompletionDetails{}},
@@ -276,30 +269,18 @@ func TestLoadPluginsForCompletion(t *testing.T) {
}
func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompletionDetails) {
- if len(plugins) != len(tests) {
- t.Fatalf("Expected commands %v, got %v", tests, plugins)
- }
+ t.Helper()
+ require.Len(t, plugins, len(tests), "Expected commands %v, got %v", tests, plugins)
- for i := 0; i < len(plugins); i++ {
+ is := assert.New(t)
+ for i := range plugins {
pp := plugins[i]
tt := tests[i]
- if pp.Use != tt.use {
- t.Errorf("%s: Expected Use=%q, got %q", pp.Name(), tt.use, pp.Use)
- }
+ is.Equal(pp.Use, tt.use, "Expected Use=%q, got %q", tt.use, pp.Use)
targs := tt.validArgs
pargs := pp.ValidArgs
- if len(targs) != len(pargs) {
- t.Fatalf("%s: expected args %v, got %v", pp.Name(), targs, pargs)
- }
-
- sort.Strings(targs)
- sort.Strings(pargs)
- for j := range targs {
- if targs[j] != pargs[j] {
- t.Errorf("%s: expected validArg=%q, got %q", pp.Name(), targs[j], pargs[j])
- }
- }
+ is.ElementsMatch(targs, pargs)
tflags := tt.flags
var pflags []string
@@ -309,24 +290,14 @@ func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompleti
pflags = append(pflags, flag.Shorthand)
}
})
- if len(tflags) != len(pflags) {
- t.Fatalf("%s: expected flags %v, got %v", pp.Name(), tflags, pflags)
- }
+ is.ElementsMatch(tflags, pflags)
- sort.Strings(tflags)
- sort.Strings(pflags)
- for j := range tflags {
- if tflags[j] != pflags[j] {
- t.Errorf("%s: expected flag=%q, got %q", pp.Name(), tflags[j], pflags[j])
- }
- }
// Check the next level
checkCommand(t, pp.Commands(), tt.next)
}
}
func TestPluginDynamicCompletion(t *testing.T) {
-
tests := []cmdTestCase{{
name: "completion for plugin",
cmd: "__complete args ''",
@@ -359,15 +330,15 @@ func TestPluginDynamicCompletion(t *testing.T) {
}
}
-func TestLoadPlugins_HelmNoPlugins(t *testing.T) {
+func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) {
settings.PluginsDirectory = "testdata/helmhome/helm/plugins"
settings.RepositoryConfig = "testdata/helmhome/helm/repository"
- os.Setenv("HELM_NO_PLUGINS", "1")
+ t.Setenv("HELM_NO_PLUGINS", "1")
out := bytes.NewBuffer(nil)
cmd := &cobra.Command{}
- loadPlugins(cmd, out)
+ loadCLIPlugins(cmd, out)
plugins := cmd.Commands()
if len(plugins) != 0 {
@@ -376,7 +347,6 @@ func TestLoadPlugins_HelmNoPlugins(t *testing.T) {
}
func TestPluginCmdsCompletion(t *testing.T) {
-
tests := []cmdTestCase{{
name: "completion for plugin update",
cmd: "__complete plugin update ''",
diff --git a/pkg/cmd/plugin_uninstall.go b/pkg/cmd/plugin_uninstall.go
new file mode 100644
index 000000000..85eb46219
--- /dev/null
+++ b/pkg/cmd/plugin_uninstall.go
@@ -0,0 +1,132 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "github.com/spf13/cobra"
+
+ "helm.sh/helm/v4/internal/plugin"
+)
+
+type pluginUninstallOptions struct {
+ names []string
+}
+
+func newPluginUninstallCmd(out io.Writer) *cobra.Command {
+ o := &pluginUninstallOptions{}
+
+ cmd := &cobra.Command{
+ Use: "uninstall ...",
+ Aliases: []string{"rm", "remove"},
+ Short: "uninstall one or more Helm plugins",
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp
+ },
+ PreRunE: func(_ *cobra.Command, args []string) error {
+ return o.complete(args)
+ },
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return o.run(out)
+ },
+ }
+ return cmd
+}
+
+func (o *pluginUninstallOptions) complete(args []string) error {
+ if len(args) == 0 {
+ return errors.New("please provide plugin name to uninstall")
+ }
+ o.names = args
+ return nil
+}
+
+func (o *pluginUninstallOptions) run(out io.Writer) error {
+ slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory)
+ plugins, err := plugin.LoadAll(settings.PluginsDirectory)
+ if err != nil {
+ return err
+ }
+ var errorPlugins []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))
+ } else {
+ fmt.Fprintf(out, "Uninstalled plugin: %s\n", name)
+ }
+ } else {
+ errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name))
+ }
+ }
+ if len(errorPlugins) > 0 {
+ return errors.Join(errorPlugins...)
+ }
+ return nil
+}
+
+func uninstallPlugin(p plugin.Plugin) error {
+ if err := os.RemoveAll(p.Dir()); err != nil {
+ return err
+ }
+
+ // Clean up versioned tarball and provenance files from HELM_PLUGINS directory
+ // These files are saved with pattern: PLUGIN_NAME-VERSION.tgz and PLUGIN_NAME-VERSION.tgz.prov
+ pluginName := p.Metadata().Name
+ pluginVersion := p.Metadata().Version
+ pluginsDir := settings.PluginsDirectory
+
+ // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
+ if pluginVersion != "" {
+ versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion)
+
+ // Remove tarball file
+ tarballPath := filepath.Join(pluginsDir, versionedBasename)
+ if _, err := os.Stat(tarballPath); err == nil {
+ slog.Debug("removing versioned tarball", "path", tarballPath)
+ if err := os.Remove(tarballPath); err != nil {
+ slog.Debug("failed to remove tarball file", "path", tarballPath, "error", err)
+ }
+ }
+
+ // Remove provenance file
+ provPath := filepath.Join(pluginsDir, versionedBasename+".prov")
+ if _, err := os.Stat(provPath); err == nil {
+ slog.Debug("removing versioned provenance", "path", provPath)
+ if err := os.Remove(provPath); err != nil {
+ slog.Debug("failed to remove provenance file", "path", provPath, "error", err)
+ }
+ }
+ }
+
+ return runHook(p, plugin.Delete)
+}
+
+// TODO should this be in pkg/plugin/loader.go?
+func findPlugin(plugins []plugin.Plugin, name string) plugin.Plugin {
+ for _, p := range plugins {
+ if p.Metadata().Name == name {
+ return p
+ }
+ }
+ return nil
+}
diff --git a/pkg/cmd/plugin_uninstall_test.go b/pkg/cmd/plugin_uninstall_test.go
new file mode 100644
index 000000000..93d4dc8a8
--- /dev/null
+++ b/pkg/cmd/plugin_uninstall_test.go
@@ -0,0 +1,146 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "helm.sh/helm/v4/internal/plugin"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/cli"
+)
+
+func TestPluginUninstallCleansUpVersionedFiles(t *testing.T) {
+ ensure.HelmHome(t)
+
+ // Create a fake plugin directory structure in a temp directory
+ pluginsDir := t.TempDir()
+ t.Setenv("HELM_PLUGINS", pluginsDir)
+
+ // Create a new settings instance that will pick up the environment variable
+ testSettings := cli.New()
+ pluginName := "test-plugin"
+
+ // Create plugin directory
+ pluginDir := filepath.Join(pluginsDir, pluginName)
+ if err := os.MkdirAll(pluginDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create plugin.yaml
+ pluginYAML := `name: test-plugin
+version: 1.2.3
+description: Test plugin
+command: $HELM_PLUGIN_DIR/test-plugin
+`
+ if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create versioned tarball and provenance files
+ tarballFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz")
+ provFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz.prov")
+ otherVersionTarball := filepath.Join(pluginsDir, "test-plugin-2.0.0.tgz")
+
+ if err := os.WriteFile(tarballFile, []byte("fake tarball"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(provFile, []byte("fake provenance"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ // Create another version that should NOT be removed
+ if err := os.WriteFile(otherVersionTarball, []byte("other version"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Load the plugin
+ p, err := plugin.LoadDir(pluginDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a test uninstall function that uses our test settings
+ testUninstallPlugin := func(plugin plugin.Plugin) error {
+ if err := os.RemoveAll(plugin.Dir()); err != nil {
+ return err
+ }
+
+ // Clean up versioned tarball and provenance files from test HELM_PLUGINS directory
+ pluginName := plugin.Metadata().Name
+ pluginVersion := plugin.Metadata().Version
+ testPluginsDir := testSettings.PluginsDirectory
+
+ // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
+ if pluginVersion != "" {
+ versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion)
+
+ // Remove tarball file
+ tarballPath := filepath.Join(testPluginsDir, versionedBasename)
+ if _, err := os.Stat(tarballPath); err == nil {
+ if err := os.Remove(tarballPath); err != nil {
+ t.Logf("failed to remove tarball file: %v", err)
+ }
+ }
+
+ // Remove provenance file
+ provPath := filepath.Join(testPluginsDir, versionedBasename+".prov")
+ if _, err := os.Stat(provPath); err == nil {
+ if err := os.Remove(provPath); err != nil {
+ t.Logf("failed to remove provenance file: %v", err)
+ }
+ }
+ }
+
+ // Skip runHook in test
+ return nil
+ }
+
+ // Verify files exist before uninstall
+ if _, err := os.Stat(tarballFile); os.IsNotExist(err) {
+ t.Fatal("tarball file should exist before uninstall")
+ }
+ if _, err := os.Stat(provFile); os.IsNotExist(err) {
+ t.Fatal("provenance file should exist before uninstall")
+ }
+ if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
+ t.Fatal("other version tarball should exist before uninstall")
+ }
+
+ // Uninstall the plugin
+ if err := testUninstallPlugin(p); err != nil {
+ t.Fatal(err)
+ }
+
+ // Verify plugin directory is removed
+ if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
+ t.Error("plugin directory should be removed")
+ }
+
+ // Verify only exact version files are removed
+ if _, err := os.Stat(tarballFile); !os.IsNotExist(err) {
+ t.Error("versioned tarball file should be removed")
+ }
+ if _, err := os.Stat(provFile); !os.IsNotExist(err) {
+ t.Error("versioned provenance file should be removed")
+ }
+ // Verify other version files are NOT removed
+ if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
+ t.Error("other version tarball should NOT be removed")
+ }
+}
diff --git a/cmd/helm/plugin_update.go b/pkg/cmd/plugin_update.go
similarity index 69%
rename from cmd/helm/plugin_update.go
rename to pkg/cmd/plugin_update.go
index 4515acdbb..c6d4b8530 100644
--- a/cmd/helm/plugin_update.go
+++ b/pkg/cmd/plugin_update.go
@@ -13,19 +13,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
+ "log/slog"
"path/filepath"
- "strings"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/pkg/plugin"
- "helm.sh/helm/v3/pkg/plugin/installer"
+ "helm.sh/helm/v4/internal/plugin"
+ "helm.sh/helm/v4/internal/plugin/installer"
)
type pluginUpdateOptions struct {
@@ -39,13 +39,13 @@ func newPluginUpdateCmd(out io.Writer) *cobra.Command {
Use: "update ...",
Aliases: []string{"up"},
Short: "update one or more Helm plugins",
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compListPlugins(toComplete, args), cobra.ShellCompDirectiveNoFileComp
},
- PreRunE: func(cmd *cobra.Command, args []string) error {
+ PreRunE: func(_ *cobra.Command, args []string) error {
return o.complete(args)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return o.run(out)
},
}
@@ -62,32 +62,32 @@ func (o *pluginUpdateOptions) complete(args []string) error {
func (o *pluginUpdateOptions) run(out io.Writer) error {
installer.Debug = settings.Debug
- debug("loading installed plugins from %s", settings.PluginsDirectory)
- plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
+ slog.Debug("loading installed plugins", "path", settings.PluginsDirectory)
+ plugins, err := plugin.LoadAll(settings.PluginsDirectory)
if err != nil {
return err
}
- var errorPlugins []string
+ var errorPlugins []error
for _, name := range o.names {
if found := findPlugin(plugins, name); found != nil {
if err := updatePlugin(found); err != nil {
- errorPlugins = append(errorPlugins, fmt.Sprintf("Failed to update plugin %s, got error (%v)", name, err))
+ errorPlugins = append(errorPlugins, fmt.Errorf("failed to update plugin %s, got error (%v)", name, err))
} else {
fmt.Fprintf(out, "Updated plugin: %s\n", name)
}
} else {
- errorPlugins = append(errorPlugins, fmt.Sprintf("Plugin: %s not found", name))
+ errorPlugins = append(errorPlugins, fmt.Errorf("plugin: %s not found", name))
}
}
if len(errorPlugins) > 0 {
- return errors.Errorf(strings.Join(errorPlugins, "\n"))
+ return errors.Join(errorPlugins...)
}
return nil
}
-func updatePlugin(p *plugin.Plugin) error {
- exactLocation, err := filepath.EvalSymlinks(p.Dir)
+func updatePlugin(p plugin.Plugin) error {
+ exactLocation, err := filepath.EvalSymlinks(p.Dir())
if err != nil {
return err
}
@@ -104,7 +104,7 @@ func updatePlugin(p *plugin.Plugin) error {
return err
}
- debug("loading plugin from %s", i.Path())
+ slog.Debug("loading plugin", "path", i.Path())
updatedPlugin, err := plugin.LoadDir(i.Path())
if err != nil {
return err
diff --git a/cmd/helm/printer.go b/pkg/cmd/printer.go
similarity index 98%
rename from cmd/helm/printer.go
rename to pkg/cmd/printer.go
index 7cf7bf994..30238f5bb 100644
--- a/cmd/helm/printer.go
+++ b/pkg/cmd/printer.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
diff --git a/pkg/cmd/profiling.go b/pkg/cmd/profiling.go
new file mode 100644
index 000000000..45e7b9342
--- /dev/null
+++ b/pkg/cmd/profiling.go
@@ -0,0 +1,91 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "runtime"
+ "runtime/pprof"
+)
+
+var (
+ cpuProfileFile *os.File
+ cpuProfilePath string
+ memProfilePath string
+)
+
+func init() {
+ cpuProfilePath = os.Getenv("HELM_PPROF_CPU_PROFILE")
+ memProfilePath = os.Getenv("HELM_PPROF_MEM_PROFILE")
+}
+
+// startProfiling starts profiling CPU usage if HELM_PPROF_CPU_PROFILE is set
+// to a file path. It returns an error if the file could not be created or
+// CPU profiling could not be started.
+func startProfiling() error {
+ if cpuProfilePath != "" {
+ var err error
+ cpuProfileFile, err = os.Create(cpuProfilePath)
+ if err != nil {
+ return fmt.Errorf("could not create CPU profile: %w", err)
+ }
+ if err := pprof.StartCPUProfile(cpuProfileFile); err != nil {
+ cpuProfileFile.Close()
+ cpuProfileFile = nil
+ return fmt.Errorf("could not start CPU profile: %w", err)
+ }
+ }
+ return nil
+}
+
+// stopProfiling stops profiling CPU and memory usage.
+// It writes memory profile to the file path specified in HELM_PPROF_MEM_PROFILE
+// environment variable.
+func stopProfiling() error {
+ errs := []error{}
+
+ // Stop CPU profiling if it was started
+ if cpuProfileFile != nil {
+ pprof.StopCPUProfile()
+ err := cpuProfileFile.Close()
+ if err != nil {
+ errs = append(errs, err)
+ }
+ cpuProfileFile = nil
+ }
+
+ if memProfilePath != "" {
+ f, err := os.Create(memProfilePath)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ defer f.Close()
+
+ runtime.GC() // get up-to-date statistics
+ if err := pprof.WriteHeapProfile(f); err != nil {
+ errs = append(errs, err)
+ }
+ }
+
+ if err := errors.Join(errs...); err != nil {
+ return fmt.Errorf("error(s) while stopping profiling: %w", err)
+ }
+
+ return nil
+}
diff --git a/cmd/helm/pull.go b/pkg/cmd/pull.go
similarity index 84%
rename from cmd/helm/pull.go
rename to pkg/cmd/pull.go
index af3092aff..e3d93c049 100644
--- a/cmd/helm/pull.go
+++ b/pkg/cmd/pull.go
@@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"io"
"log"
+ "log/slog"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const pullDesc = `
@@ -43,7 +44,7 @@ result in an error, and the chart will not be saved locally.
`
func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
- client := action.NewPullWithOpts(action.WithConfig(cfg))
+ client := action.NewPull(action.WithConfig(cfg))
cmd := &cobra.Command{
Use: "pull [chart URL | repo/chartname] [...]",
@@ -51,21 +52,21 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Aliases: []string{"fetch"},
Long: pullDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return compListCharts(toComplete, false)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.Settings = settings
if client.Version == "" && client.Devel {
- debug("setting version to >0.0.0-0")
+ slog.Debug("setting version to >0.0.0-0")
client.Version = ">0.0.0-0"
}
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
- client.InsecureSkipTLSverify, client.PlainHTTP)
+ client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
@@ -90,7 +91,7 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.StringVarP(&client.DestDir, "destination", "d", ".", "location to write the chart. If this and untardir are specified, untardir is appended to this")
addChartPathOptionsFlags(f, &client.ChartPathOptions)
- err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
diff --git a/cmd/helm/pull_test.go b/pkg/cmd/pull_test.go
similarity index 73%
rename from cmd/helm/pull_test.go
rename to pkg/cmd/pull_test.go
index 41ac237f4..c3156c394 100644
--- a/cmd/helm/pull_test.go
+++ b/pkg/cmd/pull_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -24,14 +24,14 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestPullCmd(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"),
+ )
defer srv.Stop()
ociSrv, err := repotest.NewOCIServer(t, srv.Root())
@@ -147,6 +147,18 @@ func TestPullCmd(t *testing.T) {
failExpect: "Failed to fetch chart version",
wantError: true,
},
+ {
+ name: "Chart fetch using repo URL with untardir",
+ args: "signtest --version=0.1.0 --untar --untardir repo-url-test --repo " + srv.URL(),
+ expectFile: "./signtest",
+ expectDir: true,
+ },
+ {
+ name: "Chart fetch using repo URL with untardir and previous pull",
+ args: "signtest --version=0.1.0 --untar --untardir repo-url-test --repo " + srv.URL(),
+ failExpect: "failed to untar",
+ wantError: true,
+ },
{
name: "Fetch OCI Chart",
args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart --version 0.1.0", ociSrv.RegistryURL),
@@ -183,27 +195,35 @@ func TestPullCmd(t *testing.T) {
wantError: true,
},
{
- name: "Fail fetching OCI chart without version specified",
- args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL),
- wantErrorMsg: "Error: --version flag is explicitly required for OCI registries",
- wantError: true,
+ name: "Fetching OCI chart without version option specified",
+ args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0", ociSrv.RegistryURL),
+ expectFile: "./oci-dependent-chart-0.1.0.tgz",
},
{
- name: "Fail fetching OCI chart without version specified",
- args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL),
- wantError: true,
+ name: "Fetching OCI chart with version specified",
+ args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.1.0 --version 0.1.0", ociSrv.RegistryURL),
+ expectFile: "./oci-dependent-chart-0.1.0.tgz",
+ },
+ {
+ name: "Fail fetching OCI chart with version mismatch",
+ args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.2.0 --version 0.1.0", ociSrv.RegistryURL),
+ wantErrorMsg: "Error: chart reference and version mismatch: 0.2.0 is not 0.1.0",
+ wantError: true,
},
}
+ contentCache := t.TempDir()
+
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outdir := srv.Root()
- cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s",
+ cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s --content-cache %s --plain-http",
tt.args,
outdir,
filepath.Join(outdir, "repositories.yaml"),
outdir,
filepath.Join(outdir, "config.json"),
+ contentCache,
)
// Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182
if tt.existFile != "" {
@@ -251,19 +271,85 @@ func TestPullCmd(t *testing.T) {
}
}
-func TestPullWithCredentialsCmd(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*")
- if err != nil {
- t.Fatal(err)
+// runPullTests is a helper function to run pull command tests with common logic
+func runPullTests(t *testing.T, tests []struct {
+ name string
+ args string
+ existFile string
+ existDir string
+ wantError bool
+ wantErrorMsg string
+ expectFile string
+ expectDir bool
+}, outdir string, additionalFlags string) {
+ t.Helper()
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s %s",
+ tt.args,
+ outdir,
+ filepath.Join(outdir, "repositories.yaml"),
+ outdir,
+ filepath.Join(outdir, "config.json"),
+ additionalFlags,
+ )
+ // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182
+ if tt.existFile != "" {
+ file := filepath.Join(outdir, tt.existFile)
+ _, err := os.Create(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if tt.existDir != "" {
+ file := filepath.Join(outdir, tt.existDir)
+ err := os.Mkdir(file, 0755)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, _, err := executeActionCommand(cmd)
+ if err != nil {
+ if tt.wantError {
+ if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() {
+ t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg)
+ }
+ return
+ }
+ t.Fatalf("%q reported error: %s", tt.name, err)
+ }
+
+ ef := filepath.Join(outdir, tt.expectFile)
+ fi, err := os.Stat(ef)
+ if err != nil {
+ t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err)
+ }
+ if fi.IsDir() != tt.expectDir {
+ t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir)
+ }
+ })
}
- defer srv.Stop()
+}
- srv.WithMiddleware(http.HandlerFunc(func(w 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)
- }
- }))
+// buildOCIURL is a helper function to build OCI URLs with credentials
+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)
+ }
+ if username != "" && password != "" {
+ baseURL += fmt.Sprintf(" --username %s --password %s", username, password)
+ }
+ return baseURL
+}
+
+func TestPullWithCredentialsCmd(t *testing.T) {
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"),
+ repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)),
+ )
+ defer srv.Stop()
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.FileServer(http.Dir(srv.Root())).ServeHTTP(w, r)
@@ -312,52 +398,7 @@ func TestPullWithCredentialsCmd(t *testing.T) {
},
}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- outdir := srv.Root()
- cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s",
- tt.args,
- outdir,
- filepath.Join(outdir, "repositories.yaml"),
- outdir,
- filepath.Join(outdir, "config.json"),
- )
- // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182
- if tt.existFile != "" {
- file := filepath.Join(outdir, tt.existFile)
- _, err := os.Create(file)
- if err != nil {
- t.Fatal(err)
- }
- }
- if tt.existDir != "" {
- file := filepath.Join(outdir, tt.existDir)
- err := os.Mkdir(file, 0755)
- if err != nil {
- t.Fatal(err)
- }
- }
- _, _, err := executeActionCommand(cmd)
- if err != nil {
- if tt.wantError {
- if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() {
- t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg)
- }
- return
- }
- t.Fatalf("%q reported error: %s", tt.name, err)
- }
-
- ef := filepath.Join(outdir, tt.expectFile)
- fi, err := os.Stat(ef)
- if err != nil {
- t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err)
- }
- if fi.IsDir() != tt.expectDir {
- t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir)
- }
- })
- }
+ runPullTests(t, tests, srv.Root(), "")
}
func TestPullVersionCompletion(t *testing.T) {
@@ -390,6 +431,72 @@ func TestPullVersionCompletion(t *testing.T) {
runTestCmd(t, tests)
}
+func TestPullWithCredentialsCmdOCIRegistry(t *testing.T) {
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"),
+ )
+ defer srv.Stop()
+
+ ociSrv, err := repotest.NewOCIServer(t, srv.Root())
+ if err != nil {
+ t.Fatal(err)
+ }
+ ociSrv.Run(t)
+
+ if err := srv.LinkIndices(); err != nil {
+ t.Fatal(err)
+ }
+
+ // all flags will get "-d outdir" appended.
+ tests := []struct {
+ name string
+ args string
+ existFile string
+ existDir string
+ wantError bool
+ wantErrorMsg string
+ expectFile string
+ expectDir bool
+ }{
+ {
+ name: "OCI Chart fetch with credentials",
+ args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword),
+ expectFile: "./oci-dependent-chart-0.1.0.tgz",
+ },
+ {
+ name: "OCI Chart fetch with credentials and untar",
+ args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar",
+ expectFile: "./oci-dependent-chart",
+ expectDir: true,
+ },
+ {
+ name: "OCI Chart fetch with credentials and untardir",
+ args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar --untardir ocitest-credentials",
+ expectFile: "./ocitest-credentials",
+ expectDir: true,
+ },
+ {
+ name: "Fail fetching OCI chart with wrong credentials",
+ args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", "wronguser", "wrongpass"),
+ wantError: true,
+ },
+ {
+ name: "Fail fetching non-existent OCI chart with credentials",
+ args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword),
+ wantError: true,
+ },
+ {
+ name: "Fail fetching OCI chart without version specified",
+ args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "", ociSrv.TestUsername, ociSrv.TestPassword),
+ wantErrorMsg: "Error: --version flag is explicitly required for OCI registries",
+ wantError: true,
+ },
+ }
+
+ runPullTests(t, tests, srv.Root(), "--plain-http")
+}
+
func TestPullFileCompletion(t *testing.T) {
checkFileCompletion(t, "pull", false)
checkFileCompletion(t, "pull repo/chart", false)
diff --git a/cmd/helm/push.go b/pkg/cmd/push.go
similarity index 79%
rename from cmd/helm/push.go
rename to pkg/cmd/push.go
index 3375155ed..94d322b9d 100644
--- a/cmd/helm/push.go
+++ b/pkg/cmd/push.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,9 +22,9 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/pusher"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/pusher"
)
const pushDesc = `
@@ -40,6 +40,8 @@ type registryPushOptions struct {
caFile string
insecureSkipTLSverify bool
plainHTTP bool
+ password string
+ username string
}
func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
@@ -50,7 +52,7 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "push a chart to remote",
Long: pushDesc,
Args: require.MinimumNArgs(2),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// Do file completion for the chart file to push
return nil, cobra.ShellCompDirectiveDefault
@@ -65,10 +67,13 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
return comps, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
- registryClient, err := newRegistryClient(o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSverify, o.plainHTTP)
+ RunE: func(_ *cobra.Command, args []string) error {
+ registryClient, err := newRegistryClient(
+ o.certFile, o.keyFile, o.caFile, o.insecureSkipTLSverify, o.plainHTTP, o.username, o.password,
+ )
+
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
@@ -96,6 +101,8 @@ func newPushCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the chart upload")
f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload")
+ f.StringVar(&o.username, "username", "", "chart repository username where to locate the requested chart")
+ f.StringVar(&o.password, "password", "", "chart repository password where to locate the requested chart")
return cmd
}
diff --git a/cmd/helm/push_test.go b/pkg/cmd/push_test.go
similarity index 98%
rename from cmd/helm/push_test.go
rename to pkg/cmd/push_test.go
index 8e56d99dc..80d08b48f 100644
--- a/cmd/helm/push_test.go
+++ b/pkg/cmd/push_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/registry.go b/pkg/cmd/registry.go
similarity index 95%
rename from cmd/helm/registry.go
rename to pkg/cmd/registry.go
index b2b24cd14..fcd06f13b 100644
--- a/cmd/helm/registry.go
+++ b/pkg/cmd/registry.go
@@ -13,14 +13,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
)
const registryHelp = `
diff --git a/cmd/helm/registry_login.go b/pkg/cmd/registry_login.go
similarity index 86%
rename from cmd/helm/registry_login.go
rename to pkg/cmd/registry_login.go
index 112e06a95..1350fb244 100644
--- a/cmd/helm/registry_login.go
+++ b/pkg/cmd/registry_login.go
@@ -14,25 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bufio"
"errors"
"fmt"
"io"
+ "log/slog"
"os"
"strings"
"github.com/moby/term"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const registryLoginDesc = `
Authenticate to a remote registry.
+
+For example for Github Container Registry:
+
+ echo "$GITHUB_TOKEN" | helm registry login ghcr.io -u $GITHUB_USER --password-stdin
`
type registryLoginOptions struct {
@@ -43,6 +48,7 @@ type registryLoginOptions struct {
keyFile string
caFile string
insecure bool
+ plainHTTP bool
}
func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
@@ -53,8 +59,8 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman
Short: "login to a registry",
Long: registryLoginDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: cobra.NoFileCompletions,
+ RunE: func(_ *cobra.Command, args []string) error {
hostname := args[0]
username, password, err := getUsernamePassword(o.username, o.password, o.passwordFromStdinOpt)
@@ -66,7 +72,8 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman
action.WithCertFile(o.certFile),
action.WithKeyFile(o.keyFile),
action.WithCAFile(o.caFile),
- action.WithInsecure(o.insecure))
+ action.WithInsecure(o.insecure),
+ action.WithPlainHTTPLogin(o.plainHTTP))
},
}
@@ -78,6 +85,7 @@ func newRegistryLoginCmd(cfg *action.Configuration, out io.Writer) *cobra.Comman
f.StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file")
f.StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file")
f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
+ f.BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the chart upload")
return cmd
}
@@ -119,7 +127,7 @@ func getUsernamePassword(usernameOpt string, passwordOpt string, passwordFromStd
}
}
} else {
- warning("Using --password via the CLI is insecure. Use --password-stdin.")
+ slog.Warn("using --password via the CLI is insecure. Use --password-stdin")
}
return username, password, nil
diff --git a/cmd/helm/registry_login_test.go b/pkg/cmd/registry_login_test.go
similarity index 98%
rename from cmd/helm/registry_login_test.go
rename to pkg/cmd/registry_login_test.go
index 517fe08e1..6e4f2116e 100644
--- a/cmd/helm/registry_login_test.go
+++ b/pkg/cmd/registry_login_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/registry_logout.go b/pkg/cmd/registry_logout.go
similarity index 85%
rename from cmd/helm/registry_logout.go
rename to pkg/cmd/registry_logout.go
index 0084f8c09..300453705 100644
--- a/cmd/helm/registry_logout.go
+++ b/pkg/cmd/registry_logout.go
@@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const registryLogoutDesc = `
@@ -35,8 +35,8 @@ func newRegistryLogoutCmd(cfg *action.Configuration, out io.Writer) *cobra.Comma
Short: "logout from a registry",
Long: registryLogoutDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: cobra.NoFileCompletions,
+ RunE: func(_ *cobra.Command, args []string) error {
hostname := args[0]
return action.NewRegistryLogout(cfg).Run(out, hostname)
},
diff --git a/cmd/helm/registry_logout_test.go b/pkg/cmd/registry_logout_test.go
similarity index 98%
rename from cmd/helm/registry_logout_test.go
rename to pkg/cmd/registry_logout_test.go
index 31f716725..31a21b277 100644
--- a/cmd/helm/registry_logout_test.go
+++ b/pkg/cmd/registry_logout_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/release_testing.go b/pkg/cmd/release_testing.go
similarity index 75%
rename from cmd/helm/release_testing.go
rename to pkg/cmd/release_testing.go
index 548ae2b8a..b660a16c5 100644
--- a/cmd/helm/release_testing.go
+++ b/pkg/cmd/release_testing.go
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
"regexp"
@@ -25,9 +26,9 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const releaseTestHelp = `
@@ -39,7 +40,7 @@ The tests to be run are defined in the chart that was installed.
func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
client := action.NewReleaseTesting(cfg)
- var outfmt = output.Table
+ outfmt := output.Table
var outputLogs bool
var filter []string
@@ -48,18 +49,18 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
Short: "run tests for a release",
Long: releaseTestHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.Namespace = settings.Namespace()
notName := regexp.MustCompile(`^!\s?name=`)
for _, f := range filter {
- if strings.HasPrefix(f, "name=") {
- client.Filters[action.IncludeNameFilter] = append(client.Filters[action.IncludeNameFilter], strings.TrimPrefix(f, "name="))
+ if after, ok := strings.CutPrefix(f, "name="); ok {
+ client.Filters[action.IncludeNameFilter] = append(client.Filters[action.IncludeNameFilter], after)
} else if notName.MatchString(f) {
client.Filters[action.ExcludeNameFilter] = append(client.Filters[action.ExcludeNameFilter], notName.ReplaceAllLiteralString(f, ""))
}
@@ -72,7 +73,13 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
return runErr
}
- if err := outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false}); err != nil {
+ if err := outfmt.Write(out, &statusPrinter{
+ release: rel,
+ debug: settings.Debug,
+ showMetadata: false,
+ hideNotes: client.HideNotes,
+ noColor: settings.ShouldDisableColor(),
+ }); err != nil {
return err
}
@@ -80,7 +87,7 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
// Print a newline to stdout to separate the output
fmt.Fprintln(out)
if err := client.GetPodLogs(out, rel); err != nil {
- return err
+ return errors.Join(runErr, err)
}
}
@@ -92,6 +99,7 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
f.BoolVar(&outputLogs, "logs", false, "dump the logs from test pods (this runs after all tests are complete, but before any cleanup)")
f.StringSliceVar(&filter, "filter", []string{}, "specify tests by attribute (currently \"name\") using attribute=value syntax or '!attribute=value' to exclude a test (can specify multiple or separate values with commas: name=test1,name=test2)")
+ f.BoolVar(&client.HideNotes, "hide-notes", false, "if set, do not show notes in test output. Does not affect presence in chart metadata")
return cmd
}
diff --git a/cmd/helm/release_testing_test.go b/pkg/cmd/release_testing_test.go
similarity index 98%
rename from cmd/helm/release_testing_test.go
rename to pkg/cmd/release_testing_test.go
index 680a9bd3e..43599ad0d 100644
--- a/cmd/helm/release_testing_test.go
+++ b/pkg/cmd/release_testing_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/repo.go b/pkg/cmd/repo.go
similarity index 91%
rename from cmd/helm/repo.go
rename to pkg/cmd/repo.go
index ad6ceaa8f..0dc2a7175 100644
--- a/cmd/helm/repo.go
+++ b/pkg/cmd/repo.go
@@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"io"
- "os"
+ "io/fs"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
var repoHelm = `
@@ -50,5 +50,5 @@ func newRepoCmd(out io.Writer) *cobra.Command {
}
func isNotExist(err error) bool {
- return os.IsNotExist(errors.Cause(err))
+ return errors.Is(err, fs.ErrNotExist)
}
diff --git a/cmd/helm/repo_add.go b/pkg/cmd/repo_add.go
similarity index 82%
rename from cmd/helm/repo_add.go
rename to pkg/cmd/repo_add.go
index 2deda3f4f..187234486 100644
--- a/cmd/helm/repo_add.go
+++ b/pkg/cmd/repo_add.go
@@ -14,26 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"context"
+ "errors"
"fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/gofrs/flock"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
"golang.org/x/term"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/repo"
)
// Repositories that have been permanently deleted and no longer work
@@ -51,6 +52,7 @@ type repoAddOptions struct {
passCredentialsAll bool
forceUpdate bool
allowDeprecatedRepos bool
+ timeout time.Duration
certFile string
keyFile string
@@ -59,20 +61,22 @@ type repoAddOptions struct {
repoFile string
repoCache string
-
- // Deprecated, but cannot be removed until Helm 4
- deprecatedNoUpdate bool
}
func newRepoAddCmd(out io.Writer) *cobra.Command {
o := &repoAddOptions{}
cmd := &cobra.Command{
- Use: "add [NAME] [URL]",
- Short: "add a chart repository",
- Args: require.ExactArgs(2),
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ Use: "add [NAME] [URL]",
+ Short: "add a chart repository",
+ Args: require.ExactArgs(2),
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
+ if len(args) > 1 {
+ return noMoreArgsComp()
+ }
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ },
+ RunE: func(_ *cobra.Command, args []string) error {
o.name = args[0]
o.url = args[1]
o.repoFile = settings.RepositoryConfig
@@ -87,13 +91,13 @@ func newRepoAddCmd(out io.Writer) *cobra.Command {
f.StringVar(&o.password, "password", "", "chart repository password")
f.BoolVarP(&o.passwordFromStdinOpt, "password-stdin", "", false, "read chart repository password from stdin")
f.BoolVar(&o.forceUpdate, "force-update", false, "replace (overwrite) the repo if it already exists")
- f.BoolVar(&o.deprecatedNoUpdate, "no-update", false, "Ignored. Formerly, it would disabled forced updates. It is deprecated by force-update.")
f.StringVar(&o.certFile, "cert-file", "", "identify HTTPS client using this SSL certificate file")
f.StringVar(&o.keyFile, "key-file", "", "identify HTTPS client using this SSL key file")
f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository")
f.BoolVar(&o.allowDeprecatedRepos, "allow-deprecated-repos", false, "by default, this command will not allow adding official repos that have been permanently deleted. This disables that behavior")
f.BoolVar(&o.passCredentialsAll, "pass-credentials", false, "pass credentials to all domains")
+ f.DurationVar(&o.timeout, "timeout", getter.DefaultHTTPTimeout*time.Second, "time to wait for the index file download to complete")
return cmd
}
@@ -134,7 +138,7 @@ func (o *repoAddOptions) run(out io.Writer) error {
}
b, err := os.ReadFile(o.repoFile)
- if err != nil && !os.IsNotExist(err) {
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
@@ -178,7 +182,7 @@ func (o *repoAddOptions) run(out io.Writer) error {
// Check if the repo name is legal
if strings.Contains(o.name, "/") {
- return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name)
+ return fmt.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name)
}
// If the repo exists do one of two things:
@@ -187,10 +191,9 @@ func (o *repoAddOptions) run(out io.Writer) error {
if !o.forceUpdate && f.Has(o.name) {
existing := f.Get(o.name)
if c != *existing {
-
// The input coming in for the name is different from what is already
// configured. Return an error.
- return errors.Errorf("repository name (%s) already exists, please specify a different name", o.name)
+ return fmt.Errorf("repository name (%s) already exists, please specify a different name", o.name)
}
// The add is idempotent so do nothing
@@ -198,7 +201,7 @@ func (o *repoAddOptions) run(out io.Writer) error {
return nil
}
- r, err := repo.NewChartRepository(&c, getter.All(settings))
+ r, err := repo.NewChartRepository(&c, getter.All(settings, getter.WithTimeout(o.timeout)))
if err != nil {
return err
}
@@ -207,12 +210,12 @@ func (o *repoAddOptions) run(out io.Writer) error {
r.CachePath = o.repoCache
}
if _, err := r.DownloadIndexFile(); err != nil {
- return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", o.url)
+ return fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", o.url, err)
}
f.Update(&c)
- if err := f.WriteFile(o.repoFile, 0600); err != nil {
+ if err := f.WriteFile(o.repoFile, 0o600); err != nil {
return err
}
fmt.Fprintf(out, "%q has been added to your repositories\n", o.name)
diff --git a/cmd/helm/repo_add_test.go b/pkg/cmd/repo_add_test.go
similarity index 80%
rename from cmd/helm/repo_add_test.go
rename to pkg/cmd/repo_add_test.go
index 2386bb01f..aa6c4eaad 100644
--- a/cmd/helm/repo_add_test.go
+++ b/pkg/cmd/repo_add_test.go
@@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
"strings"
@@ -27,29 +29,28 @@ import (
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestRepoAddCmd(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer srv.Stop()
// A second test server is setup to verify URL changing
- srv2, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ srv2 := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer srv2.Stop()
tmpdir := filepath.Join(t.TempDir(), "path-component.yaml/data")
- err = os.MkdirAll(tmpdir, 0777)
- if err != nil {
+ if err := os.MkdirAll(tmpdir, 0o777); err != nil {
t.Fatal(err)
}
repoFile := filepath.Join(tmpdir, "repositories.yaml")
@@ -81,10 +82,10 @@ func TestRepoAddCmd(t *testing.T) {
}
func TestRepoAdd(t *testing.T) {
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
rootDir := t.TempDir()
@@ -93,13 +94,12 @@ func TestRepoAdd(t *testing.T) {
const testRepoName = "test-name"
o := &repoAddOptions{
- name: testRepoName,
- url: ts.URL(),
- forceUpdate: false,
- deprecatedNoUpdate: true,
- repoFile: repoFile,
+ name: testRepoName,
+ url: ts.URL(),
+ forceUpdate: false,
+ repoFile: repoFile,
}
- os.Setenv(xdg.CacheHomeEnvVar, rootDir)
+ t.Setenv(xdg.CacheHomeEnvVar, rootDir)
if err := o.run(io.Discard); err != nil {
t.Error(err)
@@ -115,11 +115,11 @@ func TestRepoAdd(t *testing.T) {
}
idx := filepath.Join(helmpath.CachePath("repository"), helmpath.CacheIndexFile(testRepoName))
- if _, err := os.Stat(idx); os.IsNotExist(err) {
+ if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) {
t.Errorf("Error cache index file was not created for repository %s", testRepoName)
}
idx = filepath.Join(helmpath.CachePath("repository"), helmpath.CacheChartsFile(testRepoName))
- if _, err := os.Stat(idx); os.IsNotExist(err) {
+ if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) {
t.Errorf("Error cache charts file was not created for repository %s", testRepoName)
}
@@ -135,10 +135,10 @@ func TestRepoAdd(t *testing.T) {
}
func TestRepoAddCheckLegalName(t *testing.T) {
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
defer resetEnv()()
@@ -148,13 +148,12 @@ func TestRepoAddCheckLegalName(t *testing.T) {
repoFile := filepath.Join(t.TempDir(), "repositories.yaml")
o := &repoAddOptions{
- name: testRepoName,
- url: ts.URL(),
- forceUpdate: false,
- deprecatedNoUpdate: true,
- repoFile: repoFile,
+ name: testRepoName,
+ url: ts.URL(),
+ forceUpdate: false,
+ repoFile: repoFile,
}
- os.Setenv(xdg.CacheHomeEnvVar, rootDir)
+ t.Setenv(xdg.CacheHomeEnvVar, rootDir)
wantErrorMsg := fmt.Sprintf("repository name (%s) contains '/', please specify a different name without '/'", testRepoName)
@@ -192,10 +191,11 @@ func TestRepoAddConcurrentHiddenFile(t *testing.T) {
}
func repoAddConcurrent(t *testing.T, testName, repoFile string) {
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ t.Helper()
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
var wg sync.WaitGroup
@@ -204,11 +204,10 @@ func repoAddConcurrent(t *testing.T, testName, repoFile string) {
go func(name string) {
defer wg.Done()
o := &repoAddOptions{
- name: name,
- url: ts.URL(),
- deprecatedNoUpdate: true,
- forceUpdate: false,
- repoFile: repoFile,
+ name: name,
+ url: ts.URL(),
+ forceUpdate: false,
+ repoFile: repoFile,
}
if err := o.run(io.Discard); err != nil {
t.Error(err)
@@ -243,7 +242,11 @@ func TestRepoAddFileCompletion(t *testing.T) {
}
func TestRepoAddWithPasswordFromStdin(t *testing.T) {
- srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/testserver/*.*")
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)),
+ )
defer srv.Stop()
defer resetEnv()()
diff --git a/cmd/helm/repo_index.go b/pkg/cmd/repo_index.go
similarity index 77%
rename from cmd/helm/repo_index.go
rename to pkg/cmd/repo_index.go
index 3960380d1..c17fd9391 100644
--- a/cmd/helm/repo_index.go
+++ b/pkg/cmd/repo_index.go
@@ -14,29 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
+ "fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/repo"
)
const repoIndexDesc = `
-Read the current directory and generate an index file based on the charts found.
+Read the current directory, generate an index file based on the charts found
+and write the result to 'index.yaml' in the current directory.
This tool is used for creating an 'index.yaml' file for a chart repository. To
set an absolute URL to the charts, use '--url' flag.
To merge the generated index with an existing index file, use the '--merge'
flag. In this case, the charts found in the current directory will be merged
-into the existing index, with local charts taking priority over existing charts.
+into the index passed in with --merge, with local charts taking priority over
+existing charts.
`
type repoIndexOptions struct {
@@ -54,15 +58,15 @@ func newRepoIndexCmd(out io.Writer) *cobra.Command {
Short: "generate an index file given a directory containing packaged charts",
Long: repoIndexDesc,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// Allow file completion when completing the argument for the directory
return nil, cobra.ShellCompDirectiveDefault
}
// No more completions, so disable file completion
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
o.dir = args[0]
return o.run(out)
},
@@ -76,7 +80,7 @@ func newRepoIndexCmd(out io.Writer) *cobra.Command {
return cmd
}
-func (i *repoIndexOptions) run(out io.Writer) error {
+func (i *repoIndexOptions) run(_ io.Writer) error {
path, err := filepath.Abs(i.dir)
if err != nil {
return err
@@ -95,13 +99,13 @@ func index(dir, url, mergeTo string, json bool) error {
if mergeTo != "" {
// if index.yaml is missing then create an empty one to merge into
var i2 *repo.IndexFile
- if _, err := os.Stat(mergeTo); os.IsNotExist(err) {
+ if _, err := os.Stat(mergeTo); errors.Is(err, fs.ErrNotExist) {
i2 = repo.NewIndexFile()
writeIndexFile(i2, mergeTo, json)
} else {
i2, err = repo.LoadIndexFile(mergeTo)
if err != nil {
- return errors.Wrap(err, "merge failed")
+ return fmt.Errorf("merge failed: %w", err)
}
}
i.Merge(i2)
@@ -112,7 +116,7 @@ func index(dir, url, mergeTo string, json bool) error {
func writeIndexFile(i *repo.IndexFile, out string, json bool) error {
if json {
- return i.WriteJSONFile(out, 0644)
+ return i.WriteJSONFile(out, 0o644)
}
- return i.WriteFile(out, 0644)
+ return i.WriteFile(out, 0o644)
}
diff --git a/cmd/helm/repo_index_test.go b/pkg/cmd/repo_index_test.go
similarity index 96%
rename from cmd/helm/repo_index_test.go
rename to pkg/cmd/repo_index_test.go
index 554a3dadf..c865c8a5d 100644
--- a/cmd/helm/repo_index_test.go
+++ b/pkg/cmd/repo_index_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
@@ -24,7 +24,7 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo"
)
func TestRepoIndexCmd(t *testing.T) {
@@ -162,9 +162,9 @@ func TestRepoIndexCmd(t *testing.T) {
}
}
-func linkOrCopy(old, new string) error {
- if err := os.Link(old, new); err != nil {
- return copyFile(old, new)
+func linkOrCopy(source, target string) error {
+ if err := os.Link(source, target); err != nil {
+ return copyFile(source, target)
}
return nil
diff --git a/cmd/helm/repo_list.go b/pkg/cmd/repo_list.go
similarity index 83%
rename from cmd/helm/repo_list.go
rename to pkg/cmd/repo_list.go
index c9b952fee..70f57992e 100644
--- a/cmd/helm/repo_list.go
+++ b/pkg/cmd/repo_list.go
@@ -14,19 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"io"
"github.com/gosuri/uitable"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/repo"
)
func newRepoListCmd(out io.Writer) *cobra.Command {
@@ -36,11 +35,15 @@ func newRepoListCmd(out io.Writer) *cobra.Command {
Aliases: []string{"ls"},
Short: "list chart repositories",
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ // The error is silently ignored. If no repository file exists, it cannot be loaded,
+ // or the file isn't the right format to be parsed the error is ignored. The
+ // repositories will be 0.
f, _ := repo.LoadFile(settings.RepositoryConfig)
- if len(f.Repositories) == 0 && !(outfmt == output.JSON || outfmt == output.YAML) {
- return errors.New("no repositories to show")
+ if len(f.Repositories) == 0 && outfmt != output.JSON && outfmt != output.YAML {
+ fmt.Fprintln(cmd.ErrOrStderr(), "no repositories to show")
+ return nil
}
return outfmt.Write(out, &repoListWriter{f.Repositories})
@@ -123,7 +126,7 @@ func filterRepos(repos []*repo.Entry, ignoredRepoNames []string) []*repo.Entry {
}
// Provide dynamic auto-completion for repo names
-func compListRepos(prefix string, ignoredRepoNames []string) []string {
+func compListRepos(_ string, ignoredRepoNames []string) []string {
var rNames []string
f, err := repo.LoadFile(settings.RepositoryConfig)
diff --git a/pkg/cmd/repo_list_test.go b/pkg/cmd/repo_list_test.go
new file mode 100644
index 000000000..2f6a9e4ad
--- /dev/null
+++ b/pkg/cmd/repo_list_test.go
@@ -0,0 +1,54 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "path/filepath"
+ "testing"
+)
+
+func TestRepoListOutputCompletion(t *testing.T) {
+ outputFlagCompletionTest(t, "repo list")
+}
+
+func TestRepoListFileCompletion(t *testing.T) {
+ checkFileCompletion(t, "repo list", false)
+}
+
+func TestRepoList(t *testing.T) {
+ rootDir := t.TempDir()
+ repoFile := filepath.Join(rootDir, "repositories.yaml")
+ repoFile2 := "testdata/repositories.yaml"
+
+ tests := []cmdTestCase{
+ {
+ name: "list with no repos",
+ cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s", repoFile, rootDir),
+ golden: "output/repo-list-empty.txt",
+ wantError: false,
+ },
+ {
+ name: "list with repos",
+ cmd: fmt.Sprintf("repo list --repository-config %s --repository-cache %s", repoFile2, rootDir),
+ golden: "output/repo-list.txt",
+ wantError: false,
+ },
+ }
+
+ runTestCmd(t, tests)
+}
diff --git a/cmd/helm/repo_remove.go b/pkg/cmd/repo_remove.go
similarity index 80%
rename from cmd/helm/repo_remove.go
rename to pkg/cmd/repo_remove.go
index 0c1ad2cd5..d0a3aa205 100644
--- a/cmd/helm/repo_remove.go
+++ b/pkg/cmd/repo_remove.go
@@ -14,20 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
+ "io/fs"
"os"
"path/filepath"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/repo"
)
type repoRemoveOptions struct {
@@ -44,10 +45,10 @@ func newRepoRemoveCmd(out io.Writer) *cobra.Command {
Aliases: []string{"rm"},
Short: "remove one or more chart repositories",
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
o.repoFile = settings.RepositoryConfig
o.repoCache = settings.RepositoryCache
o.names = args
@@ -65,7 +66,7 @@ func (o *repoRemoveOptions) run(out io.Writer) error {
for _, name := range o.names {
if !r.Remove(name) {
- return errors.Errorf("no repo named %q found", name)
+ return fmt.Errorf("no repo named %q found", name)
}
if err := r.WriteFile(o.repoFile, 0600); err != nil {
return err
@@ -87,10 +88,10 @@ func removeRepoCache(root, name string) error {
}
idx = filepath.Join(root, helmpath.CacheIndexFile(name))
- if _, err := os.Stat(idx); os.IsNotExist(err) {
+ if _, err := os.Stat(idx); errors.Is(err, fs.ErrNotExist) {
return nil
} else if err != nil {
- return errors.Wrapf(err, "can't remove index file %s", idx)
+ return fmt.Errorf("can't remove index file %s: %w", idx, err)
}
return os.Remove(idx)
}
diff --git a/cmd/helm/repo_remove_test.go b/pkg/cmd/repo_remove_test.go
similarity index 94%
rename from cmd/helm/repo_remove_test.go
rename to pkg/cmd/repo_remove_test.go
index e2795e738..bd8757812 100644
--- a/cmd/helm/repo_remove_test.go
+++ b/pkg/cmd/repo_remove_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
@@ -24,16 +24,16 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestRepoRemove(t *testing.T) {
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
rootDir := t.TempDir()
@@ -153,6 +153,7 @@ func createCacheFiles(rootDir string, repoName string) (cacheIndexFile string, c
}
func testCacheFiles(t *testing.T, cacheIndexFile string, cacheChartsFile string, repoName string) {
+ t.Helper()
if _, err := os.Stat(cacheIndexFile); err == nil {
t.Errorf("Error cache index file was not removed for repository %s", repoName)
}
@@ -162,10 +163,11 @@ func testCacheFiles(t *testing.T, cacheIndexFile string, cacheChartsFile string,
}
func TestRepoRemoveCompletion(t *testing.T) {
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
+
defer ts.Stop()
rootDir := t.TempDir()
diff --git a/cmd/helm/repo_test.go b/pkg/cmd/repo_test.go
similarity index 98%
rename from cmd/helm/repo_test.go
rename to pkg/cmd/repo_test.go
index 2b0df7c4c..6b89a66c3 100644
--- a/cmd/helm/repo_test.go
+++ b/pkg/cmd/repo_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/repo_update.go b/pkg/cmd/repo_update.go
similarity index 69%
rename from cmd/helm/repo_update.go
rename to pkg/cmd/repo_update.go
index 27661674c..54318bf29 100644
--- a/cmd/helm/repo_update.go
+++ b/pkg/cmd/repo_update.go
@@ -14,19 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "errors"
"fmt"
"io"
+ "slices"
"sync"
+ "time"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/repo"
)
const updateDesc = `
@@ -41,11 +43,11 @@ To update all the repositories, use 'helm repo update'.
var errNoRepositories = errors.New("no repositories found. You must add one before updating")
type repoUpdateOptions struct {
- update func([]*repo.ChartRepository, io.Writer, bool) error
- repoFile string
- repoCache string
- names []string
- failOnRepoUpdateFail bool
+ update func([]*repo.ChartRepository, io.Writer) error
+ repoFile string
+ repoCache string
+ names []string
+ timeout time.Duration
}
func newRepoUpdateCmd(out io.Writer) *cobra.Command {
@@ -57,10 +59,10 @@ func newRepoUpdateCmd(out io.Writer) *cobra.Command {
Short: "update information of available charts locally from chart repositories",
Long: updateDesc,
Args: require.MinimumNArgs(0),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compListRepos(toComplete, args), cobra.ShellCompDirectiveNoFileComp
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
o.repoFile = settings.RepositoryConfig
o.repoCache = settings.RepositoryCache
o.names = args
@@ -69,10 +71,7 @@ func newRepoUpdateCmd(out io.Writer) *cobra.Command {
}
f := cmd.Flags()
-
- // Adding this flag for Helm 3 as stop gap functionality for https://github.com/helm/helm/issues/10016.
- // This should be deprecated in Helm 4 by update to the behaviour of `helm repo update` command.
- f.BoolVar(&o.failOnRepoUpdateFail, "fail-on-repo-update-fail", false, "update fails if any of the repository updates fail")
+ f.DurationVar(&o.timeout, "timeout", getter.DefaultHTTPTimeout*time.Second, "time to wait for the index file download to complete")
return cmd
}
@@ -83,7 +82,7 @@ func (o *repoUpdateOptions) run(out io.Writer) error {
case isNotExist(err):
return errNoRepositories
case err != nil:
- return errors.Wrapf(err, "failed loading file: %s", o.repoFile)
+ return fmt.Errorf("failed loading file: %s: %w", o.repoFile, err)
case len(f.Repositories) == 0:
return errNoRepositories
}
@@ -100,7 +99,7 @@ func (o *repoUpdateOptions) run(out io.Writer) error {
for _, cfg := range f.Repositories {
if updateAllRepos || isRepoRequested(cfg.Name, o.names) {
- r, err := repo.NewChartRepository(cfg, getter.All(settings))
+ r, err := repo.NewChartRepository(cfg, getter.All(settings, getter.WithTimeout(o.timeout)))
if err != nil {
return err
}
@@ -111,29 +110,44 @@ func (o *repoUpdateOptions) run(out io.Writer) error {
}
}
- return o.update(repos, out, o.failOnRepoUpdateFail)
+ return o.update(repos, out)
}
-func updateCharts(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error {
+func updateCharts(repos []*repo.ChartRepository, out io.Writer) error {
fmt.Fprintln(out, "Hang tight while we grab the latest from your chart repositories...")
var wg sync.WaitGroup
- var repoFailList []string
+ failRepoURLChan := make(chan string, len(repos))
+
+ writeMutex := sync.Mutex{}
for _, re := range repos {
wg.Add(1)
go func(re *repo.ChartRepository) {
defer wg.Done()
if _, err := re.DownloadIndexFile(); err != nil {
+ writeMutex.Lock()
+ defer writeMutex.Unlock()
fmt.Fprintf(out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", re.Config.Name, re.Config.URL, err)
- repoFailList = append(repoFailList, re.Config.URL)
+ failRepoURLChan <- re.Config.URL
} else {
+ writeMutex.Lock()
+ defer writeMutex.Unlock()
fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", re.Config.Name)
}
}(re)
}
- wg.Wait()
- if len(repoFailList) > 0 && failOnRepoUpdateFail {
- return fmt.Errorf("Failed to update the following repositories: %s",
+ go func() {
+ wg.Wait()
+ close(failRepoURLChan)
+ }()
+
+ var repoFailList []string
+ for url := range failRepoURLChan {
+ repoFailList = append(repoFailList, url)
+ }
+
+ if len(repoFailList) > 0 {
+ return fmt.Errorf("failed to update the following repositories: %s",
repoFailList)
}
@@ -151,17 +165,12 @@ func checkRequestedRepos(requestedRepos []string, validRepos []*repo.Entry) erro
}
}
if !found {
- return errors.Errorf("no repositories found matching '%s'. Nothing will be updated", requestedRepo)
+ return fmt.Errorf("no repositories found matching '%s'. Nothing will be updated", requestedRepo)
}
}
return nil
}
func isRepoRequested(repoName string, requestedRepos []string) bool {
- for _, requestedRepo := range requestedRepos {
- if repoName == requestedRepo {
- return true
- }
- }
- return false
+ return slices.Contains(requestedRepos, repoName)
}
diff --git a/cmd/helm/repo_update_test.go b/pkg/cmd/repo_update_test.go
similarity index 74%
rename from cmd/helm/repo_update_test.go
rename to pkg/cmd/repo_update_test.go
index 645c68cfe..b0deff1ae 100644
--- a/cmd/helm/repo_update_test.go
+++ b/pkg/cmd/repo_update_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
@@ -24,17 +24,17 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestUpdateCmd(t *testing.T) {
var out bytes.Buffer
// Instead of using the HTTP updater, we provide our own for this test.
// The TestUpdateCharts test verifies the HTTP behavior independently.
- updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error {
+ updater := func(repos []*repo.ChartRepository, out io.Writer) error {
for _, re := range repos {
fmt.Fprintln(out, re.Config.Name)
}
@@ -59,7 +59,7 @@ func TestUpdateCmdMultiple(t *testing.T) {
var out bytes.Buffer
// Instead of using the HTTP updater, we provide our own for this test.
// The TestUpdateCharts test verifies the HTTP behavior independently.
- updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error {
+ updater := func(repos []*repo.ChartRepository, out io.Writer) error {
for _, re := range repos {
fmt.Fprintln(out, re.Config.Name)
}
@@ -85,7 +85,7 @@ func TestUpdateCmdInvalid(t *testing.T) {
var out bytes.Buffer
// Instead of using the HTTP updater, we provide our own for this test.
// The TestUpdateCharts test verifies the HTTP behavior independently.
- updater := func(repos []*repo.ChartRepository, out io.Writer, failOnRepoUpdateFail bool) error {
+ updater := func(repos []*repo.ChartRepository, out io.Writer) error {
for _, re := range repos {
fmt.Fprintln(out, re.Config.Name)
}
@@ -106,10 +106,11 @@ func TestUpdateCustomCacheCmd(t *testing.T) {
cachePath := filepath.Join(rootDir, "updcustomcache")
os.Mkdir(cachePath, os.ModePerm)
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
+
defer ts.Stop()
o := &repoUpdateOptions{
@@ -130,10 +131,9 @@ func TestUpdateCharts(t *testing.T) {
defer resetEnv()()
ensure.HelmHome(t)
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
r, err := repo.NewChartRepository(&repo.Entry{
@@ -145,7 +145,7 @@ func TestUpdateCharts(t *testing.T) {
}
b := bytes.NewBuffer(nil)
- updateCharts([]*repo.ChartRepository{r}, b, false)
+ updateCharts([]*repo.ChartRepository{r}, b)
got := b.String()
if strings.Contains(got, "Unable to get an update") {
@@ -161,51 +161,25 @@ func TestRepoUpdateFileCompletion(t *testing.T) {
checkFileCompletion(t, "repo update repo1", false)
}
-func TestUpdateChartsFail(t *testing.T) {
+func TestUpdateChartsFailWithError(t *testing.T) {
defer resetEnv()()
ensure.HelmHome(t)
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
+ ts := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testserver/*.*"),
+ )
defer ts.Stop()
var invalidURL = ts.URL() + "55"
- r, err := repo.NewChartRepository(&repo.Entry{
+ r1, err := repo.NewChartRepository(&repo.Entry{
Name: "charts",
URL: invalidURL,
}, getter.All(settings))
if err != nil {
t.Error(err)
}
-
- b := bytes.NewBuffer(nil)
- if err := updateCharts([]*repo.ChartRepository{r}, b, false); err != nil {
- t.Error("Repo update should not return error if update of repository fails")
- }
-
- got := b.String()
- if !strings.Contains(got, "Unable to get an update") {
- t.Errorf("Repo should have failed update but instead got: %q", got)
- }
- if !strings.Contains(got, "Update Complete.") {
- t.Error("Update was not successful")
- }
-}
-
-func TestUpdateChartsFailWithError(t *testing.T) {
- defer resetEnv()()
- ensure.HelmHome(t)
-
- ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*")
- if err != nil {
- t.Fatal(err)
- }
- defer ts.Stop()
-
- var invalidURL = ts.URL() + "55"
- r, err := repo.NewChartRepository(&repo.Entry{
+ r2, err := repo.NewChartRepository(&repo.Entry{
Name: "charts",
URL: invalidURL,
}, getter.All(settings))
@@ -214,12 +188,12 @@ func TestUpdateChartsFailWithError(t *testing.T) {
}
b := bytes.NewBuffer(nil)
- err = updateCharts([]*repo.ChartRepository{r}, b, true)
+ err = updateCharts([]*repo.ChartRepository{r1, r2}, b)
if err == nil {
t.Error("Repo update should return error because update of repository fails and 'fail-on-repo-update-fail' flag set")
return
}
- var expectedErr = "Failed to update the following repositories"
+ var expectedErr = "failed to update the following repositories"
var receivedErr = err.Error()
if !strings.Contains(receivedErr, expectedErr) {
t.Errorf("Expected error (%s) but got (%s) instead", expectedErr, receivedErr)
diff --git a/cmd/helm/require/args.go b/pkg/cmd/require/args.go
similarity index 94%
rename from cmd/helm/require/args.go
rename to pkg/cmd/require/args.go
index cfa8a0169..f5e0888f1 100644
--- a/cmd/helm/require/args.go
+++ b/pkg/cmd/require/args.go
@@ -16,14 +16,15 @@ limitations under the License.
package require
import (
- "github.com/pkg/errors"
+ "fmt"
+
"github.com/spf13/cobra"
)
// NoArgs returns an error if any args are included.
func NoArgs(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
- return errors.Errorf(
+ return fmt.Errorf(
"%q accepts no arguments\n\nUsage: %s",
cmd.CommandPath(),
cmd.UseLine(),
@@ -36,7 +37,7 @@ func NoArgs(cmd *cobra.Command, args []string) error {
func ExactArgs(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) != n {
- return errors.Errorf(
+ return fmt.Errorf(
"%q requires %d %s\n\nUsage: %s",
cmd.CommandPath(),
n,
@@ -52,7 +53,7 @@ func ExactArgs(n int) cobra.PositionalArgs {
func MaximumNArgs(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) > n {
- return errors.Errorf(
+ return fmt.Errorf(
"%q accepts at most %d %s\n\nUsage: %s",
cmd.CommandPath(),
n,
@@ -68,7 +69,7 @@ func MaximumNArgs(n int) cobra.PositionalArgs {
func MinimumNArgs(n int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if len(args) < n {
- return errors.Errorf(
+ return fmt.Errorf(
"%q requires at least %d %s\n\nUsage: %s",
cmd.CommandPath(),
n,
diff --git a/cmd/helm/require/args_test.go b/pkg/cmd/require/args_test.go
similarity index 97%
rename from cmd/helm/require/args_test.go
rename to pkg/cmd/require/args_test.go
index 5a84a42d0..b6c430fc0 100644
--- a/cmd/helm/require/args_test.go
+++ b/pkg/cmd/require/args_test.go
@@ -63,6 +63,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) {
cmd := &cobra.Command{
@@ -71,7 +72,8 @@ func runTestCases(t *testing.T, testCases []testCase) {
Args: tc.validateFunc,
}
cmd.SetArgs(tc.args)
- cmd.SetOutput(io.Discard)
+ cmd.SetOut(io.Discard)
+ cmd.SetErr(io.Discard)
err := cmd.Execute()
if tc.wantError == "" {
diff --git a/cmd/helm/rollback.go b/pkg/cmd/rollback.go
similarity index 77%
rename from cmd/helm/rollback.go
rename to pkg/cmd/rollback.go
index 7de98e404..4b7f3016d 100644
--- a/cmd/helm/rollback.go
+++ b/pkg/cmd/rollback.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -24,8 +24,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const rollbackDesc = `
@@ -46,7 +46,7 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "roll back a release to a previous revision",
Long: rollbackDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return compListReleases(toComplete, args, cfg)
}
@@ -55,9 +55,9 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
return compListRevisions(toComplete, cfg, args[0])
}
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
if len(args) > 1 {
ver, err := strconv.Atoi(args[1])
if err != nil {
@@ -77,14 +77,15 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f := cmd.Flags()
f.BoolVar(&client.DryRun, "dry-run", false, "simulate a rollback")
- f.BoolVar(&client.Recreate, "recreate-pods", false, "performs pods restart for the resource if applicable")
- f.BoolVar(&client.Force, "force", false, "force resource update through delete/recreate if needed")
+ f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement")
+ f.BoolVar(&client.ForceReplace, "force", false, "deprecated")
+ f.MarkDeprecated("force", "use --force-replace instead")
f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during rollback")
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
- f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout")
f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout")
f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails")
f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit")
+ AddWaitFlag(cmd, &client.WaitStrategy)
return cmd
}
diff --git a/cmd/helm/rollback_test.go b/pkg/cmd/rollback_test.go
similarity index 73%
rename from cmd/helm/rollback_test.go
rename to pkg/cmd/rollback_test.go
index 6d38e16eb..53c63613e 100644
--- a/cmd/helm/rollback_test.go
+++ b/pkg/cmd/rollback_test.go
@@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
+ "fmt"
+ "reflect"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/release"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestRollbackCmd(t *testing.T) {
@@ -121,3 +123,44 @@ func TestRollbackFileCompletion(t *testing.T) {
checkFileCompletion(t, "rollback myrelease", false)
checkFileCompletion(t, "rollback myrelease 1", false)
}
+
+func TestRollbackWithLabels(t *testing.T) {
+ labels1 := map[string]string{"operation": "install", "firstLabel": "firstValue"}
+ labels2 := map[string]string{"operation": "upgrade", "secondLabel": "secondValue"}
+
+ releaseName := "funny-bunny-labels"
+ rels := []*release.Release{
+ {
+ Name: releaseName,
+ Info: &release.Info{Status: release.StatusSuperseded},
+ Chart: &chart.Chart{},
+ Version: 1,
+ Labels: labels1,
+ },
+ {
+ Name: releaseName,
+ Info: &release.Info{Status: release.StatusDeployed},
+ Chart: &chart.Chart{},
+ Version: 2,
+ Labels: labels2,
+ },
+ }
+ storage := storageFixture()
+ for _, rel := range rels {
+ if err := storage.Create(rel); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, _, err := executeActionCommandC(storage, fmt.Sprintf("rollback %s 1", releaseName))
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+ updatedRel, err := storage.Get(releaseName, 3)
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+
+ if !reflect.DeepEqual(updatedRel.Labels, labels1) {
+ t.Errorf("Expected {%v}, got {%v}", labels1, updatedRel.Labels)
+ }
+}
diff --git a/cmd/helm/root.go b/pkg/cmd/root.go
similarity index 58%
rename from cmd/helm/root.go
rename to pkg/cmd/root.go
index dd95b1df2..836df834d 100644
--- a/cmd/helm/root.go
+++ b/pkg/cmd/root.go
@@ -14,24 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main // import "helm.sh/helm/v3/cmd/helm"
+package cmd // import "helm.sh/helm/v4/pkg/cmd"
import (
"context"
"fmt"
"io"
"log"
+ "log/slog"
+ "net/http"
"os"
"strings"
+ "github.com/fatih/color"
"github.com/spf13/cobra"
+ "sigs.k8s.io/yaml"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/clientcmd"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/internal/logging"
+ "helm.sh/helm/v4/internal/tlsutil"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cli"
+ kubefake "helm.sh/helm/v4/pkg/kube/fake"
+ "helm.sh/helm/v4/pkg/registry"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
var globalUsage = `The Kubernetes package manager
@@ -45,31 +55,34 @@ Common actions for Helm:
Environment variables:
-| Name | Description |
-|------------------------------------|---------------------------------------------------------------------------------------------------|
-| $HELM_CACHE_HOME | set an alternative location for storing cached files. |
-| $HELM_CONFIG_HOME | set an alternative location for storing Helm configuration. |
-| $HELM_DATA_HOME | set an alternative location for storing Helm data. |
-| $HELM_DEBUG | indicate whether or not Helm is running in Debug mode |
-| $HELM_DRIVER | set the backend storage driver. Values are: configmap, secret, memory, sql. |
-| $HELM_DRIVER_SQL_CONNECTION_STRING | set the connection string the SQL storage driver should use. |
-| $HELM_MAX_HISTORY | set the maximum number of helm release history. |
-| $HELM_NAMESPACE | set the namespace used for the helm operations. |
-| $HELM_NO_PLUGINS | disable plugins. Set HELM_NO_PLUGINS=1 to disable plugins. |
-| $HELM_PLUGINS | set the path to the plugins directory |
-| $HELM_REGISTRY_CONFIG | set the path to the registry config file. |
-| $HELM_REPOSITORY_CACHE | set the path to the repository cache directory |
-| $HELM_REPOSITORY_CONFIG | set the path to the repositories file. |
-| $KUBECONFIG | set an alternative Kubernetes configuration file (default "~/.kube/config") |
-| $HELM_KUBEAPISERVER | set the Kubernetes API Server Endpoint for authentication |
-| $HELM_KUBECAFILE | set the Kubernetes certificate authority file. |
-| $HELM_KUBEASGROUPS | set the Groups to use for impersonation using a comma-separated list. |
-| $HELM_KUBEASUSER | set the Username to impersonate for the operation. |
-| $HELM_KUBECONTEXT | set the name of the kubeconfig context. |
-| $HELM_KUBETOKEN | set the Bearer KubeToken used for authentication. |
-| $HELM_KUBEINSECURE_SKIP_TLS_VERIFY | indicate if the Kubernetes API server's certificate validation should be skipped (insecure) |
-| $HELM_KUBETLS_SERVER_NAME | set the server name used to validate the Kubernetes API server certificate |
-| $HELM_BURST_LIMIT | set the default burst limit in the case the server contains many CRDs (default 100, -1 to disable)|
+| Name | Description |
+|------------------------------------|------------------------------------------------------------------------------------------------------------|
+| $HELM_CACHE_HOME | set an alternative location for storing cached files. |
+| $HELM_CONFIG_HOME | set an alternative location for storing Helm configuration. |
+| $HELM_DATA_HOME | set an alternative location for storing Helm data. |
+| $HELM_DEBUG | indicate whether or not Helm is running in Debug mode |
+| $HELM_DRIVER | set the backend storage driver. Values are: configmap, secret, memory, sql. |
+| $HELM_DRIVER_SQL_CONNECTION_STRING | set the connection string the SQL storage driver should use. |
+| $HELM_MAX_HISTORY | set the maximum number of helm release history. |
+| $HELM_NAMESPACE | set the namespace used for the helm operations. |
+| $HELM_NO_PLUGINS | disable plugins. Set HELM_NO_PLUGINS=1 to disable plugins. |
+| $HELM_PLUGINS | set the path to the plugins directory |
+| $HELM_REGISTRY_CONFIG | set the path to the registry config file. |
+| $HELM_REPOSITORY_CACHE | set the path to the repository cache directory |
+| $HELM_REPOSITORY_CONFIG | set the path to the repositories file. |
+| $KUBECONFIG | set an alternative Kubernetes configuration file (default "~/.kube/config") |
+| $HELM_KUBEAPISERVER | set the Kubernetes API Server Endpoint for authentication |
+| $HELM_KUBECAFILE | set the Kubernetes certificate authority file. |
+| $HELM_KUBEASGROUPS | set the Groups to use for impersonation using a comma-separated list. |
+| $HELM_KUBEASUSER | set the Username to impersonate for the operation. |
+| $HELM_KUBECONTEXT | set the name of the kubeconfig context. |
+| $HELM_KUBETOKEN | set the Bearer KubeToken used for authentication. |
+| $HELM_KUBEINSECURE_SKIP_TLS_VERIFY | indicate if the Kubernetes API server's certificate validation should be skipped (insecure) |
+| $HELM_KUBETLS_SERVER_NAME | set the server name used to validate the Kubernetes API server certificate |
+| $HELM_BURST_LIMIT | set the default burst limit in the case the server contains many CRDs (default 100, -1 to disable) |
+| $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values |
+| $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) |
+| $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) |
Helm stores cache, configuration, and data based on the following configuration order:
@@ -86,20 +99,108 @@ By default, the default directories depend on the Operating System. The defaults
| Windows | %TEMP%\helm | %APPDATA%\helm | %APPDATA%\helm |
`
-func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string) (*cobra.Command, error) {
+var settings = cli.New()
+
+func NewRootCmd(out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) {
+ actionConfig := new(action.Configuration)
+ cmd, err := newRootCmdWithConfig(actionConfig, out, args, logSetup)
+ if err != nil {
+ return nil, err
+ }
+ cobra.OnInitialize(func() {
+ helmDriver := os.Getenv("HELM_DRIVER")
+ if err := actionConfig.Init(settings.RESTClientGetter(), settings.Namespace(), helmDriver); err != nil {
+ log.Fatal(err)
+ }
+ if helmDriver == "memory" {
+ loadReleasesInMemory(actionConfig)
+ }
+ actionConfig.SetHookOutputFunc(hookOutputWriter)
+ })
+ return cmd, nil
+}
+
+// SetupLogging sets up Helm logging used by the Helm client.
+// This function is passed to the NewRootCmd function to enable logging. Any other
+// application that uses the NewRootCmd function to setup all the Helm commands may
+// use this function to setup logging or their own. Using a custom logging setup function
+// enables applications using Helm commands to integrate with their existing logging
+// system.
+// The debug argument is the value if Helm is set for debugging (i.e. --debug flag)
+func SetupLogging(debug bool) {
+ logger := logging.NewLogger(func() bool { return debug })
+ slog.SetDefault(logger)
+}
+
+// configureColorOutput configures the color output based on the ColorMode setting
+func configureColorOutput(settings *cli.EnvSettings) {
+ switch settings.ColorMode {
+ case "never":
+ color.NoColor = true
+ case "always":
+ color.NoColor = false
+ case "auto":
+ // Let fatih/color handle automatic detection
+ // It will check if output is a terminal and NO_COLOR env var
+ // We don't need to do anything here
+ }
+}
+
+func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, args []string, logSetup func(bool)) (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "helm",
Short: "The Helm package manager for Kubernetes.",
Long: globalUsage,
SilenceUsage: true,
+ PersistentPreRun: func(_ *cobra.Command, _ []string) {
+ if err := startProfiling(); err != nil {
+ log.Printf("Warning: Failed to start profiling: %v", err)
+ }
+ },
+ PersistentPostRun: func(_ *cobra.Command, _ []string) {
+ if err := stopProfiling(); err != nil {
+ log.Printf("Warning: Failed to stop profiling: %v", err)
+ }
+ },
}
+
flags := cmd.PersistentFlags()
settings.AddFlags(flags)
addKlogFlags(flags)
+ // We can safely ignore any errors that flags.Parse encounters since
+ // those errors will be caught later during the call to cmd.Execution.
+ // This call is required to gather configuration information prior to
+ // execution.
+ flags.ParseErrorsWhitelist.UnknownFlags = true
+ flags.Parse(args)
+
+ logSetup(settings.Debug)
+
+ // Validate color mode setting
+ switch settings.ColorMode {
+ case "never", "auto", "always":
+ // Valid color mode
+ default:
+ return nil, fmt.Errorf("invalid color mode %q: must be one of: never, auto, always", settings.ColorMode)
+ }
+
+ // Configure color output based on ColorMode setting
+ configureColorOutput(settings)
+
+ // Setup shell completion for the color flag
+ _ = cmd.RegisterFlagCompletionFunc("color", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+ return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp
+ })
+
+ // Setup shell completion for the colour flag
+ _ = cmd.RegisterFlagCompletionFunc("colour", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
+ return []string{"never", "auto", "always"}, cobra.ShellCompDirectiveNoFileComp
+ })
+
// Setup shell completion for the namespace flag
- err := cmd.RegisterFlagCompletionFunc("namespace", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("namespace", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
if client, err := actionConfig.KubernetesClientSet(); err == nil {
// Choose a long enough timeout that the user notices something is not working
// but short enough that the user is not made to wait very long
@@ -122,7 +223,7 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string
}
// Setup shell completion for the kube-context flag
- err = cmd.RegisterFlagCompletionFunc("kube-context", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err = cmd.RegisterFlagCompletionFunc("kube-context", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
cobra.CompDebugln("About to get the different kube-contexts", settings.Debug)
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
@@ -145,14 +246,7 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string
log.Fatal(err)
}
- // We can safely ignore any errors that flags.Parse encounters since
- // those errors will be caught later during the call to cmd.Execution.
- // This call is required to gather configuration information prior to
- // execution.
- flags.ParseErrorsWhitelist.UnknownFlags = true
- flags.Parse(args)
-
- registryClient, err := newDefaultRegistryClient(false)
+ registryClient, err := newDefaultRegistryClient(false, "", "")
if err != nil {
return nil, err
}
@@ -166,7 +260,7 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string
newPullCmd(actionConfig, out),
newShowCmd(actionConfig, out),
newLintCmd(out),
- newPackageCmd(actionConfig, out),
+ newPackageCmd(out),
newRepoCmd(out),
newSearchCmd(out),
newVerifyCmd(out),
@@ -197,11 +291,8 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string
newPushCmd(actionConfig, out),
)
- // Find and add plugins
- loadPlugins(cmd, out)
-
- // Check permissions on critical files
- checkPerms()
+ // Find and add CLI plugins
+ loadCLIPlugins(cmd, out)
// Check for expired repositories
checkForExpiredRepos(settings.RepositoryConfig)
@@ -209,6 +300,49 @@ func newRootCmd(actionConfig *action.Configuration, out io.Writer, args []string
return cmd, nil
}
+// This function loads releases into the memory storage if the
+// environment variable is properly set.
+func loadReleasesInMemory(actionConfig *action.Configuration) {
+ filePaths := strings.Split(os.Getenv("HELM_MEMORY_DRIVER_DATA"), ":")
+ if len(filePaths) == 0 {
+ return
+ }
+
+ store := actionConfig.Releases
+ mem, ok := store.Driver.(*driver.Memory)
+ if !ok {
+ // For an unexpected reason we are not dealing with the memory storage driver.
+ return
+ }
+
+ actionConfig.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard}
+
+ for _, path := range filePaths {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatal("Unable to read memory driver data", err)
+ }
+
+ releases := []*release.Release{}
+ if err := yaml.Unmarshal(b, &releases); err != nil {
+ log.Fatal("Unable to unmarshal memory driver data: ", err)
+ }
+
+ for _, rel := range releases {
+ if err := store.Create(rel); err != nil {
+ log.Fatal(err)
+ }
+ }
+ }
+ // Must reset namespace to the proper one
+ mem.SetNamespace(settings.Namespace())
+}
+
+// hookOutputWriter provides the writer for writing hook logs.
+func hookOutputWriter(_, _, _ string) io.Writer {
+ return log.Writer()
+}
+
func checkForExpiredRepos(repofile string) {
expiredRepos := []struct {
@@ -229,7 +363,7 @@ func checkForExpiredRepos(repofile string) {
}
// parse repo file.
- // Ignore the error because it is okay for a repo file to be unparseable at this
+ // Ignore the error because it is okay for a repo file to be unparsable at this
// stage. Later checks will trap the error and respond accordingly.
repoFile, err := repo.LoadFile(repofile)
if err != nil {
@@ -257,27 +391,30 @@ func checkForExpiredRepos(repofile string) {
}
-func newRegistryClient(certFile, keyFile, caFile string, insecureSkipTLSverify, plainHTTP bool) (*registry.Client, error) {
+func newRegistryClient(
+ 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)
+ registryClient, err := newRegistryClientWithTLS(certFile, keyFile, caFile, insecureSkipTLSverify, username, password)
if err != nil {
return nil, err
}
return registryClient, nil
}
- registryClient, err := newDefaultRegistryClient(plainHTTP)
+ registryClient, err := newDefaultRegistryClient(plainHTTP, username, password)
if err != nil {
return nil, err
}
return registryClient, nil
}
-func newDefaultRegistryClient(plainHTTP bool) (*registry.Client, error) {
+func newDefaultRegistryClient(plainHTTP bool, username, password string) (*registry.Client, error) {
opts := []registry.ClientOption{
registry.ClientOptDebug(settings.Debug),
registry.ClientOptEnableCache(true),
registry.ClientOptWriter(os.Stderr),
registry.ClientOptCredentialsFile(settings.RegistryConfig),
+ registry.ClientOptBasicAuth(username, password),
}
if plainHTTP {
opts = append(opts, registry.ClientOptPlainHTTP())
@@ -291,10 +428,32 @@ func newDefaultRegistryClient(plainHTTP bool) (*registry.Client, error) {
return registryClient, nil
}
-func newRegistryClientWithTLS(certFile, keyFile, caFile string, insecureSkipTLSverify bool) (*registry.Client, error) {
+func newRegistryClientWithTLS(
+ certFile, keyFile, caFile string, insecureSkipTLSverify bool, username, password string,
+) (*registry.Client, error) {
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify),
+ tlsutil.WithCertKeyPairFiles(certFile, keyFile),
+ tlsutil.WithCAFile(caFile),
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("can't create TLS config for client: %w", err)
+ }
+
// Create a new registry client
- registryClient, err := registry.NewRegistryClientWithTLS(os.Stderr, certFile, keyFile, caFile, insecureSkipTLSverify,
- settings.RegistryConfig, settings.Debug,
+ registryClient, err := registry.NewClient(
+ registry.ClientOptDebug(settings.Debug),
+ registry.ClientOptEnableCache(true),
+ registry.ClientOptWriter(os.Stderr),
+ registry.ClientOptCredentialsFile(settings.RegistryConfig),
+ registry.ClientOptHTTPClient(&http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConf,
+ Proxy: http.ProxyFromEnvironment,
+ },
+ }),
+ registry.ClientOptBasicAuth(username, password),
)
if err != nil {
return nil, err
diff --git a/cmd/helm/root_test.go b/pkg/cmd/root_test.go
similarity index 96%
rename from cmd/helm/root_test.go
rename to pkg/cmd/root_test.go
index 65e6d66c7..84e3d9ed2 100644
--- a/cmd/helm/root_test.go
+++ b/pkg/cmd/root_test.go
@@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"os"
"path/filepath"
"testing"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
func TestRootCmd(t *testing.T) {
@@ -80,7 +80,7 @@ func TestRootCmd(t *testing.T) {
ensure.HelmHome(t)
for k, v := range tt.envvars {
- os.Setenv(k, v)
+ t.Setenv(k, v)
}
if _, _, err := executeActionCommand(tt.args); err != nil {
diff --git a/cmd/helm/search.go b/pkg/cmd/search.go
similarity index 98%
rename from cmd/helm/search.go
rename to pkg/cmd/search.go
index 6c62d5d2e..4d110286d 100644
--- a/cmd/helm/search.go
+++ b/pkg/cmd/search.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"io"
diff --git a/cmd/helm/search/search.go b/pkg/cmd/search/search.go
similarity index 99%
rename from cmd/helm/search/search.go
rename to pkg/cmd/search/search.go
index ac29b27c2..f9e229154 100644
--- a/cmd/helm/search/search.go
+++ b/pkg/cmd/search/search.go
@@ -31,7 +31,7 @@ import (
"github.com/Masterminds/semver/v3"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo"
)
// Result is a search result.
diff --git a/cmd/helm/search/search_test.go b/pkg/cmd/search/search_test.go
similarity index 98%
rename from cmd/helm/search/search_test.go
rename to pkg/cmd/search/search_test.go
index dc82ca3d9..7a4ba786b 100644
--- a/cmd/helm/search/search_test.go
+++ b/pkg/cmd/search/search_test.go
@@ -20,8 +20,8 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/repo"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/repo"
)
func TestSortScore(t *testing.T) {
@@ -101,7 +101,7 @@ var indexfileEntries = map[string]repo.ChartVersions{
},
}
-func loadTestIndex(t *testing.T, all bool) *Index {
+func loadTestIndex(_ *testing.T, all bool) *Index {
i := NewIndex()
i.AddRepo("testing", &repo.IndexFile{Entries: indexfileEntries}, all)
i.AddRepo("ztesting", &repo.IndexFile{Entries: map[string]repo.ChartVersions{
diff --git a/cmd/helm/search_hub.go b/pkg/cmd/search_hub.go
similarity index 95%
rename from cmd/helm/search_hub.go
rename to pkg/cmd/search_hub.go
index 1618a4c9f..cfeeec59b 100644
--- a/cmd/helm/search_hub.go
+++ b/pkg/cmd/search_hub.go
@@ -14,19 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"io"
+ "log/slog"
"strings"
"github.com/gosuri/uitable"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/internal/monocular"
- "helm.sh/helm/v3/pkg/cli/output"
+ "helm.sh/helm/v4/internal/monocular"
+ "helm.sh/helm/v4/pkg/cli/output"
)
const searchHubDesc = `
@@ -64,7 +64,7 @@ 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(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
return o.run(out, args)
},
}
@@ -83,13 +83,13 @@ func newSearchHubCmd(out io.Writer) *cobra.Command {
func (o *searchHubOptions) run(out io.Writer, args []string) error {
c, err := monocular.New(o.searchEndpoint)
if err != nil {
- return errors.Wrap(err, fmt.Sprintf("unable to create connection to %q", o.searchEndpoint))
+ return fmt.Errorf("unable to create connection to %q: %w", o.searchEndpoint, err)
}
q := strings.Join(args, " ")
results, err := c.Search(q)
if err != nil {
- debug("%s", err)
+ slog.Debug("search failed", slog.Any("error", err))
return fmt.Errorf("unable to perform search against %q", o.searchEndpoint)
}
diff --git a/cmd/helm/search_hub_test.go b/pkg/cmd/search_hub_test.go
similarity index 99%
rename from cmd/helm/search_hub_test.go
rename to pkg/cmd/search_hub_test.go
index 89ce2b3e5..8e056f771 100644
--- a/cmd/helm/search_hub_test.go
+++ b/pkg/cmd/search_hub_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -27,7 +27,7 @@ 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, r *http.Request) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, searchResult)
}))
defer ts.Close()
@@ -57,7 +57,7 @@ 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, r *http.Request) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, searchResult)
}))
defer ts.Close()
@@ -155,7 +155,7 @@ func TestSearchHubCmd_FailOnNoResponseTests(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup a mock search service
- ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, tt.response)
}))
defer ts.Close()
diff --git a/cmd/helm/search_repo.go b/pkg/cmd/search_repo.go
similarity index 95%
rename from cmd/helm/search_repo.go
rename to pkg/cmd/search_repo.go
index 2c6f17094..dffa0d1c4 100644
--- a/cmd/helm/search_repo.go
+++ b/pkg/cmd/search_repo.go
@@ -14,26 +14,27 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bufio"
"bytes"
+ "errors"
"fmt"
"io"
+ "log/slog"
"os"
"path/filepath"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/gosuri/uitable"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/search"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/search"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/repo"
)
const searchRepoDesc = `
@@ -81,7 +82,7 @@ func newSearchRepoCmd(out io.Writer) *cobra.Command {
Use: "repo [keyword]",
Short: "search repositories for a keyword in charts",
Long: searchRepoDesc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
o.repoFile = settings.RepositoryConfig
o.repoCacheDir = settings.RepositoryCache
return o.run(out, args)
@@ -130,17 +131,17 @@ func (o *searchRepoOptions) run(out io.Writer, args []string) error {
}
func (o *searchRepoOptions) setupSearchedVersion() {
- debug("Original chart version: %q", o.version)
+ slog.Debug("original chart version", "version", o.version)
if o.version != "" {
return
}
if o.devel { // search for releases and prereleases (alpha, beta, and release candidate releases).
- debug("setting version to >0.0.0-0")
+ slog.Debug("setting version to >0.0.0-0")
o.version = ">0.0.0-0"
- } else { // search only for stable releases, prerelease versions will be skip
- debug("setting version to >0.0.0")
+ } else { // search only for stable releases, prerelease versions will be skipped
+ slog.Debug("setting version to >0.0.0")
o.version = ">0.0.0"
}
}
@@ -152,7 +153,7 @@ func (o *searchRepoOptions) applyConstraint(res []*search.Result) ([]*search.Res
constraint, err := semver.NewConstraint(o.version)
if err != nil {
- return res, errors.Wrap(err, "an invalid version/constraint format")
+ return res, fmt.Errorf("an invalid version/constraint format: %w", err)
}
data := res[:0]
@@ -189,8 +190,7 @@ func (o *searchRepoOptions) buildIndex() (*search.Index, error) {
f := filepath.Join(o.repoCacheDir, helmpath.CacheIndexFile(n))
ind, err := repo.LoadIndexFile(f)
if err != nil {
- warning("Repo %q is corrupt or missing. Try 'helm repo update'.", n)
- warning("%s", err)
+ slog.Warn("repo is corrupt or missing", "repo", n, slog.Any("error", err))
continue
}
diff --git a/cmd/helm/search_repo_test.go b/pkg/cmd/search_repo_test.go
similarity index 99%
rename from cmd/helm/search_repo_test.go
rename to pkg/cmd/search_repo_test.go
index 9039842f0..e7f104e05 100644
--- a/cmd/helm/search_repo_test.go
+++ b/pkg/cmd/search_repo_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/cmd/helm/search_test.go b/pkg/cmd/search_test.go
similarity index 98%
rename from cmd/helm/search_test.go
rename to pkg/cmd/search_test.go
index 6cf845b06..a0e5d84cb 100644
--- a/cmd/helm/search_test.go
+++ b/pkg/cmd/search_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import "testing"
diff --git a/cmd/helm/show.go b/pkg/cmd/show.go
similarity index 81%
rename from cmd/helm/show.go
rename to pkg/cmd/show.go
index 28eb9756d..1c7e7be44 100644
--- a/cmd/helm/show.go
+++ b/pkg/cmd/show.go
@@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
"io"
"log"
+ "log/slog"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const showDesc = `
@@ -57,21 +58,20 @@ of the CustomResourceDefinition files
`
func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
- client := action.NewShowWithConfig(action.ShowAll, cfg)
+ client := action.NewShow(action.ShowAll, cfg)
showCommand := &cobra.Command{
- Use: "show",
- Short: "show information of a chart",
- Aliases: []string{"inspect"},
- Long: showDesc,
- Args: require.NoArgs,
- ValidArgsFunction: noCompletions, // Disable file completion
+ Use: "show",
+ Short: "show information of a chart",
+ Aliases: []string{"inspect"},
+ Long: showDesc,
+ Args: require.NoArgs,
}
// Function providing dynamic auto-completion
- validArgsFunc := func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ validArgsFunc := func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListCharts(toComplete, true)
}
@@ -82,7 +82,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: showAllDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: validArgsFunc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.OutputFormat = action.ShowAll
err := addRegistryClient(client)
if err != nil {
@@ -103,7 +103,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: showValuesDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: validArgsFunc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.OutputFormat = action.ShowValues
err := addRegistryClient(client)
if err != nil {
@@ -124,7 +124,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: showChartDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: validArgsFunc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.OutputFormat = action.ShowChart
err := addRegistryClient(client)
if err != nil {
@@ -145,7 +145,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: readmeChartDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: validArgsFunc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.OutputFormat = action.ShowReadme
err := addRegistryClient(client)
if err != nil {
@@ -166,7 +166,7 @@ func newShowCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Long: showCRDsDesc,
Args: require.ExactArgs(1),
ValidArgsFunction: validArgsFunc,
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.OutputFormat = action.ShowCRDs
err := addRegistryClient(client)
if err != nil {
@@ -199,7 +199,7 @@ func addShowFlags(subCmd *cobra.Command, client *action.Show) {
}
addChartPathOptionsFlags(f, &client.ChartPathOptions)
- err := subCmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := subCmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
@@ -212,13 +212,13 @@ func addShowFlags(subCmd *cobra.Command, client *action.Show) {
}
func runShow(args []string, client *action.Show) (string, error) {
- debug("Original chart version: %q", client.Version)
+ slog.Debug("original chart version", "version", client.Version)
if client.Version == "" && client.Devel {
- debug("setting version to >0.0.0-0")
+ slog.Debug("setting version to >0.0.0-0")
client.Version = ">0.0.0-0"
}
- cp, err := client.ChartPathOptions.LocateChart(args[0], settings)
+ cp, err := client.LocateChart(args[0], settings)
if err != nil {
return "", err
}
@@ -227,7 +227,7 @@ func runShow(args []string, client *action.Show) (string, error) {
func addRegistryClient(client *action.Show) error {
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
- client.InsecureSkipTLSverify, client.PlainHTTP)
+ client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
diff --git a/cmd/helm/show_test.go b/pkg/cmd/show_test.go
similarity index 95%
rename from cmd/helm/show_test.go
rename to pkg/cmd/show_test.go
index 93ec08d0f..5ccb4bcad 100644
--- a/cmd/helm/show_test.go
+++ b/pkg/cmd/show_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -22,14 +22,14 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestShowPreReleaseChart(t *testing.T) {
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/testcharts/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"),
+ )
defer srv.Stop()
if err := srv.LinkIndices(); err != nil {
@@ -64,14 +64,17 @@ func TestShowPreReleaseChart(t *testing.T) {
},
}
+ contentTmp := t.TempDir()
+
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outdir := srv.Root()
- cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s",
+ cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s --content-cache %s",
tt.args,
tt.flags,
filepath.Join(outdir, "repositories.yaml"),
outdir,
+ contentTmp,
)
//_, out, err := executeActionCommand(cmd)
_, _, err := executeActionCommand(cmd)
diff --git a/cmd/helm/status.go b/pkg/cmd/status.go
similarity index 79%
rename from cmd/helm/status.go
rename to pkg/cmd/status.go
index 850862cd5..aa836f9f3 100644
--- a/cmd/helm/status.go
+++ b/pkg/cmd/status.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
@@ -28,11 +28,12 @@ import (
"k8s.io/kubectl/pkg/cmd/get"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/release"
+ coloroutput "helm.sh/helm/v4/internal/cli/output"
+ "helm.sh/helm/v4/pkg/action"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// NOTE: Keep the list of statuses up-to-date with pkg/release/status.go.
@@ -43,8 +44,8 @@ The status consists of:
- k8s namespace in which the release lives
- state of the release (can be: unknown, deployed, uninstalled, superseded, failed, uninstalling, pending-install, pending-upgrade or pending-rollback)
- revision of the release
-- description of the release (can be completion message or error message, need to enable --show-desc)
-- list of resources that this release consists of (need to enable --show-resources)
+- description of the release (can be completion message or error message)
+- list of resources that this release consists of
- details on last test suite run, if applicable
- additional notes provided by the chart
`
@@ -58,14 +59,13 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "display the status of the named release",
Long: statusHelp,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
}
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
-
+ RunE: func(_ *cobra.Command, args []string) error {
// When the output format is a table the resources should be fetched
// and displayed as a table. When YAML or JSON the resources will be
// returned. This mirrors the handling in kubectl.
@@ -80,7 +80,13 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
// strip chart metadata from the output
rel.Chart = nil
- return outfmt.Write(out, &statusPrinter{rel, false, client.ShowDescription, client.ShowResources, false})
+ return outfmt.Write(out, &statusPrinter{
+ release: rel,
+ debug: false,
+ showMetadata: false,
+ hideNotes: false,
+ noColor: settings.ShouldDisableColor(),
+ })
},
}
@@ -88,31 +94,27 @@ func newStatusCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.IntVar(&client.Version, "revision", 0, "if set, display the status of the named release with revision")
- err := cmd.RegisterFlagCompletionFunc("revision", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("revision", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 1 {
return compListRevisions(toComplete, cfg, args[0])
}
return nil, cobra.ShellCompDirectiveNoFileComp
})
-
if err != nil {
log.Fatal(err)
}
bindOutputFlag(cmd, &outfmt)
- f.BoolVar(&client.ShowDescription, "show-desc", false, "if set, display the description message of the named release")
-
- f.BoolVar(&client.ShowResources, "show-resources", false, "if set, display the resources of the named release")
return cmd
}
type statusPrinter struct {
- release *release.Release
- debug bool
- showDescription bool
- showResources bool
- showMetadata bool
+ release *release.Release
+ debug bool
+ showMetadata bool
+ hideNotes bool
+ noColor bool
}
func (s statusPrinter) WriteJSON(out io.Writer) error {
@@ -131,19 +133,17 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
if !s.release.Info.LastDeployed.IsZero() {
_, _ = fmt.Fprintf(out, "LAST DEPLOYED: %s\n", s.release.Info.LastDeployed.Format(time.ANSIC))
}
- _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", s.release.Namespace)
- _, _ = fmt.Fprintf(out, "STATUS: %s\n", s.release.Info.Status.String())
+ _, _ = fmt.Fprintf(out, "NAMESPACE: %s\n", coloroutput.ColorizeNamespace(s.release.Namespace, s.noColor))
+ _, _ = fmt.Fprintf(out, "STATUS: %s\n", coloroutput.ColorizeStatus(s.release.Info.Status, s.noColor))
_, _ = fmt.Fprintf(out, "REVISION: %d\n", s.release.Version)
if s.showMetadata {
_, _ = fmt.Fprintf(out, "CHART: %s\n", s.release.Chart.Metadata.Name)
_, _ = fmt.Fprintf(out, "VERSION: %s\n", s.release.Chart.Metadata.Version)
_, _ = fmt.Fprintf(out, "APP_VERSION: %s\n", s.release.Chart.Metadata.AppVersion)
}
- if s.showDescription {
- _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description)
- }
+ _, _ = fmt.Fprintf(out, "DESCRIPTION: %s\n", s.release.Info.Description)
- if s.showResources && s.release.Info.Resources != nil && len(s.release.Info.Resources) > 0 {
+ if len(s.release.Info.Resources) > 0 {
buf := new(bytes.Buffer)
printFlags := get.NewHumanPrintFlags()
typePrinter, _ := printFlags.ToPrinter("")
@@ -219,7 +219,8 @@ func (s statusPrinter) WriteTable(out io.Writer) error {
_, _ = fmt.Fprintf(out, "MANIFEST:\n%s\n", s.release.Manifest)
}
- if len(s.release.Info.Notes) > 0 {
+ // Hide notes from output - option in install and upgrades
+ if !s.hideNotes && len(s.release.Info.Notes) > 0 {
_, _ = fmt.Fprintf(out, "NOTES:\n%s\n", strings.TrimSpace(s.release.Info.Notes))
}
return nil
diff --git a/cmd/helm/status_test.go b/pkg/cmd/status_test.go
similarity index 95%
rename from cmd/helm/status_test.go
rename to pkg/cmd/status_test.go
index 6722bf949..cb4e23c59 100644
--- a/cmd/helm/status_test.go
+++ b/pkg/cmd/status_test.go
@@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
"time"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
func TestStatusCmd(t *testing.T) {
@@ -46,7 +46,7 @@ func TestStatusCmd(t *testing.T) {
}),
}, {
name: "get status of a deployed release, with desc",
- cmd: "status --show-desc flummoxed-chickadee",
+ cmd: "status flummoxed-chickadee",
golden: "output/status-with-desc.txt",
rels: releasesMockWithStatus(&release.Info{
Status: release.StatusDeployed,
@@ -70,7 +70,7 @@ func TestStatusCmd(t *testing.T) {
}),
}, {
name: "get status of a deployed release with resources",
- cmd: "status --show-resources flummoxed-chickadee",
+ cmd: "status flummoxed-chickadee",
golden: "output/status-with-resources.txt",
rels: releasesMockWithStatus(
&release.Info{
@@ -79,7 +79,7 @@ func TestStatusCmd(t *testing.T) {
),
}, {
name: "get status of a deployed release with resources in json",
- cmd: "status --show-resources flummoxed-chickadee -o json",
+ cmd: "status flummoxed-chickadee -o json",
golden: "output/status-with-resources.json",
rels: releasesMockWithStatus(
&release.Info{
diff --git a/cmd/helm/template.go b/pkg/cmd/template.go
similarity index 89%
rename from cmd/helm/template.go
rename to pkg/cmd/template.go
index a16cbc76e..ac20a45b3 100644
--- a/cmd/helm/template.go
+++ b/pkg/cmd/template.go
@@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"bytes"
+ "errors"
"fmt"
"io"
+ "io/fs"
"os"
"path"
"path/filepath"
"regexp"
+ "slices"
"sort"
"strings"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/releaseutil"
+ "helm.sh/helm/v4/pkg/action"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ releaseutil "helm.sh/helm/v4/pkg/release/util"
)
const templateDesc = `
@@ -61,7 +64,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "locally render templates",
Long: templateDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compInstall(args, toComplete, client)
},
RunE: func(_ *cobra.Command, args []string) error {
@@ -74,7 +77,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
- client.InsecureSkipTLSverify, client.PlainHTTP)
+ client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
@@ -198,7 +201,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVar(&skipTests, "skip-tests", false, "skip tests from templated output")
f.BoolVar(&client.IsUpgrade, "is-upgrade", false, "set .Release.IsUpgrade instead of .Release.IsInstall")
f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion")
- f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions")
+ f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)")
f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.")
bindPostRenderFlag(cmd, &client.PostRenderer)
@@ -206,12 +209,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
func isTestHook(h *release.Hook) bool {
- for _, e := range h.Events {
- if e == release.HookTest {
- return true
- }
- }
- return false
+ return slices.Contains(h.Events, release.HookTest)
}
// The following functions (writeToFile, createOrOpenFile, and ensureDirectoryForFile)
@@ -219,7 +217,7 @@ func isTestHook(h *release.Hook) bool {
// bug introduced by #8156. As part of the todo to refactor renderResources
// this duplicate code should be removed. It is added here so that the API
// surface area is as minimally impacted as possible in fixing the issue.
-func writeToFile(outputDir string, name string, data string, append bool) error {
+func writeToFile(outputDir string, name string, data string, appendData bool) error {
outfileName := strings.Join([]string{outputDir, name}, string(filepath.Separator))
err := ensureDirectoryForFile(outfileName)
@@ -227,14 +225,14 @@ func writeToFile(outputDir string, name string, data string, append bool) error
return err
}
- f, err := createOrOpenFile(outfileName, append)
+ f, err := createOrOpenFile(outfileName, appendData)
if err != nil {
return err
}
defer f.Close()
- _, err = f.WriteString(fmt.Sprintf("---\n# Source: %s\n%s\n", name, data))
+ _, err = fmt.Fprintf(f, "---\n# Source: %s\n%s\n", name, data)
if err != nil {
return err
@@ -244,8 +242,8 @@ func writeToFile(outputDir string, name string, data string, append bool) error
return nil
}
-func createOrOpenFile(filename string, append bool) (*os.File, error) {
- if append {
+func createOrOpenFile(filename string, appendData bool) (*os.File, error) {
+ if appendData {
return os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0600)
}
return os.Create(filename)
@@ -254,7 +252,7 @@ func createOrOpenFile(filename string, append bool) (*os.File, error) {
func ensureDirectoryForFile(file string) error {
baseDir := path.Dir(file)
_, err := os.Stat(baseDir)
- if err != nil && !os.IsNotExist(err) {
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
diff --git a/cmd/helm/template_test.go b/pkg/cmd/template_test.go
similarity index 96%
rename from cmd/helm/template_test.go
rename to pkg/cmd/template_test.go
index e5b939879..5bcccf5d0 100644
--- a/cmd/helm/template_test.go
+++ b/pkg/cmd/template_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -83,7 +83,12 @@ func TestTemplateCmd(t *testing.T) {
},
{
name: "check kube api versions",
- cmd: fmt.Sprintf("template --api-versions helm.k8s.io/test '%s'", chartPath),
+ cmd: fmt.Sprintf("template --api-versions helm.k8s.io/test,helm.k8s.io/test2 '%s'", chartPath),
+ golden: "output/template-with-api-version.txt",
+ },
+ {
+ name: "check kube api versions",
+ cmd: fmt.Sprintf("template --api-versions helm.k8s.io/test --api-versions helm.k8s.io/test2 '%s'", chartPath),
golden: "output/template-with-api-version.txt",
},
{
diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/plugins/fullenv/completion.yaml
rename to pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/completion.yaml
diff --git a/cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh
rename to pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/fullenv.sh
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
new file mode 100644
index 000000000..8b874da1d
--- /dev/null
+++ b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml
@@ -0,0 +1,10 @@
+name: fullenv
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "show env vars"
+ longHelp: "show all env vars"
+ ignoreFlags: false
+runtimeConfig:
+ command: "$HELM_PLUGIN_DIR/fullenv.sh"
diff --git a/cmd/helm/testdata/helm home with space/helm/repositories.yaml b/pkg/cmd/testdata/helm home with space/helm/repositories.yaml
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/repositories.yaml
rename to pkg/cmd/testdata/helm home with space/helm/repositories.yaml
diff --git a/internal/ignore/testdata/cargo/a.txt b/pkg/cmd/testdata/helm home with space/helm/repository/test-name-charts.txt
similarity index 100%
rename from internal/ignore/testdata/cargo/a.txt
rename to pkg/cmd/testdata/helm home with space/helm/repository/test-name-charts.txt
diff --git a/cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml b/pkg/cmd/testdata/helm home with space/helm/repository/test-name-index.yaml
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/repository/test-name-index.yaml
rename to pkg/cmd/testdata/helm home with space/helm/repository/test-name-index.yaml
diff --git a/cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml b/pkg/cmd/testdata/helm home with space/helm/repository/testing-index.yaml
similarity index 100%
rename from cmd/helm/testdata/helm home with space/helm/repository/testing-index.yaml
rename to pkg/cmd/testdata/helm home with space/helm/repository/testing-index.yaml
diff --git a/cmd/helm/testdata/helm-test-key.pub b/pkg/cmd/testdata/helm-test-key.pub
similarity index 100%
rename from cmd/helm/testdata/helm-test-key.pub
rename to pkg/cmd/testdata/helm-test-key.pub
diff --git a/cmd/helm/testdata/helm-test-key.secret b/pkg/cmd/testdata/helm-test-key.secret
similarity index 100%
rename from cmd/helm/testdata/helm-test-key.secret
rename to pkg/cmd/testdata/helm-test-key.secret
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/args/args.sh b/pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/args/args.sh
rename to pkg/cmd/testdata/helmhome/helm/plugins/args/args.sh
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/args/plugin.complete b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.complete
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/args/plugin.complete
rename to pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.complete
diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml
new file mode 100644
index 000000000..57312cbfa
--- /dev/null
+++ b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml
@@ -0,0 +1,10 @@
+name: args
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "echo args"
+ longHelp: "This echos args"
+ ignoreFlags: false
+runtimeConfig:
+ command: "$HELM_PLUGIN_DIR/args.sh"
diff --git a/internal/ignore/testdata/cargo/b.txt b/pkg/cmd/testdata/helmhome/helm/plugins/echo/completion.yaml
similarity index 100%
rename from internal/ignore/testdata/cargo/b.txt
rename to pkg/cmd/testdata/helmhome/helm/plugins/echo/completion.yaml
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.complete
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/echo/plugin.complete
rename to pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.complete
diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml
new file mode 100644
index 000000000..544efa85e
--- /dev/null
+++ b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml
@@ -0,0 +1,10 @@
+name: echo
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "echo stuff"
+ longHelp: "This echos stuff"
+ ignoreFlags: false
+runtimeConfig:
+ command: "echo hello"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/env/completion.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/env/completion.yaml
rename to pkg/cmd/testdata/helmhome/helm/plugins/env/completion.yaml
diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml
new file mode 100644
index 000000000..d7a4c229c
--- /dev/null
+++ b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml
@@ -0,0 +1,10 @@
+name: env
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "env stuff"
+ longHelp: "show the env"
+ ignoreFlags: false
+runtimeConfig:
+ command: "echo $HELM_PLUGIN_NAME"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/completion.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/exitwith/completion.yaml
rename to pkg/cmd/testdata/helmhome/helm/plugins/exitwith/completion.yaml
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/exitwith/exitwith.sh
rename to pkg/cmd/testdata/helmhome/helm/plugins/exitwith/exitwith.sh
diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml
new file mode 100644
index 000000000..06a350f83
--- /dev/null
+++ b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml
@@ -0,0 +1,10 @@
+name: exitwith
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "exitwith code"
+ longHelp: "This exits with the specified exit code"
+ ignoreFlags: false
+runtimeConfig:
+ command: "$HELM_PLUGIN_DIR/exitwith.sh"
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/fullenv/completion.yaml
rename to pkg/cmd/testdata/helmhome/helm/plugins/fullenv/completion.yaml
diff --git a/cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/plugins/fullenv/fullenv.sh
rename to pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh
diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml
new file mode 100644
index 000000000..8b874da1d
--- /dev/null
+++ b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml
@@ -0,0 +1,10 @@
+name: fullenv
+type: cli/v1
+apiVersion: v1
+runtime: subprocess
+config:
+ shortHelp: "show env vars"
+ longHelp: "show all env vars"
+ ignoreFlags: false
+runtimeConfig:
+ command: "$HELM_PLUGIN_DIR/fullenv.sh"
diff --git a/cmd/helm/testdata/helmhome/helm/repositories.yaml b/pkg/cmd/testdata/helmhome/helm/repositories.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/repositories.yaml
rename to pkg/cmd/testdata/helmhome/helm/repositories.yaml
diff --git a/internal/ignore/testdata/cargo/c.txt b/pkg/cmd/testdata/helmhome/helm/repository/test-name-charts.txt
similarity index 100%
rename from internal/ignore/testdata/cargo/c.txt
rename to pkg/cmd/testdata/helmhome/helm/repository/test-name-charts.txt
diff --git a/cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml b/pkg/cmd/testdata/helmhome/helm/repository/test-name-index.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/repository/test-name-index.yaml
rename to pkg/cmd/testdata/helmhome/helm/repository/test-name-index.yaml
diff --git a/cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml b/pkg/cmd/testdata/helmhome/helm/repository/testing-index.yaml
similarity index 100%
rename from cmd/helm/testdata/helmhome/helm/repository/testing-index.yaml
rename to pkg/cmd/testdata/helmhome/helm/repository/testing-index.yaml
diff --git a/cmd/helm/testdata/output/chart-with-subchart-update.txt b/pkg/cmd/testdata/output/chart-with-subchart-update.txt
similarity index 82%
rename from cmd/helm/testdata/output/chart-with-subchart-update.txt
rename to pkg/cmd/testdata/output/chart-with-subchart-update.txt
index a4135c782..5b2083e1d 100644
--- a/cmd/helm/testdata/output/chart-with-subchart-update.txt
+++ b/pkg/cmd/testdata/output/chart-with-subchart-update.txt
@@ -3,6 +3,7 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
PARENT NOTES
diff --git a/cmd/helm/testdata/output/dependency-list-archive.txt b/pkg/cmd/testdata/output/dependency-list-archive.txt
similarity index 100%
rename from cmd/helm/testdata/output/dependency-list-archive.txt
rename to pkg/cmd/testdata/output/dependency-list-archive.txt
diff --git a/cmd/helm/testdata/output/dependency-list-no-chart-linux.txt b/pkg/cmd/testdata/output/dependency-list-no-chart-linux.txt
similarity index 100%
rename from cmd/helm/testdata/output/dependency-list-no-chart-linux.txt
rename to pkg/cmd/testdata/output/dependency-list-no-chart-linux.txt
diff --git a/cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt b/pkg/cmd/testdata/output/dependency-list-no-requirements-linux.txt
similarity index 100%
rename from cmd/helm/testdata/output/dependency-list-no-requirements-linux.txt
rename to pkg/cmd/testdata/output/dependency-list-no-requirements-linux.txt
diff --git a/cmd/helm/testdata/output/dependency-list.txt b/pkg/cmd/testdata/output/dependency-list.txt
similarity index 100%
rename from cmd/helm/testdata/output/dependency-list.txt
rename to pkg/cmd/testdata/output/dependency-list.txt
diff --git a/cmd/helm/testdata/output/deprecated-chart.txt b/pkg/cmd/testdata/output/deprecated-chart.txt
similarity index 79%
rename from cmd/helm/testdata/output/deprecated-chart.txt
rename to pkg/cmd/testdata/output/deprecated-chart.txt
index 039d6aef6..fcf5cc0ef 100644
--- a/cmd/helm/testdata/output/deprecated-chart.txt
+++ b/pkg/cmd/testdata/output/deprecated-chart.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/docs-type-comp.txt b/pkg/cmd/testdata/output/docs-type-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/docs-type-comp.txt
rename to pkg/cmd/testdata/output/docs-type-comp.txt
diff --git a/cmd/helm/testdata/output/empty_default_comp.txt b/pkg/cmd/testdata/output/empty_default_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/empty_default_comp.txt
rename to pkg/cmd/testdata/output/empty_default_comp.txt
diff --git a/pkg/cmd/testdata/output/empty_nofile_comp.txt b/pkg/cmd/testdata/output/empty_nofile_comp.txt
new file mode 100644
index 000000000..3c537283e
--- /dev/null
+++ b/pkg/cmd/testdata/output/empty_nofile_comp.txt
@@ -0,0 +1,3 @@
+_activeHelp_ This command does not take any more arguments (but may accept flags).
+:4
+Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/env-comp.txt b/pkg/cmd/testdata/output/env-comp.txt
similarity index 97%
rename from cmd/helm/testdata/output/env-comp.txt
rename to pkg/cmd/testdata/output/env-comp.txt
index b7d93c12e..8f9c53fc7 100644
--- a/cmd/helm/testdata/output/env-comp.txt
+++ b/pkg/cmd/testdata/output/env-comp.txt
@@ -15,6 +15,7 @@ HELM_KUBETOKEN
HELM_MAX_HISTORY
HELM_NAMESPACE
HELM_PLUGINS
+HELM_QPS
HELM_REGISTRY_CONFIG
HELM_REPOSITORY_CACHE
HELM_REPOSITORY_CONFIG
diff --git a/cmd/helm/testdata/output/get-all-no-args.txt b/pkg/cmd/testdata/output/get-all-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-all-no-args.txt
rename to pkg/cmd/testdata/output/get-all-no-args.txt
diff --git a/cmd/helm/testdata/output/get-hooks-no-args.txt b/pkg/cmd/testdata/output/get-hooks-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-hooks-no-args.txt
rename to pkg/cmd/testdata/output/get-hooks-no-args.txt
diff --git a/cmd/helm/testdata/output/get-hooks.txt b/pkg/cmd/testdata/output/get-hooks.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-hooks.txt
rename to pkg/cmd/testdata/output/get-hooks.txt
diff --git a/cmd/helm/testdata/output/get-manifest-no-args.txt b/pkg/cmd/testdata/output/get-manifest-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-manifest-no-args.txt
rename to pkg/cmd/testdata/output/get-manifest-no-args.txt
diff --git a/cmd/helm/testdata/output/get-manifest.txt b/pkg/cmd/testdata/output/get-manifest.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-manifest.txt
rename to pkg/cmd/testdata/output/get-manifest.txt
diff --git a/cmd/helm/testdata/output/get-metadata-args.txt b/pkg/cmd/testdata/output/get-metadata-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-metadata-args.txt
rename to pkg/cmd/testdata/output/get-metadata-args.txt
diff --git a/pkg/cmd/testdata/output/get-metadata.json b/pkg/cmd/testdata/output/get-metadata.json
new file mode 100644
index 000000000..9166f87ac
--- /dev/null
+++ b/pkg/cmd/testdata/output/get-metadata.json
@@ -0,0 +1 @@
+{"name":"thomas-guide","chart":"foo","version":"0.1.0-beta.1","appVersion":"1.0","annotations":{"category":"web-apps","supported":"true"},"labels":{"key1":"value1"},"dependencies":[{"name":"cool-plugin","version":"1.0.0","repository":"https://coolplugin.io/charts","condition":"coolPlugin.enabled","enabled":true},{"name":"crds","version":"2.7.1","repository":"","condition":"crds.enabled"}],"namespace":"default","revision":1,"status":"deployed","deployedAt":"1977-09-02T22:04:05Z"}
diff --git a/cmd/helm/testdata/output/get-metadata.txt b/pkg/cmd/testdata/output/get-metadata.txt
similarity index 60%
rename from cmd/helm/testdata/output/get-metadata.txt
rename to pkg/cmd/testdata/output/get-metadata.txt
index b91f1b86a..5744083dd 100644
--- a/cmd/helm/testdata/output/get-metadata.txt
+++ b/pkg/cmd/testdata/output/get-metadata.txt
@@ -2,6 +2,9 @@ NAME: thomas-guide
CHART: foo
VERSION: 0.1.0-beta.1
APP_VERSION: 1.0
+ANNOTATIONS: category=web-apps,supported=true
+LABELS: key1=value1
+DEPENDENCIES: cool-plugin,crds
NAMESPACE: default
REVISION: 1
STATUS: deployed
diff --git a/pkg/cmd/testdata/output/get-metadata.yaml b/pkg/cmd/testdata/output/get-metadata.yaml
new file mode 100644
index 000000000..98f567837
--- /dev/null
+++ b/pkg/cmd/testdata/output/get-metadata.yaml
@@ -0,0 +1,23 @@
+annotations:
+ category: web-apps
+ supported: "true"
+appVersion: "1.0"
+chart: foo
+dependencies:
+- condition: coolPlugin.enabled
+ enabled: true
+ name: cool-plugin
+ repository: https://coolplugin.io/charts
+ version: 1.0.0
+- condition: crds.enabled
+ name: crds
+ repository: ""
+ version: 2.7.1
+deployedAt: "1977-09-02T22:04:05Z"
+labels:
+ key1: value1
+name: thomas-guide
+namespace: default
+revision: 1
+status: deployed
+version: 0.1.0-beta.1
diff --git a/cmd/helm/testdata/output/get-notes-no-args.txt b/pkg/cmd/testdata/output/get-notes-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-notes-no-args.txt
rename to pkg/cmd/testdata/output/get-notes-no-args.txt
diff --git a/cmd/helm/testdata/output/get-notes.txt b/pkg/cmd/testdata/output/get-notes.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-notes.txt
rename to pkg/cmd/testdata/output/get-notes.txt
diff --git a/cmd/helm/testdata/output/get-release-template.txt b/pkg/cmd/testdata/output/get-release-template.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-release-template.txt
rename to pkg/cmd/testdata/output/get-release-template.txt
diff --git a/cmd/helm/testdata/output/get-release.txt b/pkg/cmd/testdata/output/get-release.txt
similarity index 94%
rename from cmd/helm/testdata/output/get-release.txt
rename to pkg/cmd/testdata/output/get-release.txt
index 12b4a407a..dbca662c5 100644
--- a/cmd/helm/testdata/output/get-release.txt
+++ b/pkg/cmd/testdata/output/get-release.txt
@@ -6,6 +6,7 @@ REVISION: 1
CHART: foo
VERSION: 0.1.0-beta.1
APP_VERSION: 1.0
+DESCRIPTION: Release mock
TEST SUITE: None
USER-SUPPLIED VALUES:
name: value
diff --git a/cmd/helm/testdata/output/get-values-all.txt b/pkg/cmd/testdata/output/get-values-all.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-values-all.txt
rename to pkg/cmd/testdata/output/get-values-all.txt
diff --git a/cmd/helm/testdata/output/get-values-args.txt b/pkg/cmd/testdata/output/get-values-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-values-args.txt
rename to pkg/cmd/testdata/output/get-values-args.txt
diff --git a/cmd/helm/testdata/output/get-values.txt b/pkg/cmd/testdata/output/get-values.txt
similarity index 100%
rename from cmd/helm/testdata/output/get-values.txt
rename to pkg/cmd/testdata/output/get-values.txt
diff --git a/cmd/helm/testdata/output/history-limit.txt b/pkg/cmd/testdata/output/history-limit.txt
similarity index 100%
rename from cmd/helm/testdata/output/history-limit.txt
rename to pkg/cmd/testdata/output/history-limit.txt
diff --git a/cmd/helm/testdata/output/history.json b/pkg/cmd/testdata/output/history.json
similarity index 100%
rename from cmd/helm/testdata/output/history.json
rename to pkg/cmd/testdata/output/history.json
diff --git a/cmd/helm/testdata/output/history.txt b/pkg/cmd/testdata/output/history.txt
similarity index 100%
rename from cmd/helm/testdata/output/history.txt
rename to pkg/cmd/testdata/output/history.txt
diff --git a/cmd/helm/testdata/output/history.yaml b/pkg/cmd/testdata/output/history.yaml
similarity index 100%
rename from cmd/helm/testdata/output/history.yaml
rename to pkg/cmd/testdata/output/history.yaml
diff --git a/cmd/helm/testdata/output/install-and-replace.txt b/pkg/cmd/testdata/output/install-and-replace.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-and-replace.txt
rename to pkg/cmd/testdata/output/install-and-replace.txt
index 039d6aef6..fcf5cc0ef 100644
--- a/cmd/helm/testdata/output/install-and-replace.txt
+++ b/pkg/cmd/testdata/output/install-and-replace.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/pkg/cmd/testdata/output/install-and-take-ownership.txt b/pkg/cmd/testdata/output/install-and-take-ownership.txt
new file mode 100644
index 000000000..413329ae1
--- /dev/null
+++ b/pkg/cmd/testdata/output/install-and-take-ownership.txt
@@ -0,0 +1,7 @@
+NAME: aeneas-take-ownership
+LAST DEPLOYED: Fri Sep 2 22:04:05 1977
+NAMESPACE: default
+STATUS: deployed
+REVISION: 1
+DESCRIPTION: Install complete
+TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-chart-bad-type.txt b/pkg/cmd/testdata/output/install-chart-bad-type.txt
similarity index 100%
rename from cmd/helm/testdata/output/install-chart-bad-type.txt
rename to pkg/cmd/testdata/output/install-chart-bad-type.txt
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
new file mode 100644
index 000000000..eb770967f
--- /dev/null
+++ b/pkg/cmd/testdata/output/install-dry-run-with-secret-hidden.txt
@@ -0,0 +1,21 @@
+NAME: secrets
+LAST DEPLOYED: Fri Sep 2 22:04:05 1977
+NAMESPACE: default
+STATUS: pending-install
+REVISION: 1
+DESCRIPTION: Dry run complete
+TEST SUITE: None
+HOOKS:
+MANIFEST:
+---
+# Source: chart-with-secret/templates/secret.yaml
+# HIDDEN: The Secret output has been suppressed
+---
+# Source: chart-with-secret/templates/configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-configmap
+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
new file mode 100644
index 000000000..d22c1437f
--- /dev/null
+++ b/pkg/cmd/testdata/output/install-dry-run-with-secret.txt
@@ -0,0 +1,26 @@
+NAME: secrets
+LAST DEPLOYED: Fri Sep 2 22:04:05 1977
+NAMESPACE: default
+STATUS: pending-install
+REVISION: 1
+DESCRIPTION: Dry run complete
+TEST SUITE: None
+HOOKS:
+MANIFEST:
+---
+# Source: chart-with-secret/templates/secret.yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: test-secret
+stringData:
+ foo: bar
+---
+# Source: chart-with-secret/templates/configmap.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-configmap
+data:
+ foo: bar
+
diff --git a/pkg/cmd/testdata/output/install-hide-secret.txt b/pkg/cmd/testdata/output/install-hide-secret.txt
new file mode 100644
index 000000000..165f14f73
--- /dev/null
+++ b/pkg/cmd/testdata/output/install-hide-secret.txt
@@ -0,0 +1 @@
+Error: INSTALLATION FAILED: hiding Kubernetes secrets requires a dry-run mode
diff --git a/cmd/helm/testdata/output/install-lib-chart.txt b/pkg/cmd/testdata/output/install-lib-chart.txt
similarity index 100%
rename from cmd/helm/testdata/output/install-lib-chart.txt
rename to pkg/cmd/testdata/output/install-lib-chart.txt
diff --git a/cmd/helm/testdata/output/install-name-template.txt b/pkg/cmd/testdata/output/install-name-template.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-name-template.txt
rename to pkg/cmd/testdata/output/install-name-template.txt
index 19952e3c2..bcc5f87ba 100644
--- a/cmd/helm/testdata/output/install-name-template.txt
+++ b/pkg/cmd/testdata/output/install-name-template.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-no-args.txt b/pkg/cmd/testdata/output/install-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/install-no-args.txt
rename to pkg/cmd/testdata/output/install-no-args.txt
diff --git a/cmd/helm/testdata/output/install-no-hooks.txt b/pkg/cmd/testdata/output/install-no-hooks.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-no-hooks.txt
rename to pkg/cmd/testdata/output/install-no-hooks.txt
index 039d6aef6..fcf5cc0ef 100644
--- a/cmd/helm/testdata/output/install-no-hooks.txt
+++ b/pkg/cmd/testdata/output/install-no-hooks.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-multiple-values-files.txt b/pkg/cmd/testdata/output/install-with-multiple-values-files.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-multiple-values-files.txt
rename to pkg/cmd/testdata/output/install-with-multiple-values-files.txt
index 406e522a9..1116cb907 100644
--- a/cmd/helm/testdata/output/install-with-multiple-values-files.txt
+++ b/pkg/cmd/testdata/output/install-with-multiple-values-files.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-multiple-values.txt b/pkg/cmd/testdata/output/install-with-multiple-values.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-multiple-values.txt
rename to pkg/cmd/testdata/output/install-with-multiple-values.txt
index 406e522a9..1116cb907 100644
--- a/cmd/helm/testdata/output/install-with-multiple-values.txt
+++ b/pkg/cmd/testdata/output/install-with-multiple-values.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-timeout.txt b/pkg/cmd/testdata/output/install-with-timeout.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-timeout.txt
rename to pkg/cmd/testdata/output/install-with-timeout.txt
index 19952e3c2..bcc5f87ba 100644
--- a/cmd/helm/testdata/output/install-with-timeout.txt
+++ b/pkg/cmd/testdata/output/install-with-timeout.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-values-file.txt b/pkg/cmd/testdata/output/install-with-values-file.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-values-file.txt
rename to pkg/cmd/testdata/output/install-with-values-file.txt
index 406e522a9..1116cb907 100644
--- a/cmd/helm/testdata/output/install-with-values-file.txt
+++ b/pkg/cmd/testdata/output/install-with-values-file.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-values.txt b/pkg/cmd/testdata/output/install-with-values.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-values.txt
rename to pkg/cmd/testdata/output/install-with-values.txt
index 406e522a9..1116cb907 100644
--- a/cmd/helm/testdata/output/install-with-values.txt
+++ b/pkg/cmd/testdata/output/install-with-values.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-wait-for-jobs.txt b/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-wait-for-jobs.txt
rename to pkg/cmd/testdata/output/install-with-wait-for-jobs.txt
index 7ce22d4ec..c5676c610 100644
--- a/cmd/helm/testdata/output/install-with-wait-for-jobs.txt
+++ b/pkg/cmd/testdata/output/install-with-wait-for-jobs.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install-with-wait.txt b/pkg/cmd/testdata/output/install-with-wait.txt
similarity index 79%
rename from cmd/helm/testdata/output/install-with-wait.txt
rename to pkg/cmd/testdata/output/install-with-wait.txt
index 7ce22d4ec..c5676c610 100644
--- a/cmd/helm/testdata/output/install-with-wait.txt
+++ b/pkg/cmd/testdata/output/install-with-wait.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/install.txt b/pkg/cmd/testdata/output/install.txt
similarity index 79%
rename from cmd/helm/testdata/output/install.txt
rename to pkg/cmd/testdata/output/install.txt
index 039d6aef6..fcf5cc0ef 100644
--- a/cmd/helm/testdata/output/install.txt
+++ b/pkg/cmd/testdata/output/install.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/issue-9027.txt b/pkg/cmd/testdata/output/issue-9027.txt
similarity index 100%
rename from cmd/helm/testdata/output/issue-9027.txt
rename to pkg/cmd/testdata/output/issue-9027.txt
diff --git a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt
similarity index 68%
rename from cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt
rename to pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt
index d43c7c361..7b445a69a 100644
--- a/cmd/helm/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt
+++ b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt
@@ -1,19 +1,20 @@
==> Linting testdata/testcharts/chart-with-bad-subcharts
[INFO] Chart.yaml: icon is recommended
-[ERROR] templates/: error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
+[WARNING] templates/: directory does not exist
[ERROR] : unable to load chart
- error unpacking bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
+ error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart
[ERROR] Chart.yaml: name is required
[ERROR] Chart.yaml: apiVersion is required. The value must be either "v1" or "v2"
[ERROR] Chart.yaml: version is required
[INFO] Chart.yaml: icon is recommended
-[ERROR] templates/: validation: chart.metadata.name is required
+[WARNING] templates/: directory does not exist
[ERROR] : unable to load chart
validation: chart.metadata.name is required
==> Linting testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart
[INFO] Chart.yaml: icon is recommended
+[WARNING] templates/: directory does not exist
Error: 3 chart(s) linted, 2 chart(s) failed
diff --git a/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt
new file mode 100644
index 000000000..5a1c388bb
--- /dev/null
+++ b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts.txt
@@ -0,0 +1,7 @@
+==> Linting testdata/testcharts/chart-with-bad-subcharts
+[INFO] Chart.yaml: icon is recommended
+[WARNING] templates/: directory does not exist
+[ERROR] : unable to load chart
+ error unpacking subchart bad-subchart in chart-with-bad-subcharts: validation: chart.metadata.name is required
+
+Error: 1 chart(s) linted, 1 chart(s) failed
diff --git a/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt
new file mode 100644
index 000000000..bd0d70000
--- /dev/null
+++ b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-old-k8s.txt
@@ -0,0 +1,4 @@
+==> Linting testdata/testcharts/chart-with-deprecated-api
+[INFO] Chart.yaml: icon is recommended
+
+1 chart(s) linted, 0 chart(s) failed
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
new file mode 100644
index 000000000..a1ec4394e
--- /dev/null
+++ b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api-strict.txt
@@ -0,0 +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
+
+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
new file mode 100644
index 000000000..dac54620c
--- /dev/null
+++ b/pkg/cmd/testdata/output/lint-chart-with-deprecated-api.txt
@@ -0,0 +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
+
+1 chart(s) linted, 0 chart(s) failed
diff --git a/cmd/helm/testdata/output/lint-quiet-with-error.txt b/pkg/cmd/testdata/output/lint-quiet-with-error.txt
similarity index 74%
rename from cmd/helm/testdata/output/lint-quiet-with-error.txt
rename to pkg/cmd/testdata/output/lint-quiet-with-error.txt
index e3d29a5a3..0731a07d1 100644
--- a/cmd/helm/testdata/output/lint-quiet-with-error.txt
+++ b/pkg/cmd/testdata/output/lint-quiet-with-error.txt
@@ -1,7 +1,7 @@
==> Linting testdata/testcharts/chart-bad-requirements
[ERROR] Chart.yaml: unable to parse YAML
error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator
-[ERROR] templates/: cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator
+[WARNING] templates/: directory does not exist
[ERROR] : unable to load chart
cannot load Chart.yaml: error converting YAML to JSON: yaml: line 6: did not find expected '-' indicator
diff --git a/pkg/cmd/testdata/output/lint-quiet-with-warning.txt b/pkg/cmd/testdata/output/lint-quiet-with-warning.txt
new file mode 100644
index 000000000..ebf6c1989
--- /dev/null
+++ b/pkg/cmd/testdata/output/lint-quiet-with-warning.txt
@@ -0,0 +1,4 @@
+==> Linting testdata/testcharts/chart-with-only-crds
+[WARNING] templates/: directory does not exist
+
+1 chart(s) linted, 0 chart(s) failed
diff --git a/internal/ignore/testdata/helm.txt b/pkg/cmd/testdata/output/lint-quiet.txt
similarity index 100%
rename from internal/ignore/testdata/helm.txt
rename to pkg/cmd/testdata/output/lint-quiet.txt
diff --git a/cmd/helm/testdata/output/list-all.txt b/pkg/cmd/testdata/output/list-all.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-all.txt
rename to pkg/cmd/testdata/output/list-all.txt
diff --git a/cmd/helm/testdata/output/list-date-reversed.txt b/pkg/cmd/testdata/output/list-date-reversed.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-date-reversed.txt
rename to pkg/cmd/testdata/output/list-date-reversed.txt
diff --git a/cmd/helm/testdata/output/list-date.txt b/pkg/cmd/testdata/output/list-date.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-date.txt
rename to pkg/cmd/testdata/output/list-date.txt
diff --git a/cmd/helm/testdata/output/list-failed.txt b/pkg/cmd/testdata/output/list-failed.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-failed.txt
rename to pkg/cmd/testdata/output/list-failed.txt
diff --git a/cmd/helm/testdata/output/list-filter.txt b/pkg/cmd/testdata/output/list-filter.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-filter.txt
rename to pkg/cmd/testdata/output/list-filter.txt
diff --git a/cmd/helm/testdata/output/list-max.txt b/pkg/cmd/testdata/output/list-max.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-max.txt
rename to pkg/cmd/testdata/output/list-max.txt
diff --git a/cmd/helm/testdata/output/list-namespace.txt b/pkg/cmd/testdata/output/list-namespace.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-namespace.txt
rename to pkg/cmd/testdata/output/list-namespace.txt
diff --git a/cmd/helm/testdata/output/list-no-headers.txt b/pkg/cmd/testdata/output/list-no-headers.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-no-headers.txt
rename to pkg/cmd/testdata/output/list-no-headers.txt
diff --git a/cmd/helm/testdata/output/list-offset.txt b/pkg/cmd/testdata/output/list-offset.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-offset.txt
rename to pkg/cmd/testdata/output/list-offset.txt
diff --git a/cmd/helm/testdata/output/list-pending.txt b/pkg/cmd/testdata/output/list-pending.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-pending.txt
rename to pkg/cmd/testdata/output/list-pending.txt
diff --git a/cmd/helm/testdata/output/list-reverse.txt b/pkg/cmd/testdata/output/list-reverse.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-reverse.txt
rename to pkg/cmd/testdata/output/list-reverse.txt
diff --git a/cmd/helm/testdata/output/list-short-json.txt b/pkg/cmd/testdata/output/list-short-json.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-short-json.txt
rename to pkg/cmd/testdata/output/list-short-json.txt
diff --git a/cmd/helm/testdata/output/list-short-yaml.txt b/pkg/cmd/testdata/output/list-short-yaml.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-short-yaml.txt
rename to pkg/cmd/testdata/output/list-short-yaml.txt
diff --git a/cmd/helm/testdata/output/list-short.txt b/pkg/cmd/testdata/output/list-short.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-short.txt
rename to pkg/cmd/testdata/output/list-short.txt
diff --git a/cmd/helm/testdata/output/list-superseded.txt b/pkg/cmd/testdata/output/list-superseded.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-superseded.txt
rename to pkg/cmd/testdata/output/list-superseded.txt
diff --git a/cmd/helm/testdata/output/list-uninstalled.txt b/pkg/cmd/testdata/output/list-uninstalled.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-uninstalled.txt
rename to pkg/cmd/testdata/output/list-uninstalled.txt
diff --git a/cmd/helm/testdata/output/list-uninstalling.txt b/pkg/cmd/testdata/output/list-uninstalling.txt
similarity index 100%
rename from cmd/helm/testdata/output/list-uninstalling.txt
rename to pkg/cmd/testdata/output/list-uninstalling.txt
diff --git a/cmd/helm/testdata/output/list.txt b/pkg/cmd/testdata/output/list.txt
similarity index 100%
rename from cmd/helm/testdata/output/list.txt
rename to pkg/cmd/testdata/output/list.txt
diff --git a/cmd/helm/testdata/output/object-order.txt b/pkg/cmd/testdata/output/object-order.txt
similarity index 100%
rename from cmd/helm/testdata/output/object-order.txt
rename to pkg/cmd/testdata/output/object-order.txt
diff --git a/cmd/helm/testdata/output/output-comp.txt b/pkg/cmd/testdata/output/output-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/output-comp.txt
rename to pkg/cmd/testdata/output/output-comp.txt
diff --git a/cmd/helm/testdata/output/plugin_args_comp.txt b/pkg/cmd/testdata/output/plugin_args_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_args_comp.txt
rename to pkg/cmd/testdata/output/plugin_args_comp.txt
diff --git a/cmd/helm/testdata/output/plugin_args_flag_comp.txt b/pkg/cmd/testdata/output/plugin_args_flag_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_args_flag_comp.txt
rename to pkg/cmd/testdata/output/plugin_args_flag_comp.txt
diff --git a/cmd/helm/testdata/output/plugin_args_many_args_comp.txt b/pkg/cmd/testdata/output/plugin_args_many_args_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_args_many_args_comp.txt
rename to pkg/cmd/testdata/output/plugin_args_many_args_comp.txt
diff --git a/cmd/helm/testdata/output/plugin_args_ns_comp.txt b/pkg/cmd/testdata/output/plugin_args_ns_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_args_ns_comp.txt
rename to pkg/cmd/testdata/output/plugin_args_ns_comp.txt
diff --git a/cmd/helm/testdata/output/plugin_echo_no_directive.txt b/pkg/cmd/testdata/output/plugin_echo_no_directive.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_echo_no_directive.txt
rename to pkg/cmd/testdata/output/plugin_echo_no_directive.txt
diff --git a/cmd/helm/testdata/output/plugin_list_comp.txt b/pkg/cmd/testdata/output/plugin_list_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_list_comp.txt
rename to pkg/cmd/testdata/output/plugin_list_comp.txt
diff --git a/cmd/helm/testdata/output/plugin_repeat_comp.txt b/pkg/cmd/testdata/output/plugin_repeat_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/plugin_repeat_comp.txt
rename to pkg/cmd/testdata/output/plugin_repeat_comp.txt
diff --git a/cmd/helm/testdata/output/release_list_comp.txt b/pkg/cmd/testdata/output/release_list_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/release_list_comp.txt
rename to pkg/cmd/testdata/output/release_list_comp.txt
diff --git a/cmd/helm/testdata/output/release_list_repeat_comp.txt b/pkg/cmd/testdata/output/release_list_repeat_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/release_list_repeat_comp.txt
rename to pkg/cmd/testdata/output/release_list_repeat_comp.txt
diff --git a/cmd/helm/testdata/output/repo-add.txt b/pkg/cmd/testdata/output/repo-add.txt
similarity index 100%
rename from cmd/helm/testdata/output/repo-add.txt
rename to pkg/cmd/testdata/output/repo-add.txt
diff --git a/cmd/helm/testdata/output/repo-add2.txt b/pkg/cmd/testdata/output/repo-add2.txt
similarity index 100%
rename from cmd/helm/testdata/output/repo-add2.txt
rename to pkg/cmd/testdata/output/repo-add2.txt
diff --git a/pkg/cmd/testdata/output/repo-list-empty.txt b/pkg/cmd/testdata/output/repo-list-empty.txt
new file mode 100644
index 000000000..c6edb659a
--- /dev/null
+++ b/pkg/cmd/testdata/output/repo-list-empty.txt
@@ -0,0 +1 @@
+no repositories to show
diff --git a/pkg/cmd/testdata/output/repo-list.txt b/pkg/cmd/testdata/output/repo-list.txt
new file mode 100644
index 000000000..edbd0ecc1
--- /dev/null
+++ b/pkg/cmd/testdata/output/repo-list.txt
@@ -0,0 +1,4 @@
+NAME URL
+charts https://charts.helm.sh/stable
+firstexample http://firstexample.com
+secondexample http://secondexample.com
diff --git a/cmd/helm/testdata/output/repo_list_comp.txt b/pkg/cmd/testdata/output/repo_list_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/repo_list_comp.txt
rename to pkg/cmd/testdata/output/repo_list_comp.txt
diff --git a/cmd/helm/testdata/output/repo_repeat_comp.txt b/pkg/cmd/testdata/output/repo_repeat_comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/repo_repeat_comp.txt
rename to pkg/cmd/testdata/output/repo_repeat_comp.txt
diff --git a/cmd/helm/testdata/output/revision-comp.txt b/pkg/cmd/testdata/output/revision-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/revision-comp.txt
rename to pkg/cmd/testdata/output/revision-comp.txt
diff --git a/cmd/helm/testdata/output/revision-wrong-args-comp.txt b/pkg/cmd/testdata/output/revision-wrong-args-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/revision-wrong-args-comp.txt
rename to pkg/cmd/testdata/output/revision-wrong-args-comp.txt
diff --git a/cmd/helm/testdata/output/rollback-comp.txt b/pkg/cmd/testdata/output/rollback-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-comp.txt
rename to pkg/cmd/testdata/output/rollback-comp.txt
diff --git a/cmd/helm/testdata/output/rollback-no-args.txt b/pkg/cmd/testdata/output/rollback-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-no-args.txt
rename to pkg/cmd/testdata/output/rollback-no-args.txt
diff --git a/cmd/helm/testdata/output/rollback-no-revision.txt b/pkg/cmd/testdata/output/rollback-no-revision.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-no-revision.txt
rename to pkg/cmd/testdata/output/rollback-no-revision.txt
diff --git a/cmd/helm/testdata/output/rollback-non-existent-version.txt b/pkg/cmd/testdata/output/rollback-non-existent-version.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-non-existent-version.txt
rename to pkg/cmd/testdata/output/rollback-non-existent-version.txt
diff --git a/cmd/helm/testdata/output/rollback-timeout.txt b/pkg/cmd/testdata/output/rollback-timeout.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-timeout.txt
rename to pkg/cmd/testdata/output/rollback-timeout.txt
diff --git a/cmd/helm/testdata/output/rollback-wait-for-jobs.txt b/pkg/cmd/testdata/output/rollback-wait-for-jobs.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-wait-for-jobs.txt
rename to pkg/cmd/testdata/output/rollback-wait-for-jobs.txt
diff --git a/cmd/helm/testdata/output/rollback-wait.txt b/pkg/cmd/testdata/output/rollback-wait.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback-wait.txt
rename to pkg/cmd/testdata/output/rollback-wait.txt
diff --git a/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt b/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt
new file mode 100644
index 000000000..3c537283e
--- /dev/null
+++ b/pkg/cmd/testdata/output/rollback-wrong-args-comp.txt
@@ -0,0 +1,3 @@
+_activeHelp_ This command does not take any more arguments (but may accept flags).
+:4
+Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/rollback.txt b/pkg/cmd/testdata/output/rollback.txt
similarity index 100%
rename from cmd/helm/testdata/output/rollback.txt
rename to pkg/cmd/testdata/output/rollback.txt
diff --git a/cmd/helm/testdata/output/schema-negative-cli.txt b/pkg/cmd/testdata/output/schema-negative-cli.txt
similarity index 73%
rename from cmd/helm/testdata/output/schema-negative-cli.txt
rename to pkg/cmd/testdata/output/schema-negative-cli.txt
index c4a5cc516..12bcc5103 100644
--- a/cmd/helm/testdata/output/schema-negative-cli.txt
+++ b/pkg/cmd/testdata/output/schema-negative-cli.txt
@@ -1,4 +1,4 @@
Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
empty:
-- age: Must be greater than or equal to 0
+- at '/age': minimum: got -5, want 0
diff --git a/cmd/helm/testdata/output/schema-negative.txt b/pkg/cmd/testdata/output/schema-negative.txt
similarity index 59%
rename from cmd/helm/testdata/output/schema-negative.txt
rename to pkg/cmd/testdata/output/schema-negative.txt
index 929af5518..daf132635 100644
--- a/cmd/helm/testdata/output/schema-negative.txt
+++ b/pkg/cmd/testdata/output/schema-negative.txt
@@ -1,5 +1,5 @@
Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
empty:
-- (root): employmentInfo is required
-- age: Must be greater than or equal to 0
+- at '': missing property 'employmentInfo'
+- at '/age': minimum: got -5, want 0
diff --git a/cmd/helm/testdata/output/schema.txt b/pkg/cmd/testdata/output/schema.txt
similarity index 79%
rename from cmd/helm/testdata/output/schema.txt
rename to pkg/cmd/testdata/output/schema.txt
index 22a94b3f4..f5fe63768 100644
--- a/cmd/helm/testdata/output/schema.txt
+++ b/pkg/cmd/testdata/output/schema.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/search-constraint-single.txt b/pkg/cmd/testdata/output/search-constraint-single.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-constraint-single.txt
rename to pkg/cmd/testdata/output/search-constraint-single.txt
diff --git a/cmd/helm/testdata/output/search-constraint.txt b/pkg/cmd/testdata/output/search-constraint.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-constraint.txt
rename to pkg/cmd/testdata/output/search-constraint.txt
diff --git a/cmd/helm/testdata/output/search-multiple-devel-release.txt b/pkg/cmd/testdata/output/search-multiple-devel-release.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-multiple-devel-release.txt
rename to pkg/cmd/testdata/output/search-multiple-devel-release.txt
diff --git a/cmd/helm/testdata/output/search-multiple-stable-release.txt b/pkg/cmd/testdata/output/search-multiple-stable-release.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-multiple-stable-release.txt
rename to pkg/cmd/testdata/output/search-multiple-stable-release.txt
diff --git a/cmd/helm/testdata/output/search-multiple-versions-constraints.txt b/pkg/cmd/testdata/output/search-multiple-versions-constraints.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-multiple-versions-constraints.txt
rename to pkg/cmd/testdata/output/search-multiple-versions-constraints.txt
diff --git a/cmd/helm/testdata/output/search-multiple-versions.txt b/pkg/cmd/testdata/output/search-multiple-versions.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-multiple-versions.txt
rename to pkg/cmd/testdata/output/search-multiple-versions.txt
diff --git a/cmd/helm/testdata/output/search-not-found-error.txt b/pkg/cmd/testdata/output/search-not-found-error.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-not-found-error.txt
rename to pkg/cmd/testdata/output/search-not-found-error.txt
diff --git a/cmd/helm/testdata/output/search-not-found.txt b/pkg/cmd/testdata/output/search-not-found.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-not-found.txt
rename to pkg/cmd/testdata/output/search-not-found.txt
diff --git a/cmd/helm/testdata/output/search-output-json.txt b/pkg/cmd/testdata/output/search-output-json.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-output-json.txt
rename to pkg/cmd/testdata/output/search-output-json.txt
diff --git a/cmd/helm/testdata/output/search-output-yaml.txt b/pkg/cmd/testdata/output/search-output-yaml.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-output-yaml.txt
rename to pkg/cmd/testdata/output/search-output-yaml.txt
diff --git a/cmd/helm/testdata/output/search-regex.txt b/pkg/cmd/testdata/output/search-regex.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-regex.txt
rename to pkg/cmd/testdata/output/search-regex.txt
diff --git a/cmd/helm/testdata/output/search-versions-constraint.txt b/pkg/cmd/testdata/output/search-versions-constraint.txt
similarity index 100%
rename from cmd/helm/testdata/output/search-versions-constraint.txt
rename to pkg/cmd/testdata/output/search-versions-constraint.txt
diff --git a/cmd/helm/testdata/output/status-comp.txt b/pkg/cmd/testdata/output/status-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/status-comp.txt
rename to pkg/cmd/testdata/output/status-comp.txt
diff --git a/cmd/helm/testdata/output/status-with-desc.txt b/pkg/cmd/testdata/output/status-with-desc.txt
similarity index 100%
rename from cmd/helm/testdata/output/status-with-desc.txt
rename to pkg/cmd/testdata/output/status-with-desc.txt
diff --git a/cmd/helm/testdata/output/status-with-notes.txt b/pkg/cmd/testdata/output/status-with-notes.txt
similarity index 91%
rename from cmd/helm/testdata/output/status-with-notes.txt
rename to pkg/cmd/testdata/output/status-with-notes.txt
index e992ce91e..f05be6c18 100644
--- a/cmd/helm/testdata/output/status-with-notes.txt
+++ b/pkg/cmd/testdata/output/status-with-notes.txt
@@ -3,6 +3,7 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016
NAMESPACE: default
STATUS: deployed
REVISION: 0
+DESCRIPTION:
TEST SUITE: None
NOTES:
release notes
diff --git a/cmd/helm/testdata/output/status-with-resources.json b/pkg/cmd/testdata/output/status-with-resources.json
similarity index 100%
rename from cmd/helm/testdata/output/status-with-resources.json
rename to pkg/cmd/testdata/output/status-with-resources.json
diff --git a/cmd/helm/testdata/output/status-with-resources.txt b/pkg/cmd/testdata/output/status-with-resources.txt
similarity index 90%
rename from cmd/helm/testdata/output/status-with-resources.txt
rename to pkg/cmd/testdata/output/status-with-resources.txt
index a326c3db0..20763acda 100644
--- a/cmd/helm/testdata/output/status-with-resources.txt
+++ b/pkg/cmd/testdata/output/status-with-resources.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016
NAMESPACE: default
STATUS: deployed
REVISION: 0
+DESCRIPTION:
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/status-with-test-suite.txt b/pkg/cmd/testdata/output/status-with-test-suite.txt
similarity index 96%
rename from cmd/helm/testdata/output/status-with-test-suite.txt
rename to pkg/cmd/testdata/output/status-with-test-suite.txt
index 58c67e103..7c1ade450 100644
--- a/cmd/helm/testdata/output/status-with-test-suite.txt
+++ b/pkg/cmd/testdata/output/status-with-test-suite.txt
@@ -3,6 +3,7 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016
NAMESPACE: default
STATUS: deployed
REVISION: 0
+DESCRIPTION:
TEST SUITE: passing-test
Last Started: Mon Jan 2 15:04:05 2006
Last Completed: Mon Jan 2 15:04:07 2006
diff --git a/pkg/cmd/testdata/output/status-wrong-args-comp.txt b/pkg/cmd/testdata/output/status-wrong-args-comp.txt
new file mode 100644
index 000000000..3c537283e
--- /dev/null
+++ b/pkg/cmd/testdata/output/status-wrong-args-comp.txt
@@ -0,0 +1,3 @@
+_activeHelp_ This command does not take any more arguments (but may accept flags).
+:4
+Completion ended with directive: ShellCompDirectiveNoFileComp
diff --git a/cmd/helm/testdata/output/status.json b/pkg/cmd/testdata/output/status.json
similarity index 100%
rename from cmd/helm/testdata/output/status.json
rename to pkg/cmd/testdata/output/status.json
diff --git a/cmd/helm/testdata/output/status.txt b/pkg/cmd/testdata/output/status.txt
similarity index 90%
rename from cmd/helm/testdata/output/status.txt
rename to pkg/cmd/testdata/output/status.txt
index a326c3db0..20763acda 100644
--- a/cmd/helm/testdata/output/status.txt
+++ b/pkg/cmd/testdata/output/status.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Sat Jan 16 00:00:00 2016
NAMESPACE: default
STATUS: deployed
REVISION: 0
+DESCRIPTION:
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/subchart-schema-cli-negative.txt b/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt
similarity index 75%
rename from cmd/helm/testdata/output/subchart-schema-cli-negative.txt
rename to pkg/cmd/testdata/output/subchart-schema-cli-negative.txt
index 7396b4bfe..179550f69 100644
--- a/cmd/helm/testdata/output/subchart-schema-cli-negative.txt
+++ b/pkg/cmd/testdata/output/subchart-schema-cli-negative.txt
@@ -1,4 +1,4 @@
Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
subchart-with-schema:
-- age: Must be greater than or equal to 0
+- at '/age': minimum: got -25, want 0
diff --git a/cmd/helm/testdata/output/subchart-schema-cli.txt b/pkg/cmd/testdata/output/subchart-schema-cli.txt
similarity index 79%
rename from cmd/helm/testdata/output/subchart-schema-cli.txt
rename to pkg/cmd/testdata/output/subchart-schema-cli.txt
index 22a94b3f4..f5fe63768 100644
--- a/cmd/helm/testdata/output/subchart-schema-cli.txt
+++ b/pkg/cmd/testdata/output/subchart-schema-cli.txt
@@ -3,4 +3,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 1
+DESCRIPTION: Install complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/subchart-schema-negative.txt b/pkg/cmd/testdata/output/subchart-schema-negative.txt
similarity index 69%
rename from cmd/helm/testdata/output/subchart-schema-negative.txt
rename to pkg/cmd/testdata/output/subchart-schema-negative.txt
index 7b1f654a2..7522ef3e4 100644
--- a/cmd/helm/testdata/output/subchart-schema-negative.txt
+++ b/pkg/cmd/testdata/output/subchart-schema-negative.txt
@@ -1,6 +1,6 @@
Error: INSTALLATION FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
chart-without-schema:
-- (root): lastname is required
+- at '': missing property 'lastname'
subchart-with-schema:
-- (root): age is required
+- at '': missing property 'age'
diff --git a/cmd/helm/testdata/output/template-chart-bad-type.txt b/pkg/cmd/testdata/output/template-chart-bad-type.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-chart-bad-type.txt
rename to pkg/cmd/testdata/output/template-chart-bad-type.txt
diff --git a/cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt b/pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-chart-with-template-lib-archive-dep.txt
rename to pkg/cmd/testdata/output/template-chart-with-template-lib-archive-dep.txt
diff --git a/cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt b/pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-chart-with-template-lib-dep.txt
rename to pkg/cmd/testdata/output/template-chart-with-template-lib-dep.txt
diff --git a/cmd/helm/testdata/output/template-lib-chart.txt b/pkg/cmd/testdata/output/template-lib-chart.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-lib-chart.txt
rename to pkg/cmd/testdata/output/template-lib-chart.txt
diff --git a/cmd/helm/testdata/output/template-name-template.txt b/pkg/cmd/testdata/output/template-name-template.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-name-template.txt
rename to pkg/cmd/testdata/output/template-name-template.txt
diff --git a/cmd/helm/testdata/output/template-no-args.txt b/pkg/cmd/testdata/output/template-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-no-args.txt
rename to pkg/cmd/testdata/output/template-no-args.txt
diff --git a/cmd/helm/testdata/output/template-set.txt b/pkg/cmd/testdata/output/template-set.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-set.txt
rename to pkg/cmd/testdata/output/template-set.txt
diff --git a/cmd/helm/testdata/output/template-show-only-glob.txt b/pkg/cmd/testdata/output/template-show-only-glob.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-show-only-glob.txt
rename to pkg/cmd/testdata/output/template-show-only-glob.txt
diff --git a/cmd/helm/testdata/output/template-show-only-multiple.txt b/pkg/cmd/testdata/output/template-show-only-multiple.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-show-only-multiple.txt
rename to pkg/cmd/testdata/output/template-show-only-multiple.txt
diff --git a/cmd/helm/testdata/output/template-show-only-one.txt b/pkg/cmd/testdata/output/template-show-only-one.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-show-only-one.txt
rename to pkg/cmd/testdata/output/template-show-only-one.txt
diff --git a/cmd/helm/testdata/output/template-skip-tests.txt b/pkg/cmd/testdata/output/template-skip-tests.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-skip-tests.txt
rename to pkg/cmd/testdata/output/template-skip-tests.txt
diff --git a/cmd/helm/testdata/output/template-subchart-cm-set-file.txt b/pkg/cmd/testdata/output/template-subchart-cm-set-file.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-subchart-cm-set-file.txt
rename to pkg/cmd/testdata/output/template-subchart-cm-set-file.txt
diff --git a/cmd/helm/testdata/output/template-subchart-cm-set.txt b/pkg/cmd/testdata/output/template-subchart-cm-set.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-subchart-cm-set.txt
rename to pkg/cmd/testdata/output/template-subchart-cm-set.txt
diff --git a/cmd/helm/testdata/output/template-subchart-cm.txt b/pkg/cmd/testdata/output/template-subchart-cm.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-subchart-cm.txt
rename to pkg/cmd/testdata/output/template-subchart-cm.txt
diff --git a/cmd/helm/testdata/output/template-values-files.txt b/pkg/cmd/testdata/output/template-values-files.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-values-files.txt
rename to pkg/cmd/testdata/output/template-values-files.txt
diff --git a/cmd/helm/testdata/output/template-with-api-version.txt b/pkg/cmd/testdata/output/template-with-api-version.txt
similarity index 98%
rename from cmd/helm/testdata/output/template-with-api-version.txt
rename to pkg/cmd/testdata/output/template-with-api-version.txt
index 7e1c35001..8b6074cdb 100644
--- a/cmd/helm/testdata/output/template-with-api-version.txt
+++ b/pkg/cmd/testdata/output/template-with-api-version.txt
@@ -75,6 +75,7 @@ metadata:
kube-version/minor: "20"
kube-version/version: "v1.20.0"
kube-api-version/test: v1
+ kube-api-version/test2: v2
spec:
type: ClusterIP
ports:
diff --git a/cmd/helm/testdata/output/template-with-crds.txt b/pkg/cmd/testdata/output/template-with-crds.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-with-crds.txt
rename to pkg/cmd/testdata/output/template-with-crds.txt
diff --git a/cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt b/pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-with-invalid-yaml-debug.txt
rename to pkg/cmd/testdata/output/template-with-invalid-yaml-debug.txt
diff --git a/cmd/helm/testdata/output/template-with-invalid-yaml.txt b/pkg/cmd/testdata/output/template-with-invalid-yaml.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-with-invalid-yaml.txt
rename to pkg/cmd/testdata/output/template-with-invalid-yaml.txt
diff --git a/cmd/helm/testdata/output/template-with-kube-version.txt b/pkg/cmd/testdata/output/template-with-kube-version.txt
similarity index 100%
rename from cmd/helm/testdata/output/template-with-kube-version.txt
rename to pkg/cmd/testdata/output/template-with-kube-version.txt
diff --git a/cmd/helm/testdata/output/template.txt b/pkg/cmd/testdata/output/template.txt
similarity index 100%
rename from cmd/helm/testdata/output/template.txt
rename to pkg/cmd/testdata/output/template.txt
diff --git a/cmd/helm/testdata/output/uninstall-keep-history.txt b/pkg/cmd/testdata/output/uninstall-keep-history.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-keep-history.txt
rename to pkg/cmd/testdata/output/uninstall-keep-history.txt
diff --git a/cmd/helm/testdata/output/uninstall-multiple.txt b/pkg/cmd/testdata/output/uninstall-multiple.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-multiple.txt
rename to pkg/cmd/testdata/output/uninstall-multiple.txt
diff --git a/cmd/helm/testdata/output/uninstall-no-args.txt b/pkg/cmd/testdata/output/uninstall-no-args.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-no-args.txt
rename to pkg/cmd/testdata/output/uninstall-no-args.txt
diff --git a/cmd/helm/testdata/output/uninstall-no-hooks.txt b/pkg/cmd/testdata/output/uninstall-no-hooks.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-no-hooks.txt
rename to pkg/cmd/testdata/output/uninstall-no-hooks.txt
diff --git a/cmd/helm/testdata/output/uninstall-timeout.txt b/pkg/cmd/testdata/output/uninstall-timeout.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-timeout.txt
rename to pkg/cmd/testdata/output/uninstall-timeout.txt
diff --git a/cmd/helm/testdata/output/uninstall-wait.txt b/pkg/cmd/testdata/output/uninstall-wait.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall-wait.txt
rename to pkg/cmd/testdata/output/uninstall-wait.txt
diff --git a/cmd/helm/testdata/output/uninstall.txt b/pkg/cmd/testdata/output/uninstall.txt
similarity index 100%
rename from cmd/helm/testdata/output/uninstall.txt
rename to pkg/cmd/testdata/output/uninstall.txt
diff --git a/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt b/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt
new file mode 100644
index 000000000..59267651f
--- /dev/null
+++ b/pkg/cmd/testdata/output/upgrade-and-take-ownership.txt
@@ -0,0 +1,8 @@
+Release "funny-bunny" has been upgraded. Happy Helming!
+NAME: funny-bunny
+LAST DEPLOYED: Fri Sep 2 22:04:05 1977
+NAMESPACE: default
+STATUS: deployed
+REVISION: 3
+DESCRIPTION: Upgrade complete
+TEST SUITE: None
diff --git a/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt b/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt
new file mode 100644
index 000000000..d5c42d15c
--- /dev/null
+++ b/pkg/cmd/testdata/output/upgrade-uninstalled-with-keep-history.txt
@@ -0,0 +1,8 @@
+Release "funny-bunny" does not exist. Installing it now.
+NAME: funny-bunny
+LAST DEPLOYED: Fri Sep 2 22:04:05 1977
+NAMESPACE: default
+STATUS: deployed
+REVISION: 3
+DESCRIPTION: Install complete
+TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt b/pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt
similarity index 100%
rename from cmd/helm/testdata/output/upgrade-with-bad-dependencies.txt
rename to pkg/cmd/testdata/output/upgrade-with-bad-dependencies.txt
diff --git a/cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt b/pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt
similarity index 100%
rename from cmd/helm/testdata/output/upgrade-with-bad-or-missing-existing-release.txt
rename to pkg/cmd/testdata/output/upgrade-with-bad-or-missing-existing-release.txt
diff --git a/cmd/helm/testdata/output/upgrade-with-dependency-update.txt b/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt
similarity index 86%
rename from cmd/helm/testdata/output/upgrade-with-dependency-update.txt
rename to pkg/cmd/testdata/output/upgrade-with-dependency-update.txt
index 0e7e5842e..d1517a686 100644
--- a/cmd/helm/testdata/output/upgrade-with-dependency-update.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-dependency-update.txt
@@ -4,6 +4,7 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 3
+DESCRIPTION: Upgrade complete
TEST SUITE: None
NOTES:
PARENT NOTES
diff --git a/cmd/helm/testdata/output/upgrade-with-install-timeout.txt b/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-install-timeout.txt
rename to pkg/cmd/testdata/output/upgrade-with-install-timeout.txt
index 5d8d3a4ea..b159dc3bc 100644
--- a/cmd/helm/testdata/output/upgrade-with-install-timeout.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-install-timeout.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 2
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-install.txt b/pkg/cmd/testdata/output/upgrade-with-install.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-install.txt
rename to pkg/cmd/testdata/output/upgrade-with-install.txt
index af61212bd..7dc2fce69 100644
--- a/cmd/helm/testdata/output/upgrade-with-install.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-install.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 2
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt b/pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt
similarity index 69%
rename from cmd/helm/testdata/output/upgrade-with-missing-dependencies.txt
rename to pkg/cmd/testdata/output/upgrade-with-missing-dependencies.txt
index adf2ae899..b2c154a80 100644
--- a/cmd/helm/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/cmd/helm/testdata/output/upgrade-with-pending-install.txt b/pkg/cmd/testdata/output/upgrade-with-pending-install.txt
similarity index 100%
rename from cmd/helm/testdata/output/upgrade-with-pending-install.txt
rename to pkg/cmd/testdata/output/upgrade-with-pending-install.txt
diff --git a/cmd/helm/testdata/output/upgrade-with-reset-values.txt b/pkg/cmd/testdata/output/upgrade-with-reset-values.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-reset-values.txt
rename to pkg/cmd/testdata/output/upgrade-with-reset-values.txt
index 01f1c0ac8..d02993a5c 100644
--- a/cmd/helm/testdata/output/upgrade-with-reset-values.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-reset-values.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 5
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-reset-values2.txt b/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-reset-values2.txt
rename to pkg/cmd/testdata/output/upgrade-with-reset-values2.txt
index fdd1d2db7..7780c4fdc 100644
--- a/cmd/helm/testdata/output/upgrade-with-reset-values2.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-reset-values2.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 6
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-timeout.txt b/pkg/cmd/testdata/output/upgrade-with-timeout.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-timeout.txt
rename to pkg/cmd/testdata/output/upgrade-with-timeout.txt
index be3a42368..b1edac3af 100644
--- a/cmd/helm/testdata/output/upgrade-with-timeout.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-timeout.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 4
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt b/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt
rename to pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt
index 500d07a11..21784413c 100644
--- a/cmd/helm/testdata/output/upgrade-with-wait-for-jobs.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-wait-for-jobs.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 3
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade-with-wait.txt b/pkg/cmd/testdata/output/upgrade-with-wait.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade-with-wait.txt
rename to pkg/cmd/testdata/output/upgrade-with-wait.txt
index 500d07a11..21784413c 100644
--- a/cmd/helm/testdata/output/upgrade-with-wait.txt
+++ b/pkg/cmd/testdata/output/upgrade-with-wait.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 3
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/upgrade.txt b/pkg/cmd/testdata/output/upgrade.txt
similarity index 85%
rename from cmd/helm/testdata/output/upgrade.txt
rename to pkg/cmd/testdata/output/upgrade.txt
index bea42db54..59267651f 100644
--- a/cmd/helm/testdata/output/upgrade.txt
+++ b/pkg/cmd/testdata/output/upgrade.txt
@@ -4,4 +4,5 @@ LAST DEPLOYED: Fri Sep 2 22:04:05 1977
NAMESPACE: default
STATUS: deployed
REVISION: 3
+DESCRIPTION: Upgrade complete
TEST SUITE: None
diff --git a/cmd/helm/testdata/output/values.json b/pkg/cmd/testdata/output/values.json
similarity index 100%
rename from cmd/helm/testdata/output/values.json
rename to pkg/cmd/testdata/output/values.json
diff --git a/cmd/helm/testdata/output/values.yaml b/pkg/cmd/testdata/output/values.yaml
similarity index 100%
rename from cmd/helm/testdata/output/values.yaml
rename to pkg/cmd/testdata/output/values.yaml
diff --git a/pkg/cmd/testdata/output/version-client-shorthand.txt b/pkg/cmd/testdata/output/version-client-shorthand.txt
new file mode 100644
index 000000000..3b138ae77
--- /dev/null
+++ b/pkg/cmd/testdata/output/version-client-shorthand.txt
@@ -0,0 +1 @@
+version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/pkg/cmd/testdata/output/version-client.txt b/pkg/cmd/testdata/output/version-client.txt
new file mode 100644
index 000000000..3b138ae77
--- /dev/null
+++ b/pkg/cmd/testdata/output/version-client.txt
@@ -0,0 +1 @@
+version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/cmd/helm/testdata/output/version-comp.txt b/pkg/cmd/testdata/output/version-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/version-comp.txt
rename to pkg/cmd/testdata/output/version-comp.txt
diff --git a/cmd/helm/testdata/output/version-invalid-comp.txt b/pkg/cmd/testdata/output/version-invalid-comp.txt
similarity index 100%
rename from cmd/helm/testdata/output/version-invalid-comp.txt
rename to pkg/cmd/testdata/output/version-invalid-comp.txt
diff --git a/pkg/cmd/testdata/output/version-short.txt b/pkg/cmd/testdata/output/version-short.txt
new file mode 100644
index 000000000..1961bcc21
--- /dev/null
+++ b/pkg/cmd/testdata/output/version-short.txt
@@ -0,0 +1 @@
+v4.0
diff --git a/pkg/cmd/testdata/output/version-template.txt b/pkg/cmd/testdata/output/version-template.txt
new file mode 100644
index 000000000..1c3c8f5d7
--- /dev/null
+++ b/pkg/cmd/testdata/output/version-template.txt
@@ -0,0 +1 @@
+Version: v4.0
\ No newline at end of file
diff --git a/pkg/cmd/testdata/output/version.txt b/pkg/cmd/testdata/output/version.txt
new file mode 100644
index 000000000..3b138ae77
--- /dev/null
+++ b/pkg/cmd/testdata/output/version.txt
@@ -0,0 +1 @@
+version.BuildInfo{Version:"v4.0", GitCommit:"", GitTreeState:"", GoVersion:""}
diff --git a/cmd/helm/testdata/password b/pkg/cmd/testdata/password
similarity index 100%
rename from cmd/helm/testdata/password
rename to pkg/cmd/testdata/password
diff --git a/cmd/helm/testdata/plugins.yaml b/pkg/cmd/testdata/plugins.yaml
similarity index 100%
rename from cmd/helm/testdata/plugins.yaml
rename to pkg/cmd/testdata/plugins.yaml
diff --git a/cmd/helm/testdata/repositories.yaml b/pkg/cmd/testdata/repositories.yaml
similarity index 100%
rename from cmd/helm/testdata/repositories.yaml
rename to pkg/cmd/testdata/repositories.yaml
diff --git a/cmd/helm/testdata/testcharts/alpine/Chart.yaml b/pkg/cmd/testdata/testcharts/alpine/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/Chart.yaml
rename to pkg/cmd/testdata/testcharts/alpine/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/alpine/README.md b/pkg/cmd/testdata/testcharts/alpine/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/README.md
rename to pkg/cmd/testdata/testcharts/alpine/README.md
diff --git a/cmd/helm/testdata/testcharts/alpine/extra_values.yaml b/pkg/cmd/testdata/testcharts/alpine/extra_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/extra_values.yaml
rename to pkg/cmd/testdata/testcharts/alpine/extra_values.yaml
diff --git a/cmd/helm/testdata/testcharts/alpine/more_values.yaml b/pkg/cmd/testdata/testcharts/alpine/more_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/more_values.yaml
rename to pkg/cmd/testdata/testcharts/alpine/more_values.yaml
diff --git a/cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/alpine/templates/alpine-pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/templates/alpine-pod.yaml
rename to pkg/cmd/testdata/testcharts/alpine/templates/alpine-pod.yaml
diff --git a/cmd/helm/testdata/testcharts/alpine/values.yaml b/pkg/cmd/testdata/testcharts/alpine/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/alpine/values.yaml
rename to pkg/cmd/testdata/testcharts/alpine/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore b/pkg/cmd/testdata/testcharts/chart-bad-requirements/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-bad-requirements/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore b/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml b/pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/charts/reqsubchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml b/pkg/cmd/testdata/testcharts/chart-bad-requirements/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-requirements/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-requirements/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-bad-type/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-type/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/README.md b/pkg/cmd/testdata/testcharts/chart-bad-type/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/README.md
rename to pkg/cmd/testdata/testcharts/chart-bad-type/README.md
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml b/pkg/cmd/testdata/testcharts/chart-bad-type/extra_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/extra_values.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-type/extra_values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml b/pkg/cmd/testdata/testcharts/chart-bad-type/more_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/more_values.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-type/more_values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-type/templates/alpine-pod.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-bad-type/values.yaml b/pkg/cmd/testdata/testcharts/chart-bad-type/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-bad-type/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-bad-type/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore b/pkg/cmd/testdata/testcharts/chart-missing-deps/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-missing-deps/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore b/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml b/pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/charts/reqsubchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml b/pkg/cmd/testdata/testcharts/chart-missing-deps/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-missing-deps/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-missing-deps/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/Chart.yaml
diff --git a/internal/ignore/testdata/mast/a.txt b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml
similarity index 100%
rename from internal/ignore/testdata/mast/a.txt
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/bad-subchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/Chart.yaml
diff --git a/internal/ignore/testdata/mast/b.txt b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml
similarity index 100%
rename from internal/ignore/testdata/mast/b.txt
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/charts/good-subchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/requirements.yaml
diff --git a/internal/ignore/testdata/mast/c.txt b/pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/values.yaml
similarity index 100%
rename from internal/ignore/testdata/mast/c.txt
rename to pkg/cmd/testdata/testcharts/chart-with-bad-subcharts/values.yaml
diff --git a/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/Chart.yaml
new file mode 100644
index 000000000..3a6e99952
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+appVersion: "1.0.0"
+description: A Helm chart for Kubernetes
+name: chart-with-deprecated-api
+type: application
+version: 1.0.0
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
new file mode 100644
index 000000000..b77a4beeb
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/templates/horizontalpodautoscaler.yaml
@@ -0,0 +1,9 @@
+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/internal/ignore/testdata/rudder.txt b/pkg/cmd/testdata/testcharts/chart-with-deprecated-api/values.yaml
similarity index 100%
rename from internal/ignore/testdata/rudder.txt
rename to pkg/cmd/testdata/testcharts/chart-with-deprecated-api/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/charts/common-0.0.5.tgz
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/_helpers.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/deployment.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/ingress.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/templates/service.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/templates/service.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-lib-dep/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-lib-dep/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-lib-dep/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore b/pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-only-crds/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-with-only-crds/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-only-crds/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-only-crds/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-only-crds/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml b/pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-only-crds/crds/test-crd.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/templates/empty.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.schema.json
diff --git a/internal/ignore/testdata/templates/.dotfile b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml
similarity index 100%
rename from internal/ignore/testdata/templates/.dotfile
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/charts/subchart-with-schema/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/templates/empty.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.schema.json
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-and-subchart/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-and-subchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-negative/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/templates/empty.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/templates/empty.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.schema.json
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema/values.schema.json
rename to pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.schema.json
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml
new file mode 100644
index 000000000..5a1250bff
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema-negative-skip-validation/values.yaml
@@ -0,0 +1,14 @@
+firstname: John
+lastname: Doe
+age: -5
+likesCoffee: true
+addresses:
+ - city: Springfield
+ street: Main
+ number: 12345
+ - city: New York
+ street: Broadway
+ number: 67890
+phoneNumbers:
+ - "(888) 888-8888"
+ - "(555) 555-5555"
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-negative/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema/templates/empty.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema-negative/templates/empty.yaml
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json
new file mode 100644
index 000000000..4df89bbe8
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.schema.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "addresses": {
+ "description": "List of addresses",
+ "items": {
+ "properties": {
+ "city": {
+ "type": "string"
+ },
+ "number": {
+ "type": "number"
+ },
+ "street": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "age": {
+ "description": "Age",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "employmentInfo": {
+ "properties": {
+ "salary": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "salary"
+ ],
+ "type": "object"
+ },
+ "firstname": {
+ "description": "First name",
+ "type": "string"
+ },
+ "lastname": {
+ "type": "string"
+ },
+ "likesCoffee": {
+ "type": "boolean"
+ },
+ "phoneNumbers": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "firstname",
+ "lastname",
+ "addresses",
+ "employmentInfo"
+ ],
+ "title": "Values",
+ "type": "object"
+}
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml
new file mode 100644
index 000000000..5a1250bff
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema-negative/values.yaml
@@ -0,0 +1,14 @@
+firstname: John
+lastname: Doe
+age: -5
+likesCoffee: true
+addresses:
+ - city: Springfield
+ street: Main
+ number: 12345
+ - city: New York
+ street: Broadway
+ number: 67890
+phoneNumbers:
+ - "(888) 888-8888"
+ - "(555) 555-5555"
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml
new file mode 100644
index 000000000..395d24f6a
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema/Chart.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+description: Empty testing chart
+home: https://k8s.io/helm
+name: empty
+sources:
+- https://github.com/kubernetes/helm
+version: 0.1.0
diff --git a/cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema/extra-values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-schema/extra-values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema/extra-values.yaml
diff --git a/cmd/helm/testdata/testcharts/empty/templates/empty.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema/templates/empty.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/empty/templates/empty.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-schema/templates/empty.yaml
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json b/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json
new file mode 100644
index 000000000..4df89bbe8
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema/values.schema.json
@@ -0,0 +1,67 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "properties": {
+ "addresses": {
+ "description": "List of addresses",
+ "items": {
+ "properties": {
+ "city": {
+ "type": "string"
+ },
+ "number": {
+ "type": "number"
+ },
+ "street": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "age": {
+ "description": "Age",
+ "minimum": 0,
+ "type": "integer"
+ },
+ "employmentInfo": {
+ "properties": {
+ "salary": {
+ "minimum": 0,
+ "type": "number"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "salary"
+ ],
+ "type": "object"
+ },
+ "firstname": {
+ "description": "First name",
+ "type": "string"
+ },
+ "lastname": {
+ "type": "string"
+ },
+ "likesCoffee": {
+ "type": "boolean"
+ },
+ "phoneNumbers": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "firstname",
+ "lastname",
+ "addresses",
+ "employmentInfo"
+ ],
+ "title": "Values",
+ "type": "object"
+}
diff --git a/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml
new file mode 100644
index 000000000..042dea664
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-schema/values.yaml
@@ -0,0 +1,17 @@
+firstname: John
+lastname: Doe
+age: 25
+likesCoffee: true
+employmentInfo:
+ title: Software Developer
+ salary: 100000
+addresses:
+ - city: Springfield
+ street: Main
+ number: 12345
+ - city: New York
+ street: Broadway
+ number: 67890
+phoneNumbers:
+ - "(888) 888-8888"
+ - "(555) 555-5555"
diff --git a/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml
new file mode 100644
index 000000000..46d069e1c
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-secret/Chart.yaml
@@ -0,0 +1,4 @@
+apiVersion: v2
+description: Chart with Kubernetes Secret
+name: chart-with-secret
+version: 0.0.1
diff --git a/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml b/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml
new file mode 100644
index 000000000..ce9c27d56
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-secret/templates/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: test-configmap
+data:
+ foo: bar
diff --git a/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml b/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml
new file mode 100644
index 000000000..b1e1cff56
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/chart-with-secret/templates/secret.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: test-secret
+stringData:
+ foo: bar
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-notes/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-notes/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-notes/charts/subchart-with-notes/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-notes/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock b/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.lock
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.lock
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.lock
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-update/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-update/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-update/charts/subchart-with-notes/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-subchart-update/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/charts/common-0.0.5.tgz
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/_helpers.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/deployment.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/ingress.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/templates/service.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-archive-dep/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/.helmignore b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/.helmignore
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md
similarity index 99%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md
index 0e06414d6..cafadcd72 100755
--- a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md
+++ b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/README.md
@@ -48,7 +48,7 @@ Note that the `common.service` template defines two parameters:
- A template name containing the service definition overrides
A limitation of the Go template library is that a template can only take a
-single argument. The `list` function is used to workaround this by constructing
+single argument. The `list` function is used to work around this by constructing
a list or array of arguments that is passed to the template.
The `common.service` template is responsible for rendering the templates with
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_chartref.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_container.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_deployment.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_envvar.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_fullname.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_ingress.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_annotations.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_metadata_labels.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_name.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_persistentvolumeclaim.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_secret.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_service.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_util.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/_volume.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/templates/configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/charts/common/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/NOTES.txt
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/_helpers.tpl
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/deployment.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/ingress.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/templates/service.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-lib-dep/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-lib-dep/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-lib-dep/values.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md
rename to pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/README.md
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/templates/alpine-pod.yaml
diff --git a/cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml b/pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml
rename to pkg/cmd/testdata/testcharts/chart-with-template-with-invalid-yaml/values.yaml
diff --git a/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tar.gz b/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tar.gz
similarity index 100%
rename from cmd/helm/testdata/testcharts/compressedchart-0.1.0.tar.gz
rename to pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tar.gz
diff --git a/cmd/helm/testdata/testcharts/compressedchart-0.1.0.tgz b/pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/compressedchart-0.1.0.tgz
rename to pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/compressedchart-0.2.0.tgz b/pkg/cmd/testdata/testcharts/compressedchart-0.2.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/compressedchart-0.2.0.tgz
rename to pkg/cmd/testdata/testcharts/compressedchart-0.2.0.tgz
diff --git a/cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz b/pkg/cmd/testdata/testcharts/compressedchart-0.3.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/compressedchart-0.3.0.tgz
rename to pkg/cmd/testdata/testcharts/compressedchart-0.3.0.tgz
diff --git a/cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz b/pkg/cmd/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz
rename to pkg/cmd/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/deprecated/Chart.yaml b/pkg/cmd/testdata/testcharts/deprecated/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/deprecated/Chart.yaml
rename to pkg/cmd/testdata/testcharts/deprecated/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/deprecated/README.md b/pkg/cmd/testdata/testcharts/deprecated/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/deprecated/README.md
rename to pkg/cmd/testdata/testcharts/deprecated/README.md
diff --git a/cmd/helm/testdata/testcharts/empty/Chart.yaml b/pkg/cmd/testdata/testcharts/empty/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/empty/Chart.yaml
rename to pkg/cmd/testdata/testcharts/empty/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/empty/README.md b/pkg/cmd/testdata/testcharts/empty/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/empty/README.md
rename to pkg/cmd/testdata/testcharts/empty/README.md
diff --git a/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml b/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml
new file mode 100644
index 000000000..c80812f6e
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/empty/templates/empty.yaml
@@ -0,0 +1 @@
+# This file is intentionally blank
diff --git a/cmd/helm/testdata/testcharts/empty/values.yaml b/pkg/cmd/testdata/testcharts/empty/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/empty/values.yaml
rename to pkg/cmd/testdata/testcharts/empty/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-7233/.helmignore b/pkg/cmd/testdata/testcharts/issue-7233/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/.helmignore
rename to pkg/cmd/testdata/testcharts/issue-7233/.helmignore
diff --git a/cmd/helm/testdata/testcharts/issue-7233/Chart.yaml b/pkg/cmd/testdata/testcharts/issue-7233/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/Chart.yaml
rename to pkg/cmd/testdata/testcharts/issue-7233/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-7233/requirements.lock b/pkg/cmd/testdata/testcharts/issue-7233/requirements.lock
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/requirements.lock
rename to pkg/cmd/testdata/testcharts/issue-7233/requirements.lock
diff --git a/cmd/helm/testdata/testcharts/issue-7233/requirements.yaml b/pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/requirements.yaml
rename to pkg/cmd/testdata/testcharts/issue-7233/requirements.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml b/pkg/cmd/testdata/testcharts/issue-7233/templates/configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/templates/configmap.yaml
rename to pkg/cmd/testdata/testcharts/issue-7233/templates/configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-7233/values.yaml b/pkg/cmd/testdata/testcharts/issue-7233/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-7233/values.yaml
rename to pkg/cmd/testdata/testcharts/issue-7233/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/Chart.yaml b/pkg/cmd/testdata/testcharts/issue-9027/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/Chart.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml b/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml b/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/templates/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml b/pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/charts/subchart/values.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/charts/subchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml b/pkg/cmd/testdata/testcharts/issue-9027/templates/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/templates/values.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/templates/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue-9027/values.yaml b/pkg/cmd/testdata/testcharts/issue-9027/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue-9027/values.yaml
rename to pkg/cmd/testdata/testcharts/issue-9027/values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue1979/Chart.yaml b/pkg/cmd/testdata/testcharts/issue1979/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/Chart.yaml
rename to pkg/cmd/testdata/testcharts/issue1979/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/issue1979/README.md b/pkg/cmd/testdata/testcharts/issue1979/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/README.md
rename to pkg/cmd/testdata/testcharts/issue1979/README.md
diff --git a/cmd/helm/testdata/testcharts/issue1979/extra_values.yaml b/pkg/cmd/testdata/testcharts/issue1979/extra_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/extra_values.yaml
rename to pkg/cmd/testdata/testcharts/issue1979/extra_values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue1979/more_values.yaml b/pkg/cmd/testdata/testcharts/issue1979/more_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/more_values.yaml
rename to pkg/cmd/testdata/testcharts/issue1979/more_values.yaml
diff --git a/cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/issue1979/templates/alpine-pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/templates/alpine-pod.yaml
rename to pkg/cmd/testdata/testcharts/issue1979/templates/alpine-pod.yaml
diff --git a/cmd/helm/testdata/testcharts/issue1979/values.yaml b/pkg/cmd/testdata/testcharts/issue1979/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/issue1979/values.yaml
rename to pkg/cmd/testdata/testcharts/issue1979/values.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/.helmignore b/pkg/cmd/testdata/testcharts/lib-chart/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/.helmignore
rename to pkg/cmd/testdata/testcharts/lib-chart/.helmignore
diff --git a/cmd/helm/testdata/testcharts/lib-chart/Chart.yaml b/pkg/cmd/testdata/testcharts/lib-chart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/README.md b/pkg/cmd/testdata/testcharts/lib-chart/README.md
similarity index 99%
rename from cmd/helm/testdata/testcharts/lib-chart/README.md
rename to pkg/cmd/testdata/testcharts/lib-chart/README.md
index 87b753f25..f69ff1c02 100644
--- a/cmd/helm/testdata/testcharts/lib-chart/README.md
+++ b/pkg/cmd/testdata/testcharts/lib-chart/README.md
@@ -48,7 +48,7 @@ Note that the `common.service` template defines two parameters:
- A template name containing the service definition overrides
A limitation of the Go template library is that a template can only take a
-single argument. The `list` function is used to workaround this by constructing
+single argument. The `list` function is used to work around this by constructing
a list or array of arguments that is passed to the template.
The `common.service` template is responsible for rendering the templates with
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_chartref.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_chartref.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_chartref.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_configmap.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_container.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_container.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_container.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_deployment.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_deployment.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_deployment.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_envvar.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_envvar.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_envvar.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_fullname.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_fullname.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_fullname.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_ingress.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_ingress.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_ingress.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_metadata.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_annotations.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_metadata_labels.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_name.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_name.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_name.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_persistentvolumeclaim.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_secret.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_secret.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_secret.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml b/pkg/cmd/testdata/testcharts/lib-chart/templates/_service.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_service.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_service.yaml
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_util.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_util.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_util.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl b/pkg/cmd/testdata/testcharts/lib-chart/templates/_volume.tpl
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/templates/_volume.tpl
rename to pkg/cmd/testdata/testcharts/lib-chart/templates/_volume.tpl
diff --git a/cmd/helm/testdata/testcharts/lib-chart/values.yaml b/pkg/cmd/testdata/testcharts/lib-chart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/lib-chart/values.yaml
rename to pkg/cmd/testdata/testcharts/lib-chart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/object-order/Chart.yaml b/pkg/cmd/testdata/testcharts/object-order/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/object-order/Chart.yaml
rename to pkg/cmd/testdata/testcharts/object-order/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/object-order/templates/01-a.yml b/pkg/cmd/testdata/testcharts/object-order/templates/01-a.yml
similarity index 100%
rename from cmd/helm/testdata/testcharts/object-order/templates/01-a.yml
rename to pkg/cmd/testdata/testcharts/object-order/templates/01-a.yml
diff --git a/cmd/helm/testdata/testcharts/object-order/templates/02-b.yml b/pkg/cmd/testdata/testcharts/object-order/templates/02-b.yml
similarity index 100%
rename from cmd/helm/testdata/testcharts/object-order/templates/02-b.yml
rename to pkg/cmd/testdata/testcharts/object-order/templates/02-b.yml
diff --git a/internal/ignore/testdata/tiller.txt b/pkg/cmd/testdata/testcharts/object-order/values.yaml
similarity index 100%
rename from internal/ignore/testdata/tiller.txt
rename to pkg/cmd/testdata/testcharts/object-order/values.yaml
diff --git a/cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz b/pkg/cmd/testdata/testcharts/oci-dependent-chart-0.1.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/oci-dependent-chart-0.1.0.tgz
rename to pkg/cmd/testdata/testcharts/oci-dependent-chart-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz b/pkg/cmd/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz
rename to pkg/cmd/testdata/testcharts/pre-release-chart-0.1.0-alpha.tgz
diff --git a/cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz b/pkg/cmd/testdata/testcharts/reqtest-0.1.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz
rename to pkg/cmd/testdata/testcharts/reqtest-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/reqtest/.helmignore b/pkg/cmd/testdata/testcharts/reqtest/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/.helmignore
rename to pkg/cmd/testdata/testcharts/reqtest/.helmignore
diff --git a/cmd/helm/testdata/testcharts/reqtest/Chart.lock b/pkg/cmd/testdata/testcharts/reqtest/Chart.lock
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/Chart.lock
rename to pkg/cmd/testdata/testcharts/reqtest/Chart.lock
diff --git a/cmd/helm/testdata/testcharts/reqtest/Chart.yaml b/pkg/cmd/testdata/testcharts/reqtest/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/Chart.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml
diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz b/pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz
rename to pkg/cmd/testdata/testcharts/reqtest/charts/reqsubchart3-0.2.0.tgz
diff --git a/cmd/helm/testdata/testcharts/reqtest/values.yaml b/pkg/cmd/testdata/testcharts/reqtest/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/reqtest/values.yaml
rename to pkg/cmd/testdata/testcharts/reqtest/values.yaml
diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz b/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest-0.1.0.tgz
rename to pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz
diff --git a/cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov b/pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz.prov
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest-0.1.0.tgz.prov
rename to pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz.prov
diff --git a/cmd/helm/testdata/testcharts/signtest/.helmignore b/pkg/cmd/testdata/testcharts/signtest/.helmignore
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/.helmignore
rename to pkg/cmd/testdata/testcharts/signtest/.helmignore
diff --git a/cmd/helm/testdata/testcharts/signtest/Chart.yaml b/pkg/cmd/testdata/testcharts/signtest/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/Chart.yaml
rename to pkg/cmd/testdata/testcharts/signtest/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml b/pkg/cmd/testdata/testcharts/signtest/alpine/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/alpine/Chart.yaml
rename to pkg/cmd/testdata/testcharts/signtest/alpine/Chart.yaml
diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/README.md b/pkg/cmd/testdata/testcharts/signtest/alpine/README.md
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/alpine/README.md
rename to pkg/cmd/testdata/testcharts/signtest/alpine/README.md
diff --git a/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml b/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml
new file mode 100644
index 000000000..5bbae10af
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/signtest/alpine/templates/alpine-pod.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: {{.Release.Name}}-{{.Chart.Name}}
+ labels:
+ app.kubernetes.io/managed-by: {{.Release.Service}}
+ chartName: {{.Chart.Name}}
+ chartVersion: {{.Chart.Version | quote}}
+spec:
+ restartPolicy: {{default "Never" .restart_policy}}
+ containers:
+ - name: waiter
+ image: "alpine:3.3"
+ command: ["/bin/sleep","9000"]
diff --git a/cmd/helm/testdata/testcharts/signtest/alpine/values.yaml b/pkg/cmd/testdata/testcharts/signtest/alpine/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/alpine/values.yaml
rename to pkg/cmd/testdata/testcharts/signtest/alpine/values.yaml
diff --git a/cmd/helm/testdata/testcharts/signtest/templates/pod.yaml b/pkg/cmd/testdata/testcharts/signtest/templates/pod.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/signtest/templates/pod.yaml
rename to pkg/cmd/testdata/testcharts/signtest/templates/pod.yaml
diff --git a/pkg/cmd/testdata/testcharts/signtest/values.yaml b/pkg/cmd/testdata/testcharts/signtest/values.yaml
new file mode 100644
index 000000000..e69de29bb
diff --git a/cmd/helm/testdata/testcharts/subchart/Chart.yaml b/pkg/cmd/testdata/testcharts/subchart/Chart.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/Chart.yaml
rename to pkg/cmd/testdata/testcharts/subchart/Chart.yaml
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart1/charts/subchartA/Chart.yaml
rename to pkg/cmd/testdata/testcharts/subchart/charts/subchartA/Chart.yaml
diff --git a/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml
new file mode 100644
index 000000000..27501e1e0
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml
new file mode 100644
index 000000000..f0381ae6a
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/charts/subchartA/values.yaml
@@ -0,0 +1,17 @@
+# Default values for subchart.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+# subchartA
+service:
+ name: apache
+ type: ClusterIP
+ externalPort: 80
+ internalPort: 80
+SCAdata:
+ SCAbool: false
+ SCAfloat: 3.1
+ SCAint: 55
+ SCAstring: "jabba"
+ SCAnested1:
+ SCAnested2: true
+
diff --git a/pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/Chart.yaml
similarity index 100%
rename from pkg/chartutil/testdata/subpop/charts/subchart2/charts/subchartB/Chart.yaml
rename to pkg/cmd/testdata/testcharts/subchart/charts/subchartB/Chart.yaml
diff --git a/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml
new file mode 100644
index 000000000..27501e1e0
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml b/pkg/cmd/testdata/testcharts/subchart/charts/subchartB/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/charts/subchartB/values.yaml
rename to pkg/cmd/testdata/testcharts/subchart/charts/subchartB/values.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml b/pkg/cmd/testdata/testcharts/subchart/crds/crdA.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/crds/crdA.yaml
rename to pkg/cmd/testdata/testcharts/subchart/crds/crdA.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/extra_values.yaml b/pkg/cmd/testdata/testcharts/subchart/extra_values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/extra_values.yaml
rename to pkg/cmd/testdata/testcharts/subchart/extra_values.yaml
diff --git a/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt b/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt
new file mode 100644
index 000000000..4bdf443f6
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/templates/NOTES.txt
@@ -0,0 +1 @@
+Sample notes for {{ .Chart.Name }}
\ No newline at end of file
diff --git a/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml
new file mode 100644
index 000000000..19c931cc3
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/templates/service.yaml
@@ -0,0 +1,25 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ .Chart.Name }}
+ labels:
+ helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
+ app.kubernetes.io/instance: "{{ .Release.Name }}"
+ kube-version/major: "{{ .Capabilities.KubeVersion.Major }}"
+ kube-version/minor: "{{ .Capabilities.KubeVersion.Minor }}"
+ kube-version/version: "v{{ .Capabilities.KubeVersion.Major }}.{{ .Capabilities.KubeVersion.Minor }}.0"
+{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test" }}
+ kube-api-version/test: v1
+{{- end }}
+{{- if .Capabilities.APIVersions.Has "helm.k8s.io/test2" }}
+ kube-api-version/test2: v2
+{{- end }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.externalPort }}
+ targetPort: {{ .Values.service.internalPort }}
+ protocol: TCP
+ name: {{ .Values.service.name }}
+ selector:
+ app.kubernetes.io/name: {{ .Chart.Name }}
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/subdir/configmap.yaml
rename to pkg/cmd/testdata/testcharts/subchart/templates/subdir/configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/role.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/subdir/role.yaml
rename to pkg/cmd/testdata/testcharts/subchart/templates/subdir/role.yaml
diff --git a/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml
new file mode 100644
index 000000000..5d193f1a6
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/rolebinding.yaml
@@ -0,0 +1,12 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ .Chart.Name }}-binding
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: {{ .Chart.Name }}-role
+subjects:
+- kind: ServiceAccount
+ name: {{ .Chart.Name }}-sa
+ namespace: default
diff --git a/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml
new file mode 100644
index 000000000..7126c7d89
--- /dev/null
+++ b/pkg/cmd/testdata/testcharts/subchart/templates/subdir/serviceaccount.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ .Chart.Name }}-sa
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-config.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/tests/test-config.yaml
rename to pkg/cmd/testdata/testcharts/subchart/templates/tests/test-config.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml b/pkg/cmd/testdata/testcharts/subchart/templates/tests/test-nothing.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/templates/tests/test-nothing.yaml
rename to pkg/cmd/testdata/testcharts/subchart/templates/tests/test-nothing.yaml
diff --git a/cmd/helm/testdata/testcharts/subchart/values.yaml b/pkg/cmd/testdata/testcharts/subchart/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/subchart/values.yaml
rename to pkg/cmd/testdata/testcharts/subchart/values.yaml
diff --git a/cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml b/pkg/cmd/testdata/testcharts/upgradetest/templates/configmap.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/upgradetest/templates/configmap.yaml
rename to pkg/cmd/testdata/testcharts/upgradetest/templates/configmap.yaml
diff --git a/cmd/helm/testdata/testcharts/upgradetest/values.yaml b/pkg/cmd/testdata/testcharts/upgradetest/values.yaml
similarity index 100%
rename from cmd/helm/testdata/testcharts/upgradetest/values.yaml
rename to pkg/cmd/testdata/testcharts/upgradetest/values.yaml
diff --git a/cmd/helm/testdata/testserver/index.yaml b/pkg/cmd/testdata/testserver/index.yaml
similarity index 100%
rename from cmd/helm/testdata/testserver/index.yaml
rename to pkg/cmd/testdata/testserver/index.yaml
diff --git a/cmd/helm/testdata/testserver/repository/repositories.yaml b/pkg/cmd/testdata/testserver/repository/repositories.yaml
similarity index 100%
rename from cmd/helm/testdata/testserver/repository/repositories.yaml
rename to pkg/cmd/testdata/testserver/repository/repositories.yaml
diff --git a/cmd/helm/uninstall.go b/pkg/cmd/uninstall.go
similarity index 88%
rename from cmd/helm/uninstall.go
rename to pkg/cmd/uninstall.go
index 9ced8fef0..4680c324a 100644
--- a/cmd/helm/uninstall.go
+++ b/pkg/cmd/uninstall.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const uninstallDesc = `
@@ -47,10 +47,10 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "uninstall a release",
Long: uninstallDesc,
Args: require.MinimumNArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return compListReleases(toComplete, args, cfg)
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
validationErr := validateCascadeFlag(client)
if validationErr != nil {
return validationErr
@@ -76,10 +76,10 @@ func newUninstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during uninstallation")
f.BoolVar(&client.IgnoreNotFound, "ignore-not-found", false, `Treat "release not found" as a successful uninstall`)
f.BoolVar(&client.KeepHistory, "keep-history", false, "remove all associated resources and mark the release as deleted, but retain the release history")
- f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all the resources are deleted before returning. It will wait for as long as --timeout")
f.StringVar(&client.DeletionPropagation, "cascade", "background", "Must be \"background\", \"orphan\", or \"foreground\". Selects the deletion cascading strategy for the dependents. Defaults to background.")
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
f.StringVar(&client.Description, "description", "", "add a custom description")
+ AddWaitFlag(cmd, &client.WaitStrategy)
return cmd
}
diff --git a/cmd/helm/uninstall_test.go b/pkg/cmd/uninstall_test.go
similarity index 97%
rename from cmd/helm/uninstall_test.go
rename to pkg/cmd/uninstall_test.go
index 23b61058e..1123f449b 100644
--- a/cmd/helm/uninstall_test.go
+++ b/pkg/cmd/uninstall_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestUninstall(t *testing.T) {
diff --git a/cmd/helm/upgrade.go b/pkg/cmd/upgrade.go
similarity index 69%
rename from cmd/helm/upgrade.go
rename to pkg/cmd/upgrade.go
index 328497d7e..4ca889fc2 100644
--- a/cmd/helm/upgrade.go
+++ b/pkg/cmd/upgrade.go
@@ -14,29 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"context"
"fmt"
"io"
"log"
+ "log/slog"
"os"
"os/signal"
"syscall"
"time"
- "github.com/pkg/errors"
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/cli/output"
- "helm.sh/helm/v3/pkg/cli/values"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/storage/driver"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/cli/output"
+ "helm.sh/helm/v4/pkg/cli/values"
+ "helm.sh/helm/v4/pkg/cmd/require"
+ "helm.sh/helm/v4/pkg/downloader"
+ "helm.sh/helm/v4/pkg/getter"
+ release "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
const upgradeDesc = `
@@ -52,7 +53,7 @@ or use the '--set' flag and pass configuration from the command line, to force s
values, use '--set-string'. You can use '--set-file' to set individual
values from a file when the value itself is too long for the command line
or is dynamically generated. You can also use '--set-json' to set json values
-(scalars/objects/arrays) from the command line.
+(scalars/objects/arrays) from the command line. Additionally, you can use '--set-json' and passing json object as a string.
You can specify the '--values'/'-f' flag multiple times. The priority will be given to the
last (right-most) file specified. For example, if both myvalues.yaml and override.yaml
@@ -72,6 +73,10 @@ parameters, and existing values will be merged with any values set via '--values
or '--set' flags. Priority is given to new values.
$ helm upgrade --reuse-values --set foo=bar --set foo=newbar redis ./redis
+
+The --dry-run flag will output all generated chart manifests, including Secrets
+which can contain sensitive values. To hide Kubernetes Secrets use the
+--hide-secret flag. Please carefully consider how and when these flags are used.
`
func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
@@ -85,20 +90,20 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
Short: "upgrade a release",
Long: upgradeDesc,
Args: require.ExactArgs(2),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return compListReleases(toComplete, args, cfg)
}
if len(args) == 1 {
return compListCharts(toComplete, true)
}
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
client.Namespace = settings.Namespace()
registryClient, err := newRegistryClient(client.CertFile, client.KeyFile, client.CaFile,
- client.InsecureSkipTLSverify, client.PlainHTTP)
+ client.InsecureSkipTLSverify, client.PlainHTTP, client.Username, client.Password)
if err != nil {
return fmt.Errorf("missing registry client: %w", err)
}
@@ -111,12 +116,13 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
client.DryRunOption = "none"
}
// Fixes #7002 - Support reading values from STDIN for `upgrade` command
- // Must load values AFTER determining if we have to call install so that values loaded from stdin are are not read twice
+ // Must load values AFTER determining if we have to call install so that values loaded from stdin are not read twice
if client.Install {
// If a release does not exist, install it.
histClient := action.NewHistory(cfg)
histClient.Max = 1
- if _, err := histClient.Run(args[0]); err == driver.ErrReleaseNotFound {
+ versions, err := histClient.Run(args[0])
+ if err == driver.ErrReleaseNotFound || isReleaseUninstalled(versions) {
// Only print this to stdout for table output
if outfmt == output.Table {
fmt.Fprintf(out, "Release %q does not exist. Installing it now.\n", args[0])
@@ -124,41 +130,55 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
instClient := action.NewInstall(cfg)
instClient.CreateNamespace = createNamespace
instClient.ChartPathOptions = client.ChartPathOptions
- instClient.Force = client.Force
+ instClient.ForceReplace = client.ForceReplace
instClient.DryRun = client.DryRun
instClient.DryRunOption = client.DryRunOption
instClient.DisableHooks = client.DisableHooks
instClient.SkipCRDs = client.SkipCRDs
instClient.Timeout = client.Timeout
- instClient.Wait = client.Wait
+ instClient.WaitStrategy = client.WaitStrategy
instClient.WaitForJobs = client.WaitForJobs
instClient.Devel = client.Devel
instClient.Namespace = client.Namespace
- instClient.Atomic = client.Atomic
+ instClient.RollbackOnFailure = client.RollbackOnFailure
instClient.PostRenderer = client.PostRenderer
instClient.DisableOpenAPIValidation = client.DisableOpenAPIValidation
instClient.SubNotes = client.SubNotes
+ instClient.HideNotes = client.HideNotes
+ instClient.SkipSchemaValidation = client.SkipSchemaValidation
instClient.Description = client.Description
instClient.DependencyUpdate = client.DependencyUpdate
instClient.Labels = client.Labels
instClient.EnableDNS = client.EnableDNS
+ instClient.HideSecret = client.HideSecret
+ instClient.TakeOwnership = client.TakeOwnership
+
+ if isReleaseUninstalled(versions) {
+ instClient.Replace = true
+ }
rel, err := runInstall(args, instClient, valueOpts, out)
if err != nil {
return err
}
- return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false})
+ return outfmt.Write(out, &statusPrinter{
+ release: rel,
+ debug: settings.Debug,
+ showMetadata: false,
+ hideNotes: instClient.HideNotes,
+ noColor: settings.ShouldDisableColor(),
+ })
} else if err != nil {
return err
}
}
if client.Version == "" && client.Devel {
- debug("setting version to >0.0.0-0")
+ slog.Debug("setting version to >0.0.0-0")
client.Version = ">0.0.0-0"
}
- chartPath, err := client.ChartPathOptions.LocateChart(args[1], settings)
+ chartPath, err := client.LocateChart(args[1], settings)
if err != nil {
return err
}
@@ -180,16 +200,17 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
if req := ch.Metadata.Dependencies; req != nil {
if err := action.CheckDependencies(ch, req); err != nil {
- err = errors.Wrap(err, "An error occurred while checking for chart dependencies. You may need to run `helm dependency build` to fetch missing dependencies")
+ 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,
ChartPath: chartPath,
- Keyring: client.ChartPathOptions.Keyring,
+ Keyring: client.Keyring,
SkipUpdate: false,
Getters: p,
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
+ ContentCache: settings.ContentCache,
Debug: settings.Debug,
}
if err := man.Update(); err != nil {
@@ -197,7 +218,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
// Reload the chart with the updated Chart.lock file.
if ch, err = loader.Load(chartPath); err != nil {
- return errors.Wrap(err, "failed reloading chart after repo update")
+ return fmt.Errorf("failed reloading chart after repo update: %w", err)
}
} else {
return err
@@ -206,7 +227,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
}
if ch.Metadata.Deprecated {
- warning("This chart is deprecated")
+ slog.Warn("this chart is deprecated")
}
// Create context and prepare the handle of SIGTERM
@@ -226,14 +247,20 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
rel, err := client.RunWithContext(ctx, args[0], ch, vals)
if err != nil {
- return errors.Wrap(err, "UPGRADE FAILED")
+ return fmt.Errorf("UPGRADE FAILED: %w", err)
}
if outfmt == output.Table {
fmt.Fprintf(out, "Release %q has been upgraded. Happy Helming!\n", args[0])
}
- return outfmt.Write(out, &statusPrinter{rel, settings.Debug, false, false, false})
+ return outfmt.Write(out, &statusPrinter{
+ release: rel,
+ debug: settings.Debug,
+ showMetadata: false,
+ hideNotes: client.HideNotes,
+ noColor: settings.ShouldDisableColor(),
+ })
},
}
@@ -242,41 +269,51 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command {
f.BoolVarP(&client.Install, "install", "i", false, "if a release by this name doesn't already exist, run an install")
f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored")
f.StringVar(&client.DryRunOption, "dry-run", "", "simulate an install. If --dry-run is set with no option being specified or as '--dry-run=client', it will not attempt cluster connections. Setting '--dry-run=server' allows attempting cluster connections.")
+ f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag")
f.Lookup("dry-run").NoOptDefVal = "client"
- f.BoolVar(&client.Recreate, "recreate-pods", false, "performs pods restart for the resource if applicable")
- f.MarkDeprecated("recreate-pods", "functionality will no longer be updated. Consult the documentation for other methods to recreate pods")
- f.BoolVar(&client.Force, "force", false, "force resource updates through a replacement strategy")
+ f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement")
+ f.BoolVar(&client.ForceReplace, "force", false, "deprecated")
+ f.MarkDeprecated("force", "use --force-replace instead")
f.BoolVar(&client.DisableHooks, "no-hooks", false, "disable pre/post upgrade hooks")
f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the upgrade process will not validate rendered templates against the Kubernetes OpenAPI Schema")
f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed when an upgrade is performed with install flag enabled. By default, CRDs are installed if not already present, when an upgrade is performed with install flag enabled")
f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)")
f.BoolVar(&client.ResetValues, "reset-values", false, "when upgrading, reset the values to the ones built into the chart")
f.BoolVar(&client.ReuseValues, "reuse-values", false, "when upgrading, reuse the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' is specified, this is ignored")
- f.BoolVar(&client.Wait, "wait", false, "if set, will wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment, StatefulSet, or ReplicaSet are in a ready state before marking the release as successful. It will wait for as long as --timeout")
+ f.BoolVar(&client.ResetThenReuseValues, "reset-then-reuse-values", false, "when upgrading, reset the values to the ones built into the chart, apply the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' or '--reuse-values' is specified, this is ignored")
f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout")
- f.BoolVar(&client.Atomic, "atomic", false, "if set, upgrade process rolls back changes made in case of failed upgrade. The --wait flag will be set automatically if --atomic is used")
+ f.BoolVar(&client.RollbackOnFailure, "rollback-on-failure", false, "if set, Helm will rollback the upgrade to previous success release upon failure. The --wait flag will be defaulted to \"watcher\" if --rollback-on-failure is set")
+ f.BoolVar(&client.RollbackOnFailure, "atomic", false, "deprecated")
+ f.MarkDeprecated("atomic", "use --rollback-on-failure instead")
f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit")
f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this upgrade when upgrade fails")
f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent")
+ f.BoolVar(&client.HideNotes, "hide-notes", false, "if set, do not show notes in upgrade output. Does not affect presence in chart metadata")
+ f.BoolVar(&client.SkipSchemaValidation, "skip-schema-validation", false, "if set, disables JSON schema validation")
f.StringToStringVarP(&client.Labels, "labels", "l", nil, "Labels that would be added to release metadata. Should be separated by comma. Original release labels will be merged with upgrade labels. You can unset label using null.")
f.StringVar(&client.Description, "description", "", "add a custom description")
f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart")
f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates")
+ f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources")
addChartPathOptionsFlags(f, &client.ChartPathOptions)
addValueOptionsFlags(f, valueOpts)
bindOutputFlag(cmd, &outfmt)
bindPostRenderFlag(cmd, &client.PostRenderer)
+ AddWaitFlag(cmd, &client.WaitStrategy)
- err := cmd.RegisterFlagCompletionFunc("version", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 2 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return compVersionFlag(args[1], toComplete)
})
-
if err != nil {
log.Fatal(err)
}
return cmd
}
+
+func isReleaseUninstalled(versions []*release.Release) bool {
+ return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled
+}
diff --git a/cmd/helm/upgrade_test.go b/pkg/cmd/upgrade_test.go
similarity index 74%
rename from cmd/helm/upgrade_test.go
rename to pkg/cmd/upgrade_test.go
index 485267d1d..d7375dcad 100644
--- a/cmd/helm/upgrade_test.go
+++ b/pkg/cmd/upgrade_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -24,10 +24,10 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestUpgradeCmd(t *testing.T) {
@@ -114,6 +114,12 @@ func TestUpgradeCmd(t *testing.T) {
golden: "output/upgrade-with-reset-values2.txt",
rels: []*release.Release{relMock("funny-bunny", 5, ch2)},
},
+ {
+ name: "upgrade a release with --take-ownership",
+ cmd: fmt.Sprintf("upgrade funny-bunny '%s' --take-ownership", chartPath),
+ golden: "output/upgrade-and-take-ownership.txt",
+ rels: []*release.Release{relMock("funny-bunny", 2, ch)},
+ },
{
name: "install a release with 'upgrade --install'",
cmd: fmt.Sprintf("upgrade zany-bunny -i '%s'", chartPath),
@@ -175,13 +181,19 @@ func TestUpgradeCmd(t *testing.T) {
wantError: true,
rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusPendingInstall)},
},
+ {
+ name: "install a previously uninstalled release with '--keep-history' using 'upgrade --install'",
+ cmd: fmt.Sprintf("upgrade funny-bunny -i '%s'", chartPath),
+ golden: "output/upgrade-uninstalled-with-keep-history.txt",
+ rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, release.StatusUninstalled)},
+ },
}
runTestCmd(t, tests)
}
func TestUpgradeWithValue(t *testing.T) {
releaseName := "funny-bunny-v2"
- relMock, ch, chartPath := prepareMockRelease(releaseName, t)
+ relMock, ch, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -208,7 +220,7 @@ func TestUpgradeWithValue(t *testing.T) {
func TestUpgradeWithStringValue(t *testing.T) {
releaseName := "funny-bunny-v3"
- relMock, ch, chartPath := prepareMockRelease(releaseName, t)
+ relMock, ch, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -236,7 +248,7 @@ func TestUpgradeWithStringValue(t *testing.T) {
func TestUpgradeInstallWithSubchartNotes(t *testing.T) {
releaseName := "wacky-bunny-v1"
- relMock, ch, _ := prepareMockRelease(releaseName, t)
+ relMock, ch, _ := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -268,7 +280,7 @@ func TestUpgradeInstallWithSubchartNotes(t *testing.T) {
func TestUpgradeWithValuesFile(t *testing.T) {
releaseName := "funny-bunny-v4"
- relMock, ch, chartPath := prepareMockRelease(releaseName, t)
+ relMock, ch, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -296,7 +308,7 @@ func TestUpgradeWithValuesFile(t *testing.T) {
func TestUpgradeWithValuesFromStdin(t *testing.T) {
releaseName := "funny-bunny-v5"
- relMock, ch, chartPath := prepareMockRelease(releaseName, t)
+ relMock, ch, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -328,7 +340,7 @@ func TestUpgradeWithValuesFromStdin(t *testing.T) {
func TestUpgradeInstallWithValuesFromStdin(t *testing.T) {
releaseName := "funny-bunny-v6"
- _, _, chartPath := prepareMockRelease(releaseName, t)
+ _, _, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -356,7 +368,8 @@ func TestUpgradeInstallWithValuesFromStdin(t *testing.T) {
}
-func prepareMockRelease(releaseName string, t *testing.T) (func(n string, v int, ch *chart.Chart) *release.Release, *chart.Chart, string) {
+func prepareMockRelease(t *testing.T, releaseName string) (func(n string, v int, ch *chart.Chart) *release.Release, *chart.Chart, string) {
+ t.Helper()
tmpChart := t.TempDir()
configmapData, err := os.ReadFile("testdata/testcharts/upgradetest/templates/configmap.yaml")
if err != nil {
@@ -433,7 +446,7 @@ func TestUpgradeFileCompletion(t *testing.T) {
func TestUpgradeInstallWithLabels(t *testing.T) {
releaseName := "funny-bunny-labels"
- _, _, chartPath := prepareMockRelease(releaseName, t)
+ _, _, chartPath := prepareMockRelease(t, releaseName)
defer resetEnv()()
@@ -458,3 +471,105 @@ func TestUpgradeInstallWithLabels(t *testing.T) {
t.Errorf("Expected {%v}, got {%v}", expectedLabels, updatedRel.Labels)
}
}
+
+func prepareMockReleaseWithSecret(t *testing.T, releaseName string) (func(n string, v int, ch *chart.Chart) *release.Release, *chart.Chart, string) {
+ t.Helper()
+ tmpChart := t.TempDir()
+ configmapData, err := os.ReadFile("testdata/testcharts/chart-with-secret/templates/configmap.yaml")
+ if err != nil {
+ t.Fatalf("Error loading template yaml %v", err)
+ }
+ secretData, err := os.ReadFile("testdata/testcharts/chart-with-secret/templates/secret.yaml")
+ if err != nil {
+ t.Fatalf("Error loading template yaml %v", err)
+ }
+ cfile := &chart.Chart{
+ Metadata: &chart.Metadata{
+ APIVersion: chart.APIVersionV1,
+ Name: "testUpgradeChart",
+ Description: "A Helm chart for Kubernetes",
+ Version: "0.1.0",
+ },
+ Templates: []*chart.File{{Name: "templates/configmap.yaml", Data: configmapData}, {Name: "templates/secret.yaml", Data: secretData}},
+ }
+ chartPath := filepath.Join(tmpChart, cfile.Metadata.Name)
+ if err := chartutil.SaveDir(cfile, tmpChart); err != nil {
+ t.Fatalf("Error creating chart for upgrade: %v", err)
+ }
+ ch, err := loader.Load(chartPath)
+ if err != nil {
+ t.Fatalf("Error loading chart: %v", err)
+ }
+ _ = release.Mock(&release.MockReleaseOptions{
+ Name: releaseName,
+ Chart: ch,
+ })
+
+ relMock := func(n string, v int, ch *chart.Chart) *release.Release {
+ return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch})
+ }
+
+ return relMock, ch, chartPath
+}
+
+func TestUpgradeWithDryRun(t *testing.T) {
+ releaseName := "funny-bunny-labels"
+ _, _, chartPath := prepareMockReleaseWithSecret(t, releaseName)
+
+ defer resetEnv()()
+
+ store := storageFixture()
+
+ // First install a release into the store so that future --dry-run attempts
+ // have it available.
+ cmd := fmt.Sprintf("upgrade %s --install '%s'", releaseName, chartPath)
+ _, _, err := executeActionCommandC(store, cmd)
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+
+ _, err = store.Get(releaseName, 1)
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+
+ cmd = fmt.Sprintf("upgrade %s --dry-run '%s'", releaseName, chartPath)
+ _, out, err := executeActionCommandC(store, cmd)
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+
+ // No second release should be stored because this is a dry run.
+ _, err = store.Get(releaseName, 2)
+ if err == nil {
+ t.Error("expected error as there should be no new release but got none")
+ }
+
+ if !strings.Contains(out, "kind: Secret") {
+ t.Error("expected secret in output from --dry-run but found none")
+ }
+
+ // Ensure the secret is not in the output
+ cmd = fmt.Sprintf("upgrade %s --dry-run --hide-secret '%s'", releaseName, chartPath)
+ _, out, err = executeActionCommandC(store, cmd)
+ if err != nil {
+ t.Errorf("unexpected error, got '%v'", err)
+ }
+
+ // No second release should be stored because this is a dry run.
+ _, err = store.Get(releaseName, 2)
+ if err == nil {
+ t.Error("expected error as there should be no new release but got none")
+ }
+
+ if strings.Contains(out, "kind: Secret") {
+ t.Error("expected no secret in output from --dry-run --hide-secret but found one")
+ }
+
+ // Ensure there is an error when --hide-secret used without dry-run
+ cmd = fmt.Sprintf("upgrade %s --hide-secret '%s'", releaseName, chartPath)
+ _, _, err = executeActionCommandC(store, cmd)
+ if err == nil {
+ t.Error("expected error when --hide-secret used without --dry-run")
+ }
+}
diff --git a/cmd/helm/verify.go b/pkg/cmd/verify.go
similarity index 85%
rename from cmd/helm/verify.go
rename to pkg/cmd/verify.go
index d126c9ef3..50f1ea914 100644
--- a/cmd/helm/verify.go
+++ b/pkg/cmd/verify.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -21,8 +21,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v4/pkg/action"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const verifyDesc = `
@@ -44,15 +44,15 @@ func newVerifyCmd(out io.Writer) *cobra.Command {
Short: "verify that a chart at the given path has been signed and is valid",
Long: verifyDesc,
Args: require.ExactArgs(1),
- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
// Allow file completion when completing the argument for the path
return nil, cobra.ShellCompDirectiveDefault
}
// No more completions, so disable file completion
- return nil, cobra.ShellCompDirectiveNoFileComp
+ return noMoreArgsComp()
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, args []string) error {
err := client.Run(args[0])
if err != nil {
return err
diff --git a/cmd/helm/verify_test.go b/pkg/cmd/verify_test.go
similarity index 99%
rename from cmd/helm/verify_test.go
rename to pkg/cmd/verify_test.go
index 23b793557..ae373afd2 100644
--- a/cmd/helm/verify_test.go
+++ b/pkg/cmd/verify_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
diff --git a/cmd/helm/version.go b/pkg/cmd/version.go
similarity index 93%
rename from cmd/helm/version.go
rename to pkg/cmd/version.go
index d62778f7b..0211716fe 100644
--- a/cmd/helm/version.go
+++ b/pkg/cmd/version.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"fmt"
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "helm.sh/helm/v3/cmd/helm/require"
- "helm.sh/helm/v3/internal/version"
+ "helm.sh/helm/v4/internal/version"
+ "helm.sh/helm/v4/pkg/cmd/require"
)
const versionDesc = `
@@ -65,8 +65,8 @@ func newVersionCmd(out io.Writer) *cobra.Command {
Short: "print the client version information",
Long: versionDesc,
Args: require.NoArgs,
- ValidArgsFunction: noCompletions,
- RunE: func(cmd *cobra.Command, args []string) error {
+ ValidArgsFunction: noMoreArgsCompFunc,
+ RunE: func(_ *cobra.Command, _ []string) error {
return o.run(out)
},
}
diff --git a/cmd/helm/version_test.go b/pkg/cmd/version_test.go
similarity index 98%
rename from cmd/helm/version_test.go
rename to pkg/cmd/version_test.go
index aa3cbfb7d..c06c72309 100644
--- a/cmd/helm/version_test.go
+++ b/pkg/cmd/version_test.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package cmd
import (
"testing"
diff --git a/pkg/downloader/cache.go b/pkg/downloader/cache.go
new file mode 100644
index 000000000..cecfc8bd7
--- /dev/null
+++ b/pkg/downloader/cache.go
@@ -0,0 +1,89 @@
+/*
+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 downloader
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+
+ "helm.sh/helm/v4/internal/fileutil"
+)
+
+// Cache describes a cache that can get and put chart data.
+// The cache key is the sha256 has of the content. sha256 is used in Helm for
+// digests in index files providing a common key for checking content.
+type Cache interface {
+ // Get returns a reader for the given key.
+ Get(key [sha256.Size]byte, cacheType string) (string, error)
+ // Put stores the given reader for the given key.
+ Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error)
+}
+
+// CacheChart specifies the content is a chart
+var CacheChart = ".chart"
+
+// CacheProv specifies the content is a provenance file
+var CacheProv = ".prov"
+
+// TODO: The cache assumes files because much of Helm assumes files. Convert
+// Helm to pass content around instead of file locations.
+
+// DiskCache is a cache that stores data on disk.
+type DiskCache struct {
+ Root string
+}
+
+// Get returns a reader for the given key.
+func (c *DiskCache) Get(key [sha256.Size]byte, cacheType string) (string, error) {
+ p := c.fileName(key, cacheType)
+ fi, err := os.Stat(p)
+ 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")
+ }
+ return p, nil
+}
+
+// Put stores the given reader for the given key.
+// It returns the path to the stored file.
+func (c *DiskCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) {
+ // TODO: verify the key and digest of the key are the same.
+ p := c.fileName(key, cacheType)
+ if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+ slog.Error("failed to create cache directory")
+ return p, err
+ }
+ return p, fileutil.AtomicWriteFile(p, data, 0644)
+}
+
+// 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)
+}
diff --git a/pkg/downloader/cache_test.go b/pkg/downloader/cache_test.go
new file mode 100644
index 000000000..340c77aba
--- /dev/null
+++ b/pkg/downloader/cache_test.go
@@ -0,0 +1,122 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package downloader
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// compiler check to ensure DiskCache implements the Cache interface.
+var _ Cache = (*DiskCache)(nil)
+
+func TestDiskCache_PutAndGet(t *testing.T) {
+ // Setup a temporary directory for the cache
+ tmpDir := t.TempDir()
+ cache := &DiskCache{Root: tmpDir}
+
+ // Test data
+ content := []byte("hello world")
+ key := sha256.Sum256(content)
+
+ // --- Test case 1: Put and Get a regular file (prov=false) ---
+ t.Run("PutAndGetTgz", func(t *testing.T) {
+ // Put the data into the cache
+ path, err := cache.Put(key, bytes.NewReader(content), CacheChart)
+ require.NoError(t, err, "Put should not return an error")
+
+ // Verify the file exists at the returned path
+ _, err = os.Stat(path)
+ require.NoError(t, err, "File should exist after Put")
+
+ // Get the file from the cache
+ retrievedPath, err := cache.Get(key, CacheChart)
+ require.NoError(t, err, "Get should not return an error for existing file")
+ assert.Equal(t, path, retrievedPath, "Get should return the same path as Put")
+
+ // Verify content
+ data, err := os.ReadFile(retrievedPath)
+ require.NoError(t, err)
+ assert.Equal(t, content, data, "Content of retrieved file should match original content")
+ })
+
+ // --- Test case 2: Put and Get a provenance file (prov=true) ---
+ t.Run("PutAndGetProv", func(t *testing.T) {
+ provContent := []byte("provenance data")
+ provKey := sha256.Sum256(provContent)
+
+ path, err := cache.Put(provKey, bytes.NewReader(provContent), CacheProv)
+ require.NoError(t, err)
+
+ retrievedPath, err := cache.Get(provKey, CacheProv)
+ require.NoError(t, err)
+ assert.Equal(t, path, retrievedPath)
+
+ data, err := os.ReadFile(retrievedPath)
+ require.NoError(t, err)
+ assert.Equal(t, provContent, data)
+ })
+
+ // --- Test case 3: Get a non-existent file ---
+ t.Run("GetNonExistent", func(t *testing.T) {
+ nonExistentKey := sha256.Sum256([]byte("does not exist"))
+ _, err := cache.Get(nonExistentKey, CacheChart)
+ assert.ErrorIs(t, err, os.ErrNotExist, "Get for a non-existent key should return os.ErrNotExist")
+ })
+
+ // --- Test case 4: Put an empty file ---
+ t.Run("PutEmptyFile", func(t *testing.T) {
+ emptyContent := []byte{}
+ emptyKey := sha256.Sum256(emptyContent)
+
+ path, err := cache.Put(emptyKey, bytes.NewReader(emptyContent), CacheChart)
+ require.NoError(t, err)
+
+ // Get should return ErrNotExist for empty files
+ _, err = cache.Get(emptyKey, CacheChart)
+ assert.ErrorIs(t, err, os.ErrNotExist, "Get for an empty file should return os.ErrNotExist")
+
+ // But the file should exist
+ _, err = os.Stat(path)
+ require.NoError(t, err, "Empty file should still exist on disk")
+ })
+
+ // --- Test case 5: Get a directory ---
+ t.Run("GetDirectory", func(t *testing.T) {
+ dirKey := sha256.Sum256([]byte("i am a directory"))
+ dirPath := cache.fileName(dirKey, CacheChart)
+ err := os.MkdirAll(dirPath, 0755)
+ require.NoError(t, err)
+
+ _, err = cache.Get(dirKey, CacheChart)
+ assert.EqualError(t, err, "is a directory")
+ })
+}
+
+func TestDiskCache_fileName(t *testing.T) {
+ cache := &DiskCache{Root: "/tmp/cache"}
+ key := sha256.Sum256([]byte("some data"))
+
+ assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.chart"), cache.fileName(key, CacheChart))
+ assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.prov"), cache.fileName(key, CacheProv))
+}
diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go
index a95894e00..693e6b009 100644
--- a/pkg/downloader/chart_downloader.go
+++ b/pkg/downloader/chart_downloader.go
@@ -16,23 +16,27 @@ limitations under the License.
package downloader
import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
"fmt"
"io"
+ "io/fs"
+ "log/slog"
"net/url"
"os"
"path/filepath"
"strings"
- "github.com/Masterminds/semver/v3"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/fileutil"
- "helm.sh/helm/v3/internal/urlutil"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/provenance"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/internal/fileutil"
+ ifs "helm.sh/helm/v4/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/internal/urlutil"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/provenance"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
)
// VerificationStrategy describes a strategy for determining whether to verify a chart.
@@ -73,6 +77,14 @@ type ChartDownloader struct {
RegistryClient *registry.Client
RepositoryConfig string
RepositoryCache string
+
+ // ContentCache is the location where Cache stores its files by default
+ // In previous versions of Helm the charts were put in the RepositoryCache. The
+ // repositories and charts are stored in 2 difference caches.
+ ContentCache string
+
+ // Cache specifies the cache implementation to use.
+ Cache Cache
}
// DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file.
@@ -87,7 +99,14 @@ type ChartDownloader struct {
// Returns a string path to the location where the file was downloaded and a verification
// (if provenance was verified), or an error if something bad happened.
func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) {
- u, err := c.ResolveChartVersion(ref, version)
+ if c.Cache == nil {
+ if c.ContentCache == "" {
+ return "", nil, errors.New("content cache must be set")
+ }
+ c.Cache = &DiskCache{Root: c.ContentCache}
+ slog.Debug("setup up default downloader cache")
+ }
+ hash, u, err := c.ResolveChartVersion(ref, version)
if err != nil {
return "", nil, err
}
@@ -97,9 +116,37 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
return "", nil, err
}
- data, err := g.Get(u.String(), c.Options...)
- if err != nil {
- return "", nil, err
+ // Check the cache for the content. Otherwise download it.
+ // Note, this process will pull from the cache but does not automatically populate
+ // the cache with the file it downloads.
+ var data *bytes.Buffer
+ var found bool
+ var digest []byte
+ var digest32 [32]byte
+ if hash != "" {
+ // if there is a hash, populate the other formats
+ digest, err = hex.DecodeString(hash)
+ if err != nil {
+ return "", nil, err
+ }
+ copy(digest32[:], digest)
+ if pth, err := c.Cache.Get(digest32, CacheChart); err == nil {
+ fdata, err := os.ReadFile(pth)
+ if err == nil {
+ found = true
+ data = bytes.NewBuffer(fdata)
+ slog.Debug("found chart in cache", "id", hash)
+ }
+ }
+ }
+
+ if !found {
+ c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream"))
+
+ data, err = g.Get(u.String(), c.Options...)
+ if err != nil {
+ return "", nil, err
+ }
}
name := filepath.Base(u.Path)
@@ -116,13 +163,27 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
// If provenance is requested, verify it.
ver := &provenance.Verification{}
if c.Verify > VerifyNever {
- body, err := g.Get(u.String() + ".prov")
- if err != nil {
- if c.Verify == VerifyAlways {
- return destfile, ver, errors.Errorf("failed to fetch provenance %q", u.String()+".prov")
+ found = false
+ var body *bytes.Buffer
+ if hash != "" {
+ if pth, err := c.Cache.Get(digest32, CacheProv); err == nil {
+ fdata, err := os.ReadFile(pth)
+ if err == nil {
+ found = true
+ body = bytes.NewBuffer(fdata)
+ slog.Debug("found provenance in cache", "id", hash)
+ }
+ }
+ }
+ if !found {
+ body, err = g.Get(u.String() + ".prov")
+ if err != nil {
+ if c.Verify == VerifyAlways {
+ return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
+ }
+ fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
+ return destfile, ver, nil
}
- fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
- return destfile, ver, nil
}
provfile := destfile + ".prov"
if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil {
@@ -130,7 +191,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
}
if c.Verify != VerifyLater {
- ver, err = VerifyChart(destfile, c.Keyring)
+ ver, err = VerifyChart(destfile, destfile+".prov", c.Keyring)
if err != nil {
// Fail always in this case, since it means the verification step
// failed.
@@ -141,43 +202,143 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
return destfile, ver, nil
}
-func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL, error) {
- var tag string
- var err error
-
- // Evaluate whether an explicit version has been provided. Otherwise, determine version to use
- _, errSemVer := semver.NewVersion(version)
- if errSemVer == nil {
- tag = version
- } else {
- // Retrieve list of repository tags
- tags, err := c.RegistryClient.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)))
- if err != nil {
- return nil, err
+// DownloadToCache retrieves resources while using a content based cache.
+func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) {
+ if c.Cache == nil {
+ if c.ContentCache == "" {
+ return "", nil, errors.New("content cache must be set")
}
- if len(tags) == 0 {
- return nil, errors.Errorf("Unable to locate any tags in provided repository: %s", ref)
+ c.Cache = &DiskCache{Root: c.ContentCache}
+ slog.Debug("setup up default downloader cache")
+ }
+
+ digestString, u, err := c.ResolveChartVersion(ref, version)
+ if err != nil {
+ return "", nil, err
+ }
+
+ g, err := c.Getters.ByScheme(u.Scheme)
+ if err != nil {
+ return "", nil, err
+ }
+
+ c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream"))
+
+ // Check the cache for the file
+ digest, err := hex.DecodeString(digestString)
+ if err != nil {
+ return "", nil, err
+ }
+ var digest32 [32]byte
+ copy(digest32[:], digest)
+ if err != nil {
+ return "", nil, fmt.Errorf("unable to decode digest: %w", err)
+ }
+
+ var pth string
+ // only fetch from the cache if we have a digest
+ if len(digest) > 0 {
+ pth, err = c.Cache.Get(digest32, CacheChart)
+ if err == nil {
+ slog.Debug("found chart in cache", "id", digestString)
+ }
+ }
+ if len(digest) == 0 || err != nil {
+ slog.Debug("attempting to download chart", "ref", ref, "version", version)
+ if err != nil && !os.IsNotExist(err) {
+ return "", nil, err
+ }
+
+ // Get file not in the cache
+ data, gerr := g.Get(u.String(), c.Options...)
+ if gerr != nil {
+ return "", nil, gerr
}
- // Determine if version provided
- // If empty, try to get the highest available tag
- // If exact version, try to find it
- // If semver constraint string, try to find a match
- tag, err = registry.GetTagMatchingVersionOrConstraint(tags, version)
+ // Generate the digest
+ if len(digest) == 0 {
+ digest32 = sha256.Sum256(data.Bytes())
+ }
+
+ pth, err = c.Cache.Put(digest32, data, CacheChart)
if err != nil {
- return nil, err
+ return "", nil, err
}
+ slog.Debug("put downloaded chart in cache", "id", hex.EncodeToString(digest32[:]))
}
- u.Path = fmt.Sprintf("%s:%s", u.Path, tag)
+ // 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)
+ } else {
+ if !os.IsNotExist(err) {
+ return pth, ver, err
+ }
+
+ body, err := g.Get(u.String() + ".prov")
+ if err != nil {
+ if c.Verify == VerifyAlways {
+ return pth, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
+ }
+ fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
+ return pth, ver, nil
+ }
+
+ ppth, err = c.Cache.Put(digest32, body, CacheProv)
+ if err != nil {
+ return "", nil, err
+ }
+ slog.Debug("put downloaded provenance file in cache", "id", hex.EncodeToString(digest32[:]))
+ }
+
+ 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
+ // hash when a provenance file is used. If this isn't true, this section of code
+ // will need to be reworked.
+ name := filepath.Base(u.Path)
+ if u.Scheme == registry.OCIScheme {
+ idx := strings.LastIndexByte(name, ':')
+ name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
+ }
+
+ // Copy chart to a known location with the right name for verification and then
+ // clean it up.
+ tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp"))
+ if err := os.MkdirAll(tmpdir, 0755); err != nil {
+ return pth, ver, err
+ }
+ tmpfile := filepath.Join(tmpdir, name)
+ err = ifs.CopyFile(pth, tmpfile)
+ if err != nil {
+ return pth, ver, err
+ }
+ // Not removing the tmp dir itself because a concurrent process may be using it
+ defer os.RemoveAll(tmpfile)
- return u, err
+ ver, err = VerifyChart(tmpfile, ppth, c.Keyring)
+ if err != nil {
+ // Fail always in this case, since it means the verification step
+ // failed.
+ return pth, ver, err
+ }
+ }
+ }
+ return pth, ver, nil
}
// ResolveChartVersion resolves a chart reference to a URL.
//
-// It returns the URL and sets the ChartDownloader's Options that can fetch
-// the URL using the appropriate Getter.
+// It returns:
+// - A hash of the content if available
+// - The URL and sets the ChartDownloader's Options that can fetch the URL using the appropriate Getter.
+// - An error if there is one
//
// A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname'
// reference, or a local path.
@@ -189,19 +350,26 @@ func (c *ChartDownloader) getOciURI(ref, version string, u *url.URL) (*url.URL,
// - If version is non-empty, this will return the URL for that version
// - If version is empty, this will return the URL for the latest version
// - If no version can be found, an error is returned
-func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) {
+//
+// TODO: support OCI hash
+func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) {
u, err := url.Parse(ref)
if err != nil {
- return nil, errors.Errorf("invalid chart URL format: %s", ref)
+ return "", nil, fmt.Errorf("invalid chart URL format: %s", ref)
}
if registry.IsOCI(u.String()) {
- return c.getOciURI(ref, version, u)
+ if c.RegistryClient == nil {
+ return "", nil, fmt.Errorf("unable to lookup ref %s at version '%s', missing registry client", ref, version)
+ }
+
+ digest, OCIref, err := c.RegistryClient.ValidateReference(ref, version, u)
+ return digest, OCIref, err
}
rf, err := loadRepoConfig(c.RepositoryConfig)
if err != nil {
- return u, err
+ return "", u, err
}
if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 {
@@ -218,9 +386,9 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er
if err == ErrNoOwnerRepo {
// Make sure to add the ref URL as the URL for the getter
c.Options = append(c.Options, getter.WithURL(ref))
- return u, nil
+ return "", u, nil
}
- return u, err
+ return "", u, err
}
// If we get here, we don't need to go through the next phase of looking
@@ -239,21 +407,20 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er
getter.WithPassCredentialsAll(rc.PassCredentialsAll),
)
}
- return u, nil
+ return "", u, nil
}
// See if it's of the form: repo/path_to_chart
p := strings.SplitN(u.Path, "/", 2)
if len(p) < 2 {
- return u, errors.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u)
+ return "", u, fmt.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u)
}
repoName := p[0]
chartName := p[1]
rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories)
-
if err != nil {
- return u, err
+ return "", u, err
}
// Now that we have the chart repository information we can use that URL
@@ -262,7 +429,7 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er
r, err := repo.NewChartRepository(rc, c.Getters)
if err != nil {
- return u, err
+ return "", u, err
}
if r != nil && r.Config != nil {
@@ -281,33 +448,33 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er
idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name))
i, err := repo.LoadIndexFile(idxFile)
if err != nil {
- return u, errors.Wrap(err, "no cached repo found. (try 'helm repo update')")
+ return "", u, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err)
}
cv, err := i.Get(chartName, version)
if err != nil {
- return u, errors.Wrapf(err, "chart %q matching %s not found in %s index. (try 'helm repo update')", chartName, version, r.Config.Name)
+ return "", u, fmt.Errorf("chart %q matching %s not found in %s index. (try 'helm repo update'): %w", chartName, version, r.Config.Name, err)
}
if len(cv.URLs) == 0 {
- return u, errors.Errorf("chart %q has no downloadable URLs", ref)
+ return "", u, fmt.Errorf("chart %q has no downloadable URLs", ref)
}
// TODO: Seems that picking first URL is not fully correct
resolvedURL, err := repo.ResolveReferenceURL(rc.URL, cv.URLs[0])
-
if err != nil {
- return u, errors.Errorf("invalid chart URL format: %s", ref)
+ return cv.Digest, u, fmt.Errorf("invalid chart URL format: %s", ref)
}
- return url.Parse(resolvedURL)
+ loc, err := url.Parse(resolvedURL)
+ return cv.Digest, loc, err
}
// VerifyChart takes a path to a chart archive and a keyring, and verifies the chart.
//
// It assumes that a chart archive file is accompanied by a provenance file whose
// name is the archive file name plus the ".prov" extension.
-func VerifyChart(path, keyring string) (*provenance.Verification, error) {
+func VerifyChart(path, provfile, keyring string) (*provenance.Verification, error) {
// For now, error out if it's not a tar file.
switch fi, err := os.Stat(path); {
case err != nil:
@@ -318,14 +485,13 @@ func VerifyChart(path, keyring string) (*provenance.Verification, error) {
return nil, errors.New("chart must be a tgz file")
}
- provfile := path + ".prov"
if _, err := os.Stat(provfile); err != nil {
- return nil, errors.Wrapf(err, "could not load provenance file %s", provfile)
+ return nil, fmt.Errorf("could not load provenance file %s: %w", provfile, err)
}
sig, err := provenance.NewFromKeyring(keyring, "")
if err != nil {
- return nil, errors.Wrap(err, "failed to load keyring")
+ return nil, fmt.Errorf("failed to load keyring: %w", err)
}
return sig.Verify(path, provfile)
}
@@ -342,12 +508,12 @@ func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Ent
for _, rc := range cfgs {
if rc.Name == name {
if rc.URL == "" {
- return nil, errors.Errorf("no URL found for repository %s", name)
+ return nil, fmt.Errorf("no URL found for repository %s", name)
}
return rc, nil
}
}
- return nil, errors.Errorf("repo %s not found", name)
+ return nil, fmt.Errorf("repo %s not found", name)
}
// scanReposForURL scans all repos to find which repo contains the given URL.
@@ -380,7 +546,7 @@ func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry,
idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name))
i, err := repo.LoadIndexFile(idxFile)
if err != nil {
- return nil, errors.Wrap(err, "no cached repo found. (try 'helm repo update')")
+ return nil, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err)
}
for _, entry := range i.Entries {
@@ -399,7 +565,7 @@ func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry,
func loadRepoConfig(file string) (*repo.File, error) {
r, err := repo.LoadFile(file)
- if err != nil && !os.IsNotExist(errors.Cause(err)) {
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
return r, nil
diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go
index 131e21306..649448fef 100644
--- a/pkg/downloader/chart_downloader_test.go
+++ b/pkg/downloader/chart_downloader_test.go
@@ -16,15 +16,20 @@ limitations under the License.
package downloader
import (
+ "crypto/sha256"
+ "encoding/hex"
"os"
"path/filepath"
"testing"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/repo"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "github.com/stretchr/testify/require"
+
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
const (
@@ -46,6 +51,7 @@ func TestResolveChartRef(t *testing.T) {
{name: "reference, querystring repo", ref: "testing-querystring/alpine", expect: "http://example.com/alpine-1.2.3.tgz?key=value"},
{name: "reference, testing-relative repo", ref: "testing-relative/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"},
{name: "reference, testing-relative repo", ref: "testing-relative/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"},
+ {name: "reference, testing-relative repo", ref: "testing-relative/baz", expect: "http://example.com/path/to/baz-1.2.3.tgz"},
{name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/foo", expect: "http://example.com/helm/charts/foo-1.2.3.tgz"},
{name: "reference, testing-relative-trailing-slash repo", ref: "testing-relative-trailing-slash/bar", expect: "http://example.com/helm/bar-1.2.3.tgz"},
{name: "encoded URL", ref: "encoded-url/foobar", expect: "http://example.com/with%2Fslash/charts/foobar-4.2.1.tgz"},
@@ -53,12 +59,23 @@ func TestResolveChartRef(t *testing.T) {
{name: "full URL, file", ref: "file:///foo-1.2.3.tgz", fail: true},
{name: "invalid", ref: "invalid-1.2.3", fail: true},
{name: "not found", ref: "nosuchthing/invalid-1.2.3", fail: true},
+ {name: "ref with tag", ref: "oci://example.com/helm-charts/nginx:15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"},
+ {name: "no repository", ref: "oci://", fail: true},
+ {name: "oci ref", ref: "oci://example.com/helm-charts/nginx", version: "15.4.2", expect: "oci://example.com/helm-charts/nginx:15.4.2"},
+ {name: "oci ref with sha256 and version mismatch", ref: "oci://example.com/install/by/sha:0.1.1@sha256:d234555386402a5867ef0169fefe5486858b6d8d209eaf32fd26d29b16807fd6", version: "0.1.2", fail: true},
+ }
+
+ // Create a mock registry client for OCI references
+ registryClient, err := registry.NewClient()
+ if err != nil {
+ t.Fatal(err)
}
c := ChartDownloader{
Out: os.Stderr,
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ RegistryClient: registryClient,
Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
@@ -66,7 +83,7 @@ func TestResolveChartRef(t *testing.T) {
}
for _, tt := range tests {
- u, err := c.ResolveChartVersion(tt.ref, tt.version)
+ _, u, err := c.ResolveChartVersion(tt.ref, tt.version)
if err != nil {
if tt.fail {
continue
@@ -118,7 +135,7 @@ func TestResolveChartOpts(t *testing.T) {
continue
}
- u, err := c.ResolveChartVersion(tt.ref, tt.version)
+ _, u, err := c.ResolveChartVersion(tt.ref, tt.version)
if err != nil {
t.Errorf("%s: failed with error %s", tt.name, err)
continue
@@ -142,7 +159,7 @@ func TestResolveChartOpts(t *testing.T) {
}
func TestVerifyChart(t *testing.T) {
- v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/helm-test-key.pub")
+ v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/signtest-0.1.0.tgz.prov", "testdata/helm-test-key.pub")
if err != nil {
t.Fatal(err)
}
@@ -171,7 +188,11 @@ func TestIsTar(t *testing.T) {
}
func TestDownloadTo(t *testing.T) {
- srv := repotest.NewTempServerWithCleanupAndBasicAuth(t, "testdata/*.tgz*")
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ repotest.WithMiddleware(repotest.BasicAuthMiddleware(t)),
+ )
defer srv.Stop()
if err := srv.CreateIndex(); err != nil {
t.Fatal(err)
@@ -181,15 +202,19 @@ func TestDownloadTo(t *testing.T) {
t.Fatal(err)
}
+ contentCache := t.TempDir()
+
c := ChartDownloader{
Out: os.Stderr,
Verify: VerifyAlways,
Keyring: "testdata/helm-test-key.pub",
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
}),
Options: []getter.Option{
getter.WithBasicAuth("username", "password"),
@@ -218,12 +243,11 @@ func TestDownloadTo(t *testing.T) {
func TestDownloadTo_TLS(t *testing.T) {
// Set up mock server w/ tls enabled
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*")
- srv.Stop()
- if err != nil {
- t.Fatal(err)
- }
- srv.StartTLS()
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ repotest.WithTLSConfig(repotest.MakeTestTLSConfig(t, "../../testdata")),
+ )
defer srv.Stop()
if err := srv.CreateIndex(); err != nil {
t.Fatal(err)
@@ -234,6 +258,7 @@ func TestDownloadTo_TLS(t *testing.T) {
repoConfig := filepath.Join(srv.Root(), "repositories.yaml")
repoCache := srv.Root()
+ contentCache := t.TempDir()
c := ChartDownloader{
Out: os.Stderr,
@@ -241,11 +266,19 @@ func TestDownloadTo_TLS(t *testing.T) {
Keyring: "testdata/helm-test-key.pub",
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
}),
- Options: []getter.Option{},
+ Options: []getter.Option{
+ getter.WithTLSClientConfig(
+ "",
+ "",
+ filepath.Join("../../testdata/rootca.crt"),
+ ),
+ },
}
cname := "test/signtest"
dest := srv.Root()
@@ -274,23 +307,26 @@ func TestDownloadTo_VerifyLater(t *testing.T) {
dest := t.TempDir()
// Set up a fake repo
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ )
defer srv.Stop()
if err := srv.LinkIndices(); err != nil {
t.Fatal(err)
}
+ contentCache := t.TempDir()
c := ChartDownloader{
Out: os.Stderr,
Verify: VerifyLater,
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
+ ContentCache: contentCache,
}),
}
cname := "/signtest-0.1.0.tgz"
@@ -344,3 +380,108 @@ func TestScanReposForURL(t *testing.T) {
t.Fatalf("expected ErrNoOwnerRepo, got %v", err)
}
}
+
+func TestDownloadToCache(t *testing.T) {
+ srv := repotest.NewTempServer(t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ )
+ defer srv.Stop()
+ if err := srv.CreateIndex(); err != nil {
+ t.Fatal(err)
+ }
+ if err := srv.LinkIndices(); err != nil {
+ t.Fatal(err)
+ }
+
+ // The repo file needs to point to our server.
+ repoFile := filepath.Join(srv.Root(), "repositories.yaml")
+ repoCache := srv.Root()
+ contentCache := t.TempDir()
+
+ c := ChartDownloader{
+ Out: os.Stderr,
+ Verify: VerifyNever,
+ RepositoryConfig: repoFile,
+ RepositoryCache: repoCache,
+ Getters: getter.All(&cli.EnvSettings{
+ RepositoryConfig: repoFile,
+ RepositoryCache: repoCache,
+ ContentCache: contentCache,
+ }),
+ Cache: &DiskCache{Root: contentCache},
+ }
+
+ // Case 1: Chart not in cache, download it.
+ t.Run("download and cache chart", func(t *testing.T) {
+ // Clear cache for this test
+ os.RemoveAll(contentCache)
+ os.MkdirAll(contentCache, 0755)
+ c.Cache = &DiskCache{Root: contentCache}
+
+ pth, v, err := c.DownloadToCache("test/signtest", "0.1.0")
+ require.NoError(t, err)
+ require.NotNil(t, v)
+
+ // Check that the file exists at the returned path
+ _, err = os.Stat(pth)
+ require.NoError(t, err, "chart should exist at returned path")
+
+ // Check that it's in the cache
+ digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0")
+ require.NoError(t, err)
+ digestBytes, err := hex.DecodeString(digest)
+ require.NoError(t, err)
+ var digestArray [sha256.Size]byte
+ copy(digestArray[:], digestBytes)
+
+ cachePath, err := c.Cache.Get(digestArray, CacheChart)
+ require.NoError(t, err, "chart should now be in cache")
+ require.Equal(t, pth, cachePath)
+ })
+
+ // Case 2: Chart is in cache, get from cache.
+ t.Run("get chart from cache", func(t *testing.T) {
+ // The cache should be populated from the previous test.
+ // To prove it's coming from cache, we can stop the server.
+ // But repotest doesn't support restarting.
+ // Let's just call it again and assume it works if it's fast and doesn't error.
+ pth, v, err := c.DownloadToCache("test/signtest", "0.1.0")
+ require.NoError(t, err)
+ require.NotNil(t, v)
+
+ _, err = os.Stat(pth)
+ require.NoError(t, err, "chart should exist at returned path")
+ })
+
+ // Case 3: Download with verification
+ t.Run("download and verify", func(t *testing.T) {
+ // Clear cache
+ os.RemoveAll(contentCache)
+ os.MkdirAll(contentCache, 0755)
+ c.Cache = &DiskCache{Root: contentCache}
+ c.Verify = VerifyAlways
+ c.Keyring = "testdata/helm-test-key.pub"
+
+ _, v, err := c.DownloadToCache("test/signtest", "0.1.0")
+ require.NoError(t, err)
+ require.NotNil(t, v)
+ require.NotEmpty(t, v.FileHash, "verification should have a file hash")
+
+ // Check that both chart and prov are in cache
+ digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0")
+ require.NoError(t, err)
+ digestBytes, err := hex.DecodeString(digest)
+ require.NoError(t, err)
+ var digestArray [sha256.Size]byte
+ copy(digestArray[:], digestBytes)
+
+ _, err = c.Cache.Get(digestArray, CacheChart)
+ require.NoError(t, err, "chart should be in cache")
+ _, err = c.Cache.Get(digestArray, CacheProv)
+ require.NoError(t, err, "provenance file should be in cache")
+
+ // Reset for other tests
+ c.Verify = VerifyNever
+ c.Keyring = ""
+ })
+}
diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go
index a5b0af080..8b77a77c0 100644
--- a/pkg/downloader/manager.go
+++ b/pkg/downloader/manager.go
@@ -18,31 +18,31 @@ package downloader
import (
"crypto"
"encoding/hex"
+ "errors"
"fmt"
"io"
+ stdfs "io/fs"
"log"
"net/url"
"os"
- "path"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/Masterminds/semver/v3"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/internal/resolver"
- "helm.sh/helm/v3/internal/third_party/dep/fs"
- "helm.sh/helm/v3/internal/urlutil"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/internal/resolver"
+ "helm.sh/helm/v4/internal/third_party/dep/fs"
+ "helm.sh/helm/v4/internal/urlutil"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
)
// ErrRepoNotFound indicates that chart repositories can't be found in local repo cache.
@@ -75,6 +75,9 @@ type Manager struct {
RegistryClient *registry.Client
RepositoryConfig string
RepositoryCache string
+
+ // ContentCache is a location where a cache of charts can be stored
+ ContentCache string
}
// Build rebuilds a local charts directory from a lockfile.
@@ -173,7 +176,7 @@ func (m *Manager) Update() error {
// has some information about them and, when possible, the index files
// locally.
// TODO(mattfarina): Repositories should be explicitly added by end users
- // rather than automattic. In Helm v4 require users to add repositories. They
+ // rather than automatic. In Helm v4 require users to add repositories. They
// should have to add them in order to make sure they are aware of the
// repositories and opt-in to any locations, for security.
repoNames, err = m.ensureMissingRepos(repoNames, req)
@@ -220,7 +223,7 @@ func (m *Manager) Update() error {
func (m *Manager) loadChartDir() (*chart.Chart, error) {
if fi, err := os.Stat(m.ChartPath); err != nil {
- return nil, errors.Wrapf(err, "could not find %s", m.ChartPath)
+ return nil, fmt.Errorf("could not find %s: %w", m.ChartPath, err)
} else if !fi.IsDir() {
return nil, errors.New("only unpacked charts can be updated")
}
@@ -246,14 +249,14 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
}
destPath := filepath.Join(m.ChartPath, "charts")
- tmpPath := filepath.Join(m.ChartPath, "tmpcharts")
+ tmpPath := filepath.Join(m.ChartPath, fmt.Sprintf("tmpcharts-%d", os.Getpid()))
// Check if 'charts' directory is not actually a directory. If it does not exist, create it.
if fi, err := os.Stat(destPath); err == nil {
if !fi.IsDir() {
- return errors.Errorf("%q is not a directory", destPath)
+ return fmt.Errorf("%q is not a directory", destPath)
}
- } else if os.IsNotExist(err) {
+ } else if errors.Is(err, stdfs.ErrNotExist) {
if err := os.MkdirAll(destPath, 0755); err != nil {
return err
}
@@ -314,7 +317,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
// https://github.com/helm/helm/issues/1439
churl, username, password, insecureskiptlsverify, passcredentialsall, caFile, certFile, keyFile, err := m.findChartURL(dep.Name, dep.Version, dep.Repository, repos)
if err != nil {
- saveError = errors.Wrapf(err, "could not find %s", churl)
+ saveError = fmt.Errorf("could not find %s: %w", churl, err)
break
}
@@ -331,6 +334,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
Keyring: m.Keyring,
RepositoryConfig: m.RepositoryConfig,
RepositoryCache: m.RepositoryCache,
+ ContentCache: m.ContentCache,
RegistryClient: m.RegistryClient,
Getters: m.Getters,
Options: []getter.Option{
@@ -345,7 +349,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
if registry.IsOCI(churl) {
churl, version, err = parseOCIRef(churl)
if err != nil {
- return errors.Wrapf(err, "could not parse OCI reference")
+ return fmt.Errorf("could not parse OCI reference: %w", err)
}
dl.Options = append(dl.Options,
getter.WithRegistryClient(m.RegistryClient),
@@ -353,7 +357,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error {
}
if _, _, err = dl.DownloadTo(churl, version, tmpPath); err != nil {
- saveError = errors.Wrapf(err, "could not download %s", churl)
+ saveError = fmt.Errorf("could not download %s: %w", churl, err)
break
}
@@ -377,7 +381,7 @@ func parseOCIRef(chartRef string) (string, string, error) {
refTagRegexp := regexp.MustCompile(`^(oci://[^:]+(:[0-9]{1,5})?[^:]+):(.*)$`)
caps := refTagRegexp.FindStringSubmatch(chartRef)
if len(caps) != 4 {
- return "", "", errors.Errorf("improperly formatted oci chart reference: %s", chartRef)
+ return "", "", fmt.Errorf("improperly formatted oci chart reference: %s", chartRef)
}
chartRef = caps[1]
tag := caps[3]
@@ -385,7 +389,7 @@ func parseOCIRef(chartRef string) (string, string, error) {
return chartRef, tag, nil
}
-// safeMoveDep moves all dependencies in the source and moves them into dest.
+// safeMoveDeps moves all dependencies in the source and moves them into dest.
//
// It does this by first matching the file name to an expected pattern, then loading
// the file to verify that it is a chart.
@@ -559,7 +563,7 @@ func (m *Manager) ensureMissingRepos(repoNames map[string]string, deps []*chart.
func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, error) {
rf, err := loadRepoConfig(m.RepositoryConfig)
if err != nil {
- if os.IsNotExist(err) {
+ if errors.Is(err, stdfs.ErrNotExist) {
return make(map[string]string), nil
}
return nil, err
@@ -659,10 +663,28 @@ func (m *Manager) UpdateRepositories() error {
return nil
}
+// Filter out duplicate repos by URL, including those with trailing slashes.
+func dedupeRepos(repos []*repo.Entry) []*repo.Entry {
+ seen := make(map[string]*repo.Entry)
+ for _, r := range repos {
+ // Normalize URL by removing trailing slashes.
+ seenURL := strings.TrimSuffix(r.URL, "/")
+ seen[seenURL] = r
+ }
+ var unique []*repo.Entry
+ for _, r := range seen {
+ unique = append(unique, r)
+ }
+ return unique
+}
+
func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) error {
var wg sync.WaitGroup
- for _, c := range repos {
+
+ localRepos := dedupeRepos(repos)
+
+ for _, c := range localRepos {
r, err := repo.NewChartRepository(c, m.Getters)
if err != nil {
return err
@@ -709,20 +731,25 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*
}
for _, cr := range repos {
-
if urlutil.Equal(repoURL, cr.Config.URL) {
var entry repo.ChartVersions
entry, err = findEntryByName(name, cr)
if err != nil {
+ // TODO: Where linting is skipped in this function we should
+ // refactor to remove naked returns while ensuring the same
+ // behavior
+ //nolint:nakedret
return
}
var ve *repo.ChartVersion
ve, err = findVersionedEntry(version, entry)
if err != nil {
+ //nolint:nakedret
return
}
- url, err = normalizeURL(repoURL, ve.URLs[0])
+ url, err = repo.ResolveReferenceURL(repoURL, ve.URLs[0])
if err != nil {
+ //nolint:nakedret
return
}
username = cr.Config.Username
@@ -732,14 +759,15 @@ func (m *Manager) findChartURL(name, version, repoURL string, repos map[string]*
caFile = cr.Config.CAFile
certFile = cr.Config.CertFile
keyFile = cr.Config.KeyFile
+ //nolint:nakedret
return
}
}
- url, err = repo.FindChartInRepoURL(repoURL, name, version, certFile, keyFile, caFile, m.Getters)
+ url, err = repo.FindChartInRepoURL(repoURL, name, m.Getters, repo.WithChartVersion(version), repo.WithClientTLS(certFile, keyFile, caFile))
if err == nil {
return url, username, password, false, false, "", "", "", err
}
- err = errors.Errorf("chart %s not found in %s: %s", name, repoURL, err)
+ err = fmt.Errorf("chart %s not found in %s: %w", name, repoURL, err)
return url, username, password, false, false, "", "", "", err
}
@@ -785,24 +813,6 @@ func versionEquals(v1, v2 string) bool {
return sv1.Equal(sv2)
}
-func normalizeURL(baseURL, urlOrPath string) (string, error) {
- u, err := url.Parse(urlOrPath)
- if err != nil {
- return urlOrPath, err
- }
- if u.IsAbs() {
- return u.String(), nil
- }
- u2, err := url.Parse(baseURL)
- if err != nil {
- return urlOrPath, errors.Wrap(err, "base URL failed to parse")
- }
-
- u2.RawPath = path.Join(u2.RawPath, urlOrPath)
- u2.Path = path.Join(u2.Path, urlOrPath)
- return u2.String(), nil
-}
-
// loadChartRepositories reads the repositories.yaml, and then builds a map of
// ChartRepositories.
//
@@ -813,7 +823,7 @@ func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, err
// Load repositories.yaml file
rf, err := loadRepoConfig(m.RepositoryConfig)
if err != nil {
- return indices, errors.Wrapf(err, "failed to load %s", m.RepositoryConfig)
+ return indices, fmt.Errorf("failed to load %s: %w", m.RepositoryConfig, err)
}
for _, re := range rf.Repositories {
@@ -845,13 +855,27 @@ func writeLock(chartpath string, lock *chart.Lock, legacyLockfile bool) error {
lockfileName = "requirements.lock"
}
dest := filepath.Join(chartpath, lockfileName)
+
+ info, err := os.Lstat(dest)
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("error getting info for %q: %w", dest, err)
+ } else if err == nil {
+ if info.Mode()&os.ModeSymlink != 0 {
+ link, err := os.Readlink(dest)
+ if err != nil {
+ return fmt.Errorf("error reading symlink for %q: %w", dest, err)
+ }
+ return fmt.Errorf("the %s file is a symlink to %q", lockfileName, link)
+ }
+ }
+
return os.WriteFile(dest, data, 0644)
}
// archive a dep chart from local directory and save it into destPath
func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, error) {
if !strings.HasPrefix(repo, "file://") {
- return "", errors.Errorf("wrong format: chart %s repository %s", name, repo)
+ return "", fmt.Errorf("wrong format: chart %s repository %s", name, repo)
}
origPath, err := resolver.GetLocalPath(repo, chartpath)
@@ -866,7 +890,7 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e
constraint, err := semver.NewConstraint(version)
if err != nil {
- return "", errors.Wrapf(err, "dependency %s has an invalid version/constraint format", name)
+ return "", fmt.Errorf("dependency %s has an invalid version/constraint format: %w", name, err)
}
v, err := semver.NewVersion(ch.Metadata.Version)
@@ -879,7 +903,7 @@ func tarFromLocalDir(chartpath, name, repo, version, destPath string) (string, e
return ch.Metadata.Version, err
}
- return "", errors.Errorf("can't get a valid version for dependency %s", name)
+ return "", fmt.Errorf("can't get a valid version for dependency %s", name)
}
// The prefix to use for cache keys created by the manager for repo names
diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go
index f7ab1a568..b7121a4ce 100644
--- a/pkg/downloader/manager_test.go
+++ b/pkg/downloader/manager_test.go
@@ -17,16 +17,23 @@ package downloader
import (
"bytes"
+ "errors"
+ "io/fs"
"os"
"path/filepath"
"reflect"
"testing"
+ "time"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/repo/repotest"
+ "github.com/stretchr/testify/assert"
+ "sigs.k8s.io/yaml"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/repo"
+ "helm.sh/helm/v4/pkg/repo/repotest"
)
func TestVersionEquals(t *testing.T) {
@@ -48,26 +55,6 @@ func TestVersionEquals(t *testing.T) {
}
}
-func TestNormalizeURL(t *testing.T) {
- tests := []struct {
- name, base, path, expect string
- }{
- {name: "basic URL", base: "https://example.com", path: "http://helm.sh/foo", expect: "http://helm.sh/foo"},
- {name: "relative path", base: "https://helm.sh/charts", path: "foo", expect: "https://helm.sh/charts/foo"},
- {name: "Encoded path", base: "https://helm.sh/a%2Fb/charts", path: "foo", expect: "https://helm.sh/a%2Fb/charts/foo"},
- }
-
- for _, tt := range tests {
- got, err := normalizeURL(tt.base, tt.path)
- if err != nil {
- t.Errorf("%s: error %s", tt.name, err)
- continue
- } else if got != tt.expect {
- t.Errorf("%s: expected %q, got %q", tt.name, tt.expect, got)
- }
- }
-}
-
func TestFindChartURL(t *testing.T) {
var b bytes.Buffer
m := &Manager{
@@ -129,6 +116,31 @@ func TestFindChartURL(t *testing.T) {
if passcredentialsall != false {
t.Errorf("Unexpected passcredentialsall %t", passcredentialsall)
}
+
+ name = "foo"
+ version = "1.2.3"
+ repoURL = "http://example.com/helm"
+
+ churl, username, password, insecureSkipTLSVerify, passcredentialsall, _, _, _, err = m.findChartURL(name, version, repoURL, repos)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if churl != "http://example.com/helm/charts/foo-1.2.3.tgz" {
+ t.Errorf("Unexpected URL %q", churl)
+ }
+ if username != "" {
+ t.Errorf("Unexpected username %q", username)
+ }
+ if password != "" {
+ t.Errorf("Unexpected password %q", password)
+ }
+ if passcredentialsall != false {
+ t.Errorf("Unexpected passcredentialsall %t", passcredentialsall)
+ }
+ if insecureSkipTLSVerify {
+ t.Errorf("Unexpected insecureSkipTLSVerify %t", insecureSkipTLSVerify)
+ }
}
func TestGetRepoNames(t *testing.T) {
@@ -259,17 +271,43 @@ func TestDownloadAll(t *testing.T) {
t.Error(err)
}
- if _, err := os.Stat(filepath.Join(chartPath, "charts", "signtest-0.1.0.tgz")); os.IsNotExist(err) {
+ if _, err := os.Stat(filepath.Join(chartPath, "charts", "signtest-0.1.0.tgz")); errors.Is(err, fs.ErrNotExist) {
t.Error(err)
}
-}
-func TestUpdateBeforeBuild(t *testing.T) {
- // Set up a fake repo
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*")
+ // A chart with a bad name like this cannot be loaded and saved. Handling in
+ // the loading and saving will return an error about the invalid name. In
+ // this case, the chart needs to be created directly.
+ badchartyaml := `apiVersion: v2
+description: A Helm chart for Kubernetes
+name: ../bad-local-subchart
+version: 0.1.0`
+ if err := os.MkdirAll(filepath.Join(chartPath, "testdata", "bad-local-subchart"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ err = os.WriteFile(filepath.Join(chartPath, "testdata", "bad-local-subchart", "Chart.yaml"), []byte(badchartyaml), 0644)
if err != nil {
t.Fatal(err)
}
+
+ badLocalDep := &chart.Dependency{
+ Name: "../bad-local-subchart",
+ Repository: "file://./testdata/bad-local-subchart",
+ Version: "0.1.0",
+ }
+
+ err = m.downloadAll([]*chart.Dependency{badLocalDep})
+ if err == nil {
+ t.Fatal("Expected error for bad dependency name")
+ }
+}
+
+func TestUpdateBeforeBuild(t *testing.T) {
+ // Set up a fake repo
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ )
defer srv.Stop()
if err := srv.LinkIndices(); err != nil {
t.Fatal(err)
@@ -321,13 +359,11 @@ func TestUpdateBeforeBuild(t *testing.T) {
}
// Update before Build. see issue: https://github.com/helm/helm/issues/7101
- err = m.Update()
- if err != nil {
+ if err := m.Update(); err != nil {
t.Fatal(err)
}
- err = m.Build()
- if err != nil {
+ if err := m.Build(); err != nil {
t.Fatal(err)
}
}
@@ -337,10 +373,10 @@ func TestUpdateBeforeBuild(t *testing.T) {
// to be fetched.
func TestUpdateWithNoRepo(t *testing.T) {
// Set up a fake repo
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ )
defer srv.Stop()
if err := srv.LinkIndices(); err != nil {
t.Fatal(err)
@@ -396,8 +432,7 @@ func TestUpdateWithNoRepo(t *testing.T) {
}
// Test the update
- err = m.Update()
- if err != nil {
+ if err := m.Update(); err != nil {
t.Fatal(err)
}
}
@@ -409,11 +444,12 @@ func TestUpdateWithNoRepo(t *testing.T) {
// Parent chart includes local-subchart 0.1.0 subchart from a fake repository, by default.
// If each of these main fields (name, version, repository) is not supplied by dep param, default value will be used.
func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Dependency) {
+ t.Helper()
// Set up a fake repo
- srv, err := repotest.NewTempServerWithCleanup(t, "testdata/*.tgz*")
- if err != nil {
- t.Fatal(err)
- }
+ srv := repotest.NewTempServer(
+ t,
+ repotest.WithChartSourceGlob("testdata/*.tgz*"),
+ )
defer srv.Stop()
if err := srv.LinkIndices(); err != nil {
t.Fatal(err)
@@ -452,23 +488,23 @@ func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Depe
Schemes: []string{"http", "https"},
New: getter.NewHTTPGetter,
}}
+ contentCache := t.TempDir()
m := &Manager{
ChartPath: dir(chartName),
Out: b,
Getters: g,
RepositoryConfig: dir("repositories.yaml"),
RepositoryCache: dir(),
+ ContentCache: contentCache,
}
// First build will update dependencies and create Chart.lock file.
- err = m.Build()
- if err != nil {
+ if err := m.Build(); err != nil {
t.Fatal(err)
}
// Second build should be passed. See PR #6655.
- err = m.Build()
- if err != nil {
+ if err := m.Build(); err != nil {
t.Fatal(err)
}
}
@@ -572,3 +608,162 @@ func TestKey(t *testing.T) {
}
}
}
+
+// Test dedupeRepos tests that the dedupeRepos function correctly deduplicates
+func TestDedupeRepos(t *testing.T) {
+ tests := []struct {
+ name string
+ repos []*repo.Entry
+ want []*repo.Entry
+ }{
+ {
+ name: "no duplicates",
+ repos: []*repo.Entry{
+ {
+ URL: "https://example.com/charts",
+ },
+ {
+ URL: "https://example.com/charts2",
+ },
+ },
+ want: []*repo.Entry{
+ {
+ URL: "https://example.com/charts",
+ },
+ {
+ URL: "https://example.com/charts2",
+ },
+ },
+ },
+ {
+ name: "duplicates",
+ repos: []*repo.Entry{
+ {
+ URL: "https://example.com/charts",
+ },
+ {
+ URL: "https://example.com/charts",
+ },
+ },
+ want: []*repo.Entry{
+ {
+ URL: "https://example.com/charts",
+ },
+ },
+ },
+ {
+ name: "duplicates with trailing slash",
+ repos: []*repo.Entry{
+ {
+ URL: "https://example.com/charts",
+ },
+ {
+ URL: "https://example.com/charts/",
+ },
+ },
+ want: []*repo.Entry{
+ {
+ // the last one wins
+ URL: "https://example.com/charts/",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := dedupeRepos(tt.repos)
+ assert.ElementsMatch(t, tt.want, got)
+ })
+ }
+}
+
+func TestWriteLock(t *testing.T) {
+ fixedTime, err := time.Parse(time.RFC3339, "2025-07-04T00:00:00Z")
+ assert.NoError(t, err)
+ lock := &chart.Lock{
+ Generated: fixedTime,
+ Digest: "sha256:12345",
+ Dependencies: []*chart.Dependency{
+ {
+ Name: "fantastic-chart",
+ Version: "1.2.3",
+ Repository: "https://example.com/charts",
+ },
+ },
+ }
+ expectedContent, err := yaml.Marshal(lock)
+ assert.NoError(t, err)
+
+ t.Run("v2 lock file", func(t *testing.T) {
+ dir := t.TempDir()
+ err := writeLock(dir, lock, false)
+ assert.NoError(t, err)
+
+ lockfilePath := filepath.Join(dir, "Chart.lock")
+ _, err = os.Stat(lockfilePath)
+ assert.NoError(t, err, "Chart.lock should exist")
+
+ content, err := os.ReadFile(lockfilePath)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedContent, content)
+
+ // Check that requirements.lock does not exist
+ _, err = os.Stat(filepath.Join(dir, "requirements.lock"))
+ assert.Error(t, err)
+ assert.True(t, os.IsNotExist(err))
+ })
+
+ t.Run("v1 lock file", func(t *testing.T) {
+ dir := t.TempDir()
+ err := writeLock(dir, lock, true)
+ assert.NoError(t, err)
+
+ lockfilePath := filepath.Join(dir, "requirements.lock")
+ _, err = os.Stat(lockfilePath)
+ assert.NoError(t, err, "requirements.lock should exist")
+
+ content, err := os.ReadFile(lockfilePath)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedContent, content)
+
+ // Check that Chart.lock does not exist
+ _, err = os.Stat(filepath.Join(dir, "Chart.lock"))
+ assert.Error(t, err)
+ assert.True(t, os.IsNotExist(err))
+ })
+
+ t.Run("overwrite existing lock file", func(t *testing.T) {
+ dir := t.TempDir()
+ lockfilePath := filepath.Join(dir, "Chart.lock")
+ assert.NoError(t, os.WriteFile(lockfilePath, []byte("old content"), 0644))
+
+ err = writeLock(dir, lock, false)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(lockfilePath)
+ assert.NoError(t, err)
+ assert.Equal(t, expectedContent, content)
+ })
+
+ t.Run("lock file is a symlink", func(t *testing.T) {
+ dir := t.TempDir()
+ dummyFile := filepath.Join(dir, "dummy.txt")
+ assert.NoError(t, os.WriteFile(dummyFile, []byte("dummy"), 0644))
+
+ lockfilePath := filepath.Join(dir, "Chart.lock")
+ assert.NoError(t, os.Symlink(dummyFile, lockfilePath))
+
+ err = writeLock(dir, lock, false)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "the Chart.lock file is a symlink to")
+ })
+
+ t.Run("chart path is not a directory", func(t *testing.T) {
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "not-a-dir")
+ assert.NoError(t, os.WriteFile(filePath, []byte("file"), 0644))
+
+ err = writeLock(filePath, lock, false)
+ assert.Error(t, err)
+ })
+}
diff --git a/pkg/downloader/testdata/repository/testing-relative-index.yaml b/pkg/downloader/testdata/repository/testing-relative-index.yaml
index ba27ed257..9524daf6e 100644
--- a/pkg/downloader/testdata/repository/testing-relative-index.yaml
+++ b/pkg/downloader/testdata/repository/testing-relative-index.yaml
@@ -26,3 +26,16 @@ entries:
version: 1.2.3
checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d
apiVersion: v2
+ baz:
+ - name: baz
+ description: Baz Chart With Absolute Path
+ home: https://helm.sh/helm
+ keywords: []
+ maintainers: []
+ sources:
+ - https://github.com/helm/charts
+ urls:
+ - /path/to/baz-1.2.3.tgz
+ version: 1.2.3
+ checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d
+ apiVersion: v2
diff --git a/pkg/engine/doc.go b/pkg/engine/doc.go
index 6b3443aaf..e764a829a 100644
--- a/pkg/engine/doc.go
+++ b/pkg/engine/doc.go
@@ -21,4 +21,4 @@ When Helm renders templates it does so with additional functions and different
modes (e.g., strict, lint mode). This package handles the helm specific
implementation.
*/
-package engine // import "helm.sh/helm/v3/pkg/engine"
+package engine // import "helm.sh/helm/v4/pkg/engine"
diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go
index 150be16b7..6e47a0e39 100644
--- a/pkg/engine/engine.go
+++ b/pkg/engine/engine.go
@@ -17,8 +17,10 @@ limitations under the License.
package engine
import (
+ "errors"
"fmt"
- "log"
+ "log/slog"
+ "maps"
"path"
"path/filepath"
"regexp"
@@ -26,13 +28,24 @@ import (
"strings"
"text/template"
- "github.com/pkg/errors"
"k8s.io/client-go/rest"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
+// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141
+// > "template: %s: executing %q at <%s>: %s"
+var execErrFmt = regexp.MustCompile(`^template: (?P(?U).+): executing (?P(?U).+) at (?P(?U).+): (?P(?U).+)(?P( template:.*)?)$`)
+
+// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138
+// > "template: %s: %s"
+var execErrFmtWithoutTemplate = regexp.MustCompile(`^template: (?P(?U).+): (?P.*)(?P( template:.*)?)$`)
+
+// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191
+// > "template: no template %q associated with template %q"
+var execErrNoTemplateAssociated = regexp.MustCompile(`^template: no template (?P.*) associated with template (?P(.*)?)$`)
+
// Engine is an implementation of the Helm rendering implementation for templates.
type Engine struct {
// If strict is enabled, template rendering will fail if a template references
@@ -40,16 +53,19 @@ type Engine struct {
Strict bool
// In LintMode, some 'required' template values may be missing, so don't fail
LintMode bool
- // the rest config to connect to the kubernetes api
- config *rest.Config
+ // optional provider of clients to talk to the Kubernetes API
+ clientProvider *ClientProvider
// EnableDNS tells the engine to allow DNS lookups when rendering templates
EnableDNS bool
+ // CustomTemplateFuncs is defined by users to provide custom template funcs
+ CustomTemplateFuncs template.FuncMap
}
// New creates a new instance of Engine using the passed in rest config.
func New(config *rest.Config) Engine {
+ var clientProvider ClientProvider = clientProviderFromConfig{config}
return Engine{
- config: config,
+ clientProvider: &clientProvider,
}
}
@@ -85,10 +101,21 @@ func Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, erro
// RenderWithClient takes a chart, optional values, and value overrides, and attempts to
// render the Go templates using the default options. This engine is client aware and so can have template
-// functions that interact with the client
+// functions that interact with the client.
func RenderWithClient(chrt *chart.Chart, values chartutil.Values, config *rest.Config) (map[string]string, error) {
+ var clientProvider ClientProvider = clientProviderFromConfig{config}
+ return Engine{
+ clientProvider: &clientProvider,
+ }.Render(chrt, values)
+}
+
+// RenderWithClientProvider takes a chart, optional values, and value overrides, and attempts to
+// render the Go templates using the default options. This engine is client aware and so can have template
+// functions that interact with the client.
+// This function differs from RenderWithClient in that it lets you customize the way a dynamic client is constructed.
+func RenderWithClientProvider(chrt *chart.Chart, values chartutil.Values, clientProvider ClientProvider) (map[string]string, error) {
return Engine{
- config: config,
+ clientProvider: &clientProvider,
}.Render(chrt, values)
}
@@ -112,17 +139,16 @@ func warnWrap(warn string) string {
return warnStartDelim + warn + warnEndDelim
}
-// initFunMap creates the Engine's FuncMap and adds context-specific functions.
-func (e Engine) initFunMap(t *template.Template, referenceTpls map[string]renderable) {
- funcMap := funcMap()
- includedNames := make(map[string]int)
-
- // Add the 'include' function here so we can close over t.
- funcMap["include"] = func(name string, data interface{}) (string, error) {
+// '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) {
var buf strings.Builder
if v, ok := includedNames[name]; ok {
if v > recursionMaxNums {
- return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name)
+ return "", fmt.Errorf(
+ "rendering template has a nested reference name: %s: %w",
+ name, errors.New("unable to execute template"))
}
includedNames[name]++
} else {
@@ -132,51 +158,80 @@ func (e Engine) initFunMap(t *template.Template, referenceTpls map[string]render
includedNames[name]--
return buf.String(), err
}
+}
- // Add the 'tpl' function here
- funcMap["tpl"] = func(tpl string, vals chartutil.Values) (string, error) {
- basePath, err := vals.PathValue("Template.BasePath")
+// 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) {
+ t, err := parent.Clone()
if err != nil {
- return "", errors.Wrapf(err, "cannot retrieve Template.Basepath from values inside tpl function: %s", tpl)
+ return "", fmt.Errorf("cannot clone template: %w", err)
}
- templateName, err := vals.PathValue("Template.Name")
- if err != nil {
- return "", errors.Wrapf(err, "cannot retrieve Template.Name from values inside tpl function: %s", tpl)
+ // Re-inject the missingkey option, see text/template issue https://github.com/golang/go/issues/43022
+ // We have to go by strict from our engine configuration, as the option fields are private in Template.
+ // TODO: Remove workaround (and the strict parameter) once we build only with golang versions with a fix.
+ if strict {
+ t.Option("missingkey=error")
+ } else {
+ t.Option("missingkey=zero")
}
- templates := map[string]renderable{
- templateName.(string): {
- tpl: tpl,
- vals: vals,
- basePath: basePath.(string),
- },
+ // Re-inject 'include' so that it can close over our clone of t;
+ // this lets any 'define's inside tpl be 'include'd.
+ t.Funcs(template.FuncMap{
+ "include": includeFun(t, includedNames),
+ "tpl": tplFun(t, includedNames, strict),
+ })
+
+ // We need a .New template, as template text which is just blanks
+ // or comments after parsing out defines just adds new named
+ // template definitions without changing the main template.
+ // https://pkg.go.dev/text/template#Template.Parse
+ // Use the parent's name for lack of a better way to identify the tpl
+ // text string. (Maybe we could use a hash appended to the name?)
+ t, err = t.New(parent.Name()).Parse(tpl)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse template %q: %w", tpl, err)
}
- result, err := e.renderWithReferences(templates, referenceTpls)
- if err != nil {
- return "", errors.Wrapf(err, "error during tpl function execution for %q", tpl)
+ var buf strings.Builder
+ if err := t.Execute(&buf, vals); err != nil {
+ return "", fmt.Errorf("error during tpl function execution for %q: %w", tpl, err)
}
- return result[templateName.(string)], nil
+
+ // See comment in renderWithReferences explaining the hack.
+ return strings.ReplaceAll(buf.String(), "", ""), nil
}
+}
+
+// initFunMap creates the Engine's FuncMap and adds context-specific functions.
+func (e Engine) initFunMap(t *template.Template) {
+ funcMap := funcMap()
+ includedNames := make(map[string]int)
+
+ // Add the template-rendering functions here so we can close over t.
+ funcMap["include"] = includeFun(t, includedNames)
+ 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) {
if val == nil {
if e.LintMode {
// Don't fail on missing required values when linting
- log.Printf("[INFO] Missing required value: %s", warn)
+ slog.Warn("missing required value", "message", warn)
return "", nil
}
- return val, errors.Errorf(warnWrap(warn))
+ return val, errors.New(warnWrap(warn))
} else if _, ok := val.(string); ok {
if val == "" {
if e.LintMode {
// Don't fail on missing required values when linting
- log.Printf("[INFO] Missing required value: %s", warn)
+ slog.Warn("missing required values", "message", warn)
return "", nil
}
- return val, errors.Errorf(warnWrap(warn))
+ return val, errors.New(warnWrap(warn))
}
}
return val, nil
@@ -186,7 +241,7 @@ func (e Engine) initFunMap(t *template.Template, referenceTpls map[string]render
funcMap["fail"] = func(msg string) (string, error) {
if e.LintMode {
// Don't fail when linting
- log.Printf("[INFO] Fail: %s", msg)
+ slog.Info("funcMap fail", "message", msg)
return "", nil
}
return "", errors.New(warnWrap(msg))
@@ -194,29 +249,26 @@ func (e Engine) initFunMap(t *template.Template, referenceTpls map[string]render
// If we are not linting and have a cluster connection, provide a Kubernetes-backed
// implementation.
- if !e.LintMode && e.config != nil {
- funcMap["lookup"] = NewLookupFunction(e.config)
+ if !e.LintMode && e.clientProvider != nil {
+ funcMap["lookup"] = newLookupFunction(*e.clientProvider)
}
// When DNS lookups are not enabled override the sprig function and return
// an empty string.
if !e.EnableDNS {
- funcMap["getHostByName"] = func(name string) string {
+ funcMap["getHostByName"] = func(_ string) string {
return ""
}
}
+ // Set custom template funcs
+ maps.Copy(funcMap, e.CustomTemplateFuncs)
+
t.Funcs(funcMap)
}
// render takes a map of templates/values and renders them.
-func (e Engine) render(tpls map[string]renderable) (map[string]string, error) {
- return e.renderWithReferences(tpls, tpls)
-}
-
-// renderWithReferences takes a map of templates/values to render, and a map of
-// templates which can be referenced within them.
-func (e Engine) renderWithReferences(tpls, referenceTpls map[string]renderable) (rendered map[string]string, err error) {
+func (e Engine) render(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.
@@ -226,7 +278,7 @@ func (e Engine) renderWithReferences(tpls, referenceTpls map[string]renderable)
// template engine.
defer func() {
if r := recover(); r != nil {
- err = errors.Errorf("rendering template failed: %v", r)
+ err = fmt.Errorf("rendering template failed: %v", r)
}
}()
t := template.New("gotpl")
@@ -238,12 +290,11 @@ func (e Engine) renderWithReferences(tpls, referenceTpls map[string]renderable)
t.Option("missingkey=zero")
}
- e.initFunMap(t, referenceTpls)
+ e.initFunMap(t)
// We want to parse the templates in a predictable order. The order favors
// higher-level (in file system) templates over deeply nested templates.
keys := sortTemplates(tpls)
- referenceKeys := sortTemplates(referenceTpls)
for _, filename := range keys {
r := tpls[filename]
@@ -252,17 +303,6 @@ func (e Engine) renderWithReferences(tpls, referenceTpls map[string]renderable)
}
}
- // Adding the reference templates to the template context
- // so they can be referenced in the tpl function
- for _, filename := range referenceKeys {
- if t.Lookup(filename) == nil {
- r := referenceTpls[filename]
- if _, err := t.New(filename).Parse(r.tpl); err != nil {
- return map[string]string{}, cleanupParseError(filename, err)
- }
- }
- }
-
rendered = make(map[string]string, len(keys))
for _, filename := range keys {
// Don't render partials. We don't care out the direct output of partials.
@@ -275,7 +315,7 @@ func (e Engine) renderWithReferences(tpls, referenceTpls map[string]renderable)
vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath}
var buf strings.Builder
if err := t.ExecuteTemplate(&buf, filename, vals); err != nil {
- return map[string]string{}, cleanupExecError(filename, err)
+ return map[string]string{}, reformatExecErrorMsg(filename, err)
}
// Work around the issue where Go will emit "" even if Options(missing=zero)
@@ -301,7 +341,33 @@ func cleanupParseError(filename string, err error) error {
return fmt.Errorf("parse error at (%s): %s", string(location), errMsg)
}
-func cleanupExecError(filename string, err error) error {
+type TraceableError struct {
+ location string
+ message string
+ executedFunction string
+}
+
+func (t TraceableError) String() string {
+ var errorString strings.Builder
+ if t.location != "" {
+ fmt.Fprintf(&errorString, "%s\n ", t.location)
+ }
+ if t.executedFunction != "" {
+ fmt.Fprintf(&errorString, "%s\n ", t.executedFunction)
+ }
+ if t.message != "" {
+ fmt.Fprintf(&errorString, "%s\n", t.message)
+ }
+ return errorString.String()
+}
+
+// reformatExecErrorMsg takes an error message for template rendering and formats it into a formatted
+// multi-line error string
+func reformatExecErrorMsg(filename string, err error) error {
+ // This function matches the error message against regex's for the text/template package.
+ // If the regex's can parse out details from that error message such as the line number, template it failed on,
+ // and error description, then it will construct a new error that displays these details in a structured way.
+ // If there are issues with parsing the error message, the err passed into the function should return instead.
if _, isExecError := err.(template.ExecError); !isExecError {
return err
}
@@ -320,8 +386,46 @@ func cleanupExecError(filename string, err error) error {
if len(parts) >= 2 {
return fmt.Errorf("execution error at (%s): %s", string(location), parts[1])
}
+ current := err
+ fileLocations := []TraceableError{}
+ for current != nil {
+ var traceable TraceableError
+ if matches := execErrFmt.FindStringSubmatch(current.Error()); matches != nil {
+ templateName := matches[execErrFmt.SubexpIndex("templateName")]
+ functionName := matches[execErrFmt.SubexpIndex("functionName")]
+ locationName := matches[execErrFmt.SubexpIndex("location")]
+ errMsg := matches[execErrFmt.SubexpIndex("errMsg")]
+ traceable = TraceableError{
+ location: templateName,
+ message: errMsg,
+ executedFunction: "executing " + functionName + " at " + locationName + ":",
+ }
+ } else if matches := execErrFmtWithoutTemplate.FindStringSubmatch(current.Error()); matches != nil {
+ templateName := matches[execErrFmt.SubexpIndex("templateName")]
+ errMsg := matches[execErrFmt.SubexpIndex("errMsg")]
+ traceable = TraceableError{
+ location: templateName,
+ message: errMsg,
+ }
+ } else if matches := execErrNoTemplateAssociated.FindStringSubmatch(current.Error()); matches != nil {
+ traceable = TraceableError{
+ message: current.Error(),
+ }
+ } else {
+ return err
+ }
+ if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != traceable {
+ fileLocations = append(fileLocations, traceable)
+ }
+ current = errors.Unwrap(current)
+ }
+
+ var finalErrorString strings.Builder
+ for _, fileLocation := range fileLocations {
+ fmt.Fprintf(&finalErrorString, "%s", fileLocation.String())
+ }
- return err
+ return errors.New(strings.TrimSpace(finalErrorString.String()))
}
func sortTemplates(tpls map[string]renderable) []string {
diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go
index 27bb9e78e..f4228fbd7 100644
--- a/pkg/engine/engine_test.go
+++ b/pkg/engine/engine_test.go
@@ -24,8 +24,16 @@ import (
"testing"
"text/template"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
+ "github.com/stretchr/testify/assert"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/dynamic"
+ "k8s.io/client-go/dynamic/fake"
+
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
func TestSortTemplates(t *testing.T) {
@@ -72,7 +80,7 @@ func TestFuncMap(t *testing.T) {
}
// Test for Engine-specific template functions.
- expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "toJson", "fromJson", "lookup"}
+ expect := []string{"include", "required", "tpl", "toYaml", "fromYaml", "toToml", "fromToml", "toJson", "fromJson", "lookup"}
for _, f := range expect {
if _, ok := fns[f]; !ok {
t.Errorf("Expected add-on function %q", f)
@@ -204,7 +212,7 @@ func TestRenderInternals(t *testing.T) {
}
}
-func TestRenderWIthDNS(t *testing.T) {
+func TestRenderWithDNS(t *testing.T) {
c := &chart.Chart{
Metadata: &chart.Metadata{
Name: "moby",
@@ -240,6 +248,178 @@ func TestRenderWIthDNS(t *testing.T) {
}
}
+type kindProps struct {
+ shouldErr error
+ gvr schema.GroupVersionResource
+ namespaced bool
+}
+
+type testClientProvider struct {
+ t *testing.T
+ scheme map[string]kindProps
+ objects []runtime.Object
+}
+
+func (p *testClientProvider) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) {
+ props := p.scheme[path.Join(apiVersion, kind)]
+ if props.shouldErr != nil {
+ return nil, false, props.shouldErr
+ }
+ return fake.NewSimpleDynamicClient(runtime.NewScheme(), p.objects...).Resource(props.gvr), props.namespaced, nil
+}
+
+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{}{
+ "apiVersion": apiVersion,
+ "kind": kind,
+ "metadata": map[string]interface{}{
+ "name": name,
+ },
+ }}
+ if namespace != "" {
+ ret.Object["metadata"].(map[string]interface{})["namespace"] = namespace
+ }
+ return ret
+}
+
+func TestRenderWithClientProvider(t *testing.T) {
+ provider := &testClientProvider{
+ t: t,
+ scheme: map[string]kindProps{
+ "v1/Namespace": {
+ gvr: schema.GroupVersionResource{
+ Version: "v1",
+ Resource: "namespaces",
+ },
+ },
+ "v1/Pod": {
+ gvr: schema.GroupVersionResource{
+ Version: "v1",
+ Resource: "pods",
+ },
+ namespaced: true,
+ },
+ },
+ objects: []runtime.Object{
+ makeUnstructured("v1", "Namespace", "default", ""),
+ makeUnstructured("v1", "Pod", "pod1", "default"),
+ makeUnstructured("v1", "Pod", "pod2", "ns1"),
+ makeUnstructured("v1", "Pod", "pod3", "ns1"),
+ },
+ }
+
+ type testCase struct {
+ template string
+ output string
+ }
+ cases := map[string]testCase{
+ "ns-single": {
+ template: `{{ (lookup "v1" "Namespace" "" "default").metadata.name }}`,
+ output: "default",
+ },
+ "ns-list": {
+ template: `{{ (lookup "v1" "Namespace" "" "").items | len }}`,
+ output: "1",
+ },
+ "ns-missing": {
+ template: `{{ (lookup "v1" "Namespace" "" "absent") }}`,
+ output: "map[]",
+ },
+ "pod-single": {
+ template: `{{ (lookup "v1" "Pod" "default" "pod1").metadata.name }}`,
+ output: "pod1",
+ },
+ "pod-list": {
+ template: `{{ (lookup "v1" "Pod" "ns1" "").items | len }}`,
+ output: "2",
+ },
+ "pod-all": {
+ template: `{{ (lookup "v1" "Pod" "" "").items | len }}`,
+ output: "3",
+ },
+ "pod-missing": {
+ template: `{{ (lookup "v1" "Pod" "" "ns2") }}`,
+ output: "map[]",
+ },
+ }
+
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "moby",
+ Version: "1.2.3",
+ },
+ Values: map[string]interface{}{},
+ }
+
+ for name, exp := range cases {
+ c.Templates = append(c.Templates, &chart.File{
+ Name: path.Join("templates", name),
+ Data: []byte(exp.template),
+ })
+ }
+
+ vals := map[string]interface{}{
+ "Values": map[string]interface{}{},
+ }
+
+ v, err := chartutil.CoalesceValues(c, vals)
+ if err != nil {
+ t.Fatalf("Failed to coalesce values: %s", err)
+ }
+
+ out, err := RenderWithClientProvider(c, v, provider)
+ if err != nil {
+ t.Errorf("Failed to render templates: %s", err)
+ }
+
+ for name, want := range cases {
+ t.Run(name, func(t *testing.T) {
+ key := path.Join("moby/templates", name)
+ if out[key] != want.output {
+ t.Errorf("Expected %q, got %q", want, out[key])
+ }
+ })
+ }
+}
+
+func TestRenderWithClientProvider_error(t *testing.T) {
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "moby",
+ Version: "1.2.3",
+ },
+ Templates: []*chart.File{
+ {Name: "templates/error", Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)},
+ },
+ Values: map[string]interface{}{},
+ }
+
+ vals := map[string]interface{}{
+ "Values": map[string]interface{}{},
+ }
+
+ v, err := chartutil.CoalesceValues(c, vals)
+ if err != nil {
+ t.Fatalf("Failed to coalesce values: %s", err)
+ }
+
+ provider := &testClientProvider{
+ t: t,
+ scheme: map[string]kindProps{
+ "v1/Error": {
+ shouldErr: fmt.Errorf("kaboom"),
+ },
+ },
+ }
+ _, err = RenderWithClientProvider(c, v, provider)
+ if err == nil || !strings.Contains(err.Error(), "kaboom") {
+ t.Errorf("Expected error from client provider when rendering, got %q", err)
+ }
+}
+
func TestParallelRenderInternals(t *testing.T) {
// Make sure that we can use one Engine to run parallel template renders.
e := new(Engine)
@@ -948,8 +1128,6 @@ func TestRenderTplTemplateNames(t *testing.T) {
{Name: "templates/default-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)},
{Name: "templates/modified-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)},
{Name: "templates/modified-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .Values.dot}}`)},
- // Current implementation injects the 'tpl' template as if it were a template file, and
- // so only BasePath and Name make it through.
{Name: "templates/modified-field", Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)},
},
}
@@ -979,7 +1157,7 @@ func TestRenderTplTemplateNames(t *testing.T) {
"TplTemplateNames/templates/default-name": "TplTemplateNames/templates/default-name",
"TplTemplateNames/templates/modified-basepath": "path/to/template",
"TplTemplateNames/templates/modified-name": "name-of-template",
- "TplTemplateNames/templates/modified-field": "",
+ "TplTemplateNames/templates/modified-field": "extra-field",
}
for file, expect := range expects {
if out[file] != expect {
@@ -1001,13 +1179,17 @@ func TestRenderTplRedefines(t *testing.T) {
`{{define "manifest"}}original-in-manifest{{end}}` +
`before: {{include "manifest" .}}\n{{tpl .Values.manifestText .}}\nafter: {{include "manifest" .}}`,
)},
- // The current implementation replaces the manifest text and re-parses, so a
- // partial template defined only in the manifest invoking tpl cannot be accessed
- // by that tpl call.
- //{Name: "templates/manifest-only", Data: []byte(
- // `{{define "manifest-only"}}only-in-manifest{{end}}` +
- // `before: {{include "manifest-only" .}}\n{{tpl .Values.manifestOnlyText .}}\nafter: {{include "manifest-only" .}}`,
- //)},
+ {Name: "templates/manifest-only", Data: []byte(
+ `{{define "manifest-only"}}only-in-manifest{{end}}` +
+ `before: {{include "manifest-only" .}}\n{{tpl .Values.manifestOnlyText .}}\nafter: {{include "manifest-only" .}}`,
+ )},
+ {Name: "templates/nested", Data: []byte(
+ `{{define "nested"}}original-in-manifest{{end}}` +
+ `{{define "nested-outer"}}original-outer-in-manifest{{end}}` +
+ `before: {{include "nested" .}} {{include "nested-outer" .}}\n` +
+ `{{tpl .Values.nestedText .}}\n` +
+ `after: {{include "nested" .}} {{include "nested-outer" .}}`,
+ )},
},
}
v := chartutil.Values{
@@ -1015,6 +1197,12 @@ func TestRenderTplRedefines(t *testing.T) {
"partialText": `{{define "partial"}}redefined-in-tpl{{end}}tpl: {{include "partial" .}}`,
"manifestText": `{{define "manifest"}}redefined-in-tpl{{end}}tpl: {{include "manifest" .}}`,
"manifestOnlyText": `tpl: {{include "manifest-only" .}}`,
+ "nestedText": `{{define "nested"}}redefined-in-tpl{{end}}` +
+ `{{define "nested-outer"}}redefined-outer-in-tpl{{end}}` +
+ `before-inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}\n` +
+ `{{tpl .Values.innerText .}}\n` +
+ `after-inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`,
+ "innerText": `{{define "nested"}}redefined-in-inner-tpl{{end}}inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`,
},
"Chart": c.Metadata,
"Release": chartutil.Values{
@@ -1028,9 +1216,14 @@ func TestRenderTplRedefines(t *testing.T) {
}
expects := map[string]string{
- "TplRedefines/templates/partial": `before: original-in-partial\ntpl: original-in-partial\nafter: original-in-partial`,
- "TplRedefines/templates/manifest": `before: original-in-manifest\ntpl: redefined-in-tpl\nafter: original-in-manifest`,
- //"TplRedefines/templates/manifest-only": `before: only-in-manifest\ntpl: only-in-manifest\nafter: only-in-manifest`,
+ "TplRedefines/templates/partial": `before: original-in-partial\ntpl: redefined-in-tpl\nafter: original-in-partial`,
+ "TplRedefines/templates/manifest": `before: original-in-manifest\ntpl: redefined-in-tpl\nafter: original-in-manifest`,
+ "TplRedefines/templates/manifest-only": `before: only-in-manifest\ntpl: only-in-manifest\nafter: only-in-manifest`,
+ "TplRedefines/templates/nested": `before: original-in-manifest original-outer-in-manifest\n` +
+ `before-inner-tpl: redefined-in-tpl redefined-outer-in-tpl\n` +
+ `inner-tpl: redefined-in-inner-tpl redefined-outer-in-tpl\n` +
+ `after-inner-tpl: redefined-in-tpl redefined-outer-in-tpl\n` +
+ `after: original-in-manifest original-outer-in-manifest`,
}
for file, expect := range expects {
if out[file] != expect {
@@ -1098,14 +1291,140 @@ func TestRenderTplMissingKeyString(t *testing.T) {
t.Errorf("Expected error, got %v", out)
return
}
- switch err.(type) {
- case (template.ExecError):
- errTxt := fmt.Sprint(err)
- if !strings.Contains(errTxt, "noSuchKey") {
- t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt)
- }
- default:
- // Some unexpected error.
+ errTxt := fmt.Sprint(err)
+ if !strings.Contains(errTxt, "noSuchKey") {
+ t.Errorf("Expected error to contain 'noSuchKey', got %s", errTxt)
+ }
+
+}
+
+func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) {
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{Name: "NestedHelperFunctions"},
+ Templates: []*chart.File{
+ {Name: "templates/svc.yaml", Data: []byte(
+ `name: {{ include "nested_helper.name" . }}`,
+ )},
+ {Name: "templates/_helpers_1.tpl", Data: []byte(
+ `{{- define "nested_helper.name" -}}{{- include "common.names.get_name" . -}}{{- end -}}`,
+ )},
+ {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte(
+ `{{- define "common.names.get_name" -}}{{- .Values.nonexistant.key | trunc 63 | trimSuffix "-" -}}{{- end -}}`,
+ )},
+ },
+ }
+
+ expectedErrorMessage := `NestedHelperFunctions/templates/svc.yaml:1:9
+ executing "NestedHelperFunctions/templates/svc.yaml" at :
+ error calling include:
+NestedHelperFunctions/templates/_helpers_1.tpl:1:39
+ executing "nested_helper.name" at :
+ error calling include:
+NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49
+ executing "common.names.get_name" at <.Values.nonexistant.key>:
+ nil pointer evaluating interface {}.key`
+
+ v := chartutil.Values{}
+
+ val, _ := chartutil.CoalesceValues(c, v)
+ vals := map[string]interface{}{
+ "Values": val.AsMap(),
+ }
+ _, err := Render(c, vals)
+
+ assert.NotNil(t, err)
+ assert.Equal(t, expectedErrorMessage, err.Error())
+}
+
+func TestMultilineNoTemplateAssociatedError(t *testing.T) {
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{Name: "multiline"},
+ Templates: []*chart.File{
+ {Name: "templates/svc.yaml", Data: []byte(
+ `name: {{ include "nested_helper.name" . }}`,
+ )},
+ {Name: "templates/test.yaml", Data: []byte(
+ `{{ toYaml .Values }}`,
+ )},
+ {Name: "charts/common/templates/_helpers_2.tpl", Data: []byte(
+ `{{ toYaml .Values }}`,
+ )},
+ },
+ }
+
+ expectedErrorMessage := `multiline/templates/svc.yaml:1:9
+ executing "multiline/templates/svc.yaml" at :
+ error calling include:
+template: no template "nested_helper.name" associated with template "gotpl"`
+
+ v := chartutil.Values{}
+
+ val, _ := chartutil.CoalesceValues(c, v)
+ vals := map[string]interface{}{
+ "Values": val.AsMap(),
+ }
+ _, err := Render(c, vals)
+
+ assert.NotNil(t, err)
+ assert.Equal(t, expectedErrorMessage, err.Error())
+}
+
+func TestRenderCustomTemplateFuncs(t *testing.T) {
+ // Create a chart with two templates that use custom functions
+ c := &chart.Chart{
+ Metadata: &chart.Metadata{Name: "CustomFunc"},
+ Templates: []*chart.File{
+ {
+ Name: "templates/manifest",
+ Data: []byte(`{{exclaim .Values.message}}`),
+ },
+ {
+ Name: "templates/override",
+ Data: []byte(`{{ upper .Values.message }}`),
+ },
+ },
+ }
+ v := chartutil.Values{
+ "Values": chartutil.Values{
+ "message": "hello",
+ },
+ "Chart": c.Metadata,
+ "Release": chartutil.Values{
+ "Name": "TestRelease",
+ },
+ }
+
+ // Define a custom template function "exclaim" that appends "!!!" to a string and override "upper" function
+ customFuncs := template.FuncMap{
+ "exclaim": func(input string) string {
+ return input + "!!!"
+ },
+ "upper": func(s string) string {
+ return "custom:" + s
+ },
+ }
+
+ // Create an engine instance and set the CustomTemplateFuncs.
+ e := new(Engine)
+ e.CustomTemplateFuncs = customFuncs
+
+ // Render the chart.
+ out, err := e.Render(c, v)
+ if err != nil {
t.Fatal(err)
}
+
+ // Expected output should be "hello!!!".
+ expected := "hello!!!"
+ key := "CustomFunc/templates/manifest"
+ if rendered, ok := out[key]; !ok || rendered != expected {
+ t.Errorf("Expected %q, got %q", expected, rendered)
+ }
+
+ // Verify that the rendered template used the custom "upper" function.
+ expected = "custom:hello"
+ key = "CustomFunc/templates/override"
+ if rendered, ok := out[key]; !ok || rendered != expected {
+ t.Errorf("Expected %q, got %q", expected, rendered)
+ }
}
diff --git a/pkg/engine/files.go b/pkg/engine/files.go
index f2cfdb3f3..87166728c 100644
--- a/pkg/engine/files.go
+++ b/pkg/engine/files.go
@@ -23,7 +23,7 @@ import (
"github.com/gobwas/glob"
- "helm.sh/helm/v3/pkg/chart"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
)
// files is a map of files in a chart that can be accessed from a template.
diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go
index 8f05a3a1d..a97f8f104 100644
--- a/pkg/engine/funcs.go
+++ b/pkg/engine/funcs.go
@@ -19,12 +19,14 @@ package engine
import (
"bytes"
"encoding/json"
+ "maps"
"strings"
"text/template"
"github.com/BurntSushi/toml"
"github.com/Masterminds/sprig/v3"
"sigs.k8s.io/yaml"
+ goYaml "sigs.k8s.io/yaml/goyaml.v3"
)
// funcMap returns a mapping of all of the functions that Engine has.
@@ -48,10 +50,14 @@ func funcMap() template.FuncMap {
// Add some extra functionality
extra := template.FuncMap{
"toToml": toTOML,
+ "fromToml": fromTOML,
"toYaml": toYAML,
+ "mustToYaml": mustToYAML,
+ "toYamlPretty": toYAMLPretty,
"fromYaml": fromYAML,
"fromYamlArray": fromYAMLArray,
"toJson": toJSON,
+ "mustToJson": mustToJSON,
"fromJson": fromJSON,
"fromJsonArray": fromJSONArray,
@@ -68,9 +74,7 @@ func funcMap() template.FuncMap {
},
}
- for k, v := range extra {
- f[k] = v
- }
+ maps.Copy(f, extra)
return f
}
@@ -88,6 +92,32 @@ func toYAML(v interface{}) string {
return strings.TrimSuffix(string(data), "\n")
}
+// mustToYAML takes an interface, marshals it to yaml, and returns a string.
+// It will panic if there is an error.
+//
+// This is designed to be called from a template when need to ensure that the
+// output YAML is valid.
+func mustToYAML(v interface{}) string {
+ data, err := yaml.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return strings.TrimSuffix(string(data), "\n")
+}
+
+func toYAMLPretty(v interface{}) string {
+ var data bytes.Buffer
+ encoder := goYaml.NewEncoder(&data)
+ encoder.SetIndent(2)
+ err := encoder.Encode(v)
+
+ if err != nil {
+ // Swallow errors inside of a template.
+ return ""
+ }
+ return strings.TrimSuffix(data.String(), "\n")
+}
+
// fromYAML converts a YAML document into a map[string]interface{}.
//
// This is not a general-purpose YAML parser, and will not parse all valid
@@ -132,6 +162,21 @@ func toTOML(v interface{}) string {
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{})
+
+ if err := toml.Unmarshal([]byte(str), &m); err != nil {
+ m["Error"] = err.Error()
+ }
+ return m
+}
+
// toJSON takes an interface, marshals it to json, and returns a string. It will
// always return a string, even on marshal error (empty string).
//
@@ -145,6 +190,19 @@ func toJSON(v interface{}) string {
return string(data)
}
+// mustToJSON takes an interface, marshals it to json, and returns a string.
+// It will panic if there is an error.
+//
+// This is designed to be called from a template when need to ensure that the
+// output JSON is valid.
+func mustToJSON(v interface{}) string {
+ data, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return string(data)
+}
+
// fromJSON converts a JSON document into a map[string]interface{}.
//
// This is not a general-purpose JSON parser, and will not parse all valid
diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go
index 29bc121b5..71a72e2e4 100644
--- a/pkg/engine/funcs_test.go
+++ b/pkg/engine/funcs_test.go
@@ -33,10 +33,38 @@ func TestFuncs(t *testing.T) {
tpl: `{{ toYaml . }}`,
expect: `foo: bar`,
vars: map[string]interface{}{"foo": "bar"},
+ }, {
+ tpl: `{{ toYamlPretty . }}`,
+ expect: "baz:\n - 1\n - 2\n - 3",
+ vars: map[string]interface{}{"baz": []int{1, 2, 3}},
}, {
tpl: `{{ toToml . }}`,
expect: "foo = \"bar\"\n",
vars: map[string]interface{}{"foo": "bar"},
+ }, {
+ tpl: `{{ fromToml . }}`,
+ expect: "map[hello:world]",
+ vars: `hello = "world"`,
+ }, {
+ tpl: `{{ fromToml . }}`,
+ expect: "map[table:map[keyInTable:valueInTable subtable:map[keyInSubtable:valueInSubTable]]]",
+ vars: `
+[table]
+keyInTable = "valueInTable"
+[table.subtable]
+keyInSubtable = "valueInSubTable"`,
+ }, {
+ tpl: `{{ fromToml . }}`,
+ expect: "map[tableArray:[map[keyInElement0:valueInElement0] map[keyInElement1:valueInElement1]]]",
+ vars: `
+[[tableArray]]
+keyInElement0 = "valueInElement0"
+[[tableArray]]
+keyInElement1 = "valueInElement1"`,
+ }, {
+ tpl: `{{ fromToml . }}`,
+ expect: "map[Error:toml: line 1: unexpected EOF; expected key separator '=']",
+ vars: "one",
}, {
tpl: `{{ toJson . }}`,
expect: `{"foo":"bar"}`,
@@ -107,6 +135,43 @@ func TestFuncs(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, tt.expect, b.String(), tt.tpl)
}
+
+ loopMap := map[string]interface{}{
+ "foo": "bar",
+ }
+ loopMap["loop"] = []interface{}{loopMap}
+
+ mustFuncsTests := []struct {
+ tpl string
+ expect interface{}
+ vars interface{}
+ }{{
+ tpl: `{{ mustToYaml . }}`,
+ vars: loopMap,
+ }, {
+ tpl: `{{ mustToJson . }}`,
+ vars: loopMap,
+ }, {
+ tpl: `{{ toYaml . }}`,
+ expect: "", // should return empty string and swallow error
+ vars: loopMap,
+ }, {
+ tpl: `{{ toJson . }}`,
+ expect: "", // should return empty string and swallow error
+ vars: loopMap,
+ },
+ }
+
+ for _, tt := range mustFuncsTests {
+ var b strings.Builder
+ err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars)
+ if tt.expect != nil {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expect, b.String(), tt.tpl)
+ } else {
+ assert.Error(t, err)
+ }
+ }
}
// This test to check a function provided by sprig is due to a change in a
diff --git a/pkg/engine/lookup_func.go b/pkg/engine/lookup_func.go
index b378ca9d6..605b43a48 100644
--- a/pkg/engine/lookup_func.go
+++ b/pkg/engine/lookup_func.go
@@ -18,10 +18,10 @@ package engine
import (
"context"
- "log"
+ "fmt"
+ "log/slog"
"strings"
- "github.com/pkg/errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -35,13 +35,29 @@ type lookupFunc = func(apiversion string, resource string, namespace string, nam
// NewLookupFunction returns a function for looking up objects in the cluster.
//
// If the resource does not exist, no error is raised.
-//
-// This function is considered deprecated, and will be renamed in Helm 4. It will no
-// longer be a public function.
func NewLookupFunction(config *rest.Config) lookupFunc {
- return func(apiversion string, resource string, namespace string, name string) (map[string]interface{}, error) {
+ return newLookupFunction(clientProviderFromConfig{config: config})
+}
+
+type ClientProvider interface {
+ // GetClientFor returns a dynamic.NamespaceableResourceInterface suitable for interacting with resources
+ // corresponding to the provided apiVersion and kind, as well as a boolean indicating whether the resources
+ // are namespaced.
+ GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error)
+}
+
+type clientProviderFromConfig struct {
+ config *rest.Config
+}
+
+func (c clientProviderFromConfig) GetClientFor(apiVersion, kind string) (dynamic.NamespaceableResourceInterface, bool, error) {
+ return getDynamicClientOnKind(apiVersion, kind, c.config)
+}
+
+func newLookupFunction(clientProvider ClientProvider) lookupFunc {
+ return func(apiversion string, kind string, namespace string, name string) (map[string]interface{}, error) {
var client dynamic.ResourceInterface
- c, namespaced, err := getDynamicClientOnKind(apiversion, resource, config)
+ c, namespaced, err := clientProvider.GetClientFor(apiversion, kind)
if err != nil {
return map[string]interface{}{}, err
}
@@ -82,8 +98,8 @@ func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config)
gvk := schema.FromAPIVersionAndKind(apiversion, kind)
apiRes, err := getAPIResourceForGVK(gvk, config)
if err != nil {
- log.Printf("[ERROR] unable to get apiresource from unstructured: %s , error %s", gvk.String(), err)
- return nil, false, errors.Wrapf(err, "unable to get apiresource from unstructured: %s", gvk.String())
+ slog.Error("unable to get apiresource", "groupVersionKind", gvk.String(), slog.Any("error", err))
+ return nil, false, fmt.Errorf("unable to get apiresource from unstructured: %s: %w", gvk.String(), err)
}
gvr := schema.GroupVersionResource{
Group: apiRes.Group,
@@ -92,7 +108,7 @@ func getDynamicClientOnKind(apiversion string, kind string, config *rest.Config)
}
intf, err := dynamic.NewForConfig(config)
if err != nil {
- log.Printf("[ERROR] unable to get dynamic client %s", err)
+ slog.Error("unable to get dynamic client", slog.Any("error", err))
return nil, false, err
}
res := intf.Resource(gvr)
@@ -103,16 +119,16 @@ func getAPIResourceForGVK(gvk schema.GroupVersionKind, config *rest.Config) (met
res := metav1.APIResource{}
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
- log.Printf("[ERROR] unable to create discovery client %s", err)
+ slog.Error("unable to create discovery client", slog.Any("error", err))
return res, err
}
resList, err := discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
if err != nil {
- log.Printf("[ERROR] unable to retrieve resource list for: %s , error: %s", gvk.GroupVersion().String(), err)
+ slog.Error("unable to retrieve resource list", "GroupVersion", gvk.GroupVersion().String(), slog.Any("error", err))
return res, err
}
for _, resource := range resList.APIResources {
- // if a resource contains a "/" it's referencing a subresource. we don't support suberesource for now.
+ // if a resource contains a "/" it's referencing a subresource. we don't support subresource for now.
if resource.Kind == gvk.Kind && !strings.Contains(resource.Name, "/") {
res = resource
res.Group = gvk.Group
diff --git a/pkg/gates/gates_test.go b/pkg/gates/gates_test.go
index 6bdd17ed6..4d77199e6 100644
--- a/pkg/gates/gates_test.go
+++ b/pkg/gates/gates_test.go
@@ -23,14 +23,13 @@ import (
const name string = "HELM_EXPERIMENTAL_FEATURE"
func TestIsEnabled(t *testing.T) {
- os.Unsetenv(name)
g := Gate(name)
if g.IsEnabled() {
t.Errorf("feature gate shows as available, but the environment variable %s was not set", name)
}
- os.Setenv(name, "1")
+ t.Setenv(name, "1")
if !g.IsEnabled() {
t.Errorf("feature gate shows as disabled, but the environment variable %s was set", name)
diff --git a/pkg/getter/getter.go b/pkg/getter/getter.go
index 45ab4da7e..a2d0f0ee2 100644
--- a/pkg/getter/getter.go
+++ b/pkg/getter/getter.go
@@ -18,19 +18,20 @@ package getter
import (
"bytes"
+ "fmt"
"net/http"
+ "slices"
"time"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/registry"
)
-// options are generic parameters to be provided to the getter during instantiation.
+// getterOptions are generic parameters to be provided to the getter during instantiation.
//
// Getters may or may not ignore these parameters as they are passed in.
-type options struct {
+// TODO what is the difference between this and schema.GetterOptionsV1?
+type getterOptions struct {
url string
certFile string
keyFile string
@@ -38,6 +39,7 @@ type options struct {
unTar bool
insecureSkipVerifyTLS bool
plainHTTP bool
+ acceptHeader string
username string
password string
passCredentialsAll bool
@@ -46,51 +48,59 @@ type options struct {
registryClient *registry.Client
timeout time.Duration
transport *http.Transport
+ artifactType string
}
// Option allows specifying various settings configurable by the user for overriding the defaults
// used when performing Get operations with the Getter.
-type Option func(*options)
+type Option func(*getterOptions)
// WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with
// WithTLSClientConfig to set the TLSClientConfig's server name.
func WithURL(url string) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.url = url
}
}
+// WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types
+func WithAcceptHeader(header string) Option {
+ return func(opts *getterOptions) {
+ opts.acceptHeader = header
+ }
+}
+
// WithBasicAuth sets the request's Authorization header to use the provided credentials
func WithBasicAuth(username, password string) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.username = username
opts.password = password
}
}
func WithPassCredentialsAll(pass bool) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.passCredentialsAll = pass
}
}
// WithUserAgent sets the request's User-Agent header to use the provided agent name.
func WithUserAgent(userAgent string) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.userAgent = userAgent
}
}
// WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked
func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS
}
}
// WithTLSClientConfig sets the client auth with the provided credentials.
func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.certFile = certFile
opts.keyFile = keyFile
opts.caFile = caFile
@@ -98,43 +108,50 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option {
}
func WithPlainHTTP(plainHTTP bool) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.plainHTTP = plainHTTP
}
}
// WithTimeout sets the timeout for requests
func WithTimeout(timeout time.Duration) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.timeout = timeout
}
}
func WithTagName(tagname string) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.version = tagname
}
}
func WithRegistryClient(client *registry.Client) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.registryClient = client
}
}
func WithUntar() Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.unTar = true
}
}
// WithTransport sets the http.Transport to allow overwriting the HTTPGetter default.
func WithTransport(transport *http.Transport) Option {
- return func(opts *options) {
+ return func(opts *getterOptions) {
opts.transport = transport
}
}
+// WithArtifactType sets the type of OCI artifact ("chart" or "plugin")
+func WithArtifactType(artifactType string) Option {
+ return func(opts *getterOptions) {
+ opts.artifactType = artifactType
+ }
+}
+
// Getter is an interface to support GET to the specified URL.
type Getter interface {
// Get file content by url string
@@ -156,12 +173,7 @@ type Provider struct {
// Provides returns true if the given scheme is supported by this Provider.
func (p Provider) Provides(scheme string) bool {
- for _, i := range p.Schemes {
- if i == scheme {
- return true
- }
- }
- return false
+ return slices.Contains(p.Schemes, scheme)
}
// Providers is a collection of Provider objects.
@@ -176,7 +188,7 @@ func (p Providers) ByScheme(scheme string) (Getter, error) {
return pp.New()
}
}
- return nil, errors.Errorf("scheme %q not supported", scheme)
+ return nil, fmt.Errorf("scheme %q not supported", scheme)
}
const (
@@ -188,25 +200,33 @@ const (
var defaultOptions = []Option{WithTimeout(time.Second * DefaultHTTPTimeout)}
-var httpProvider = Provider{
- Schemes: []string{"http", "https"},
- New: func(options ...Option) (Getter, error) {
- options = append(options, defaultOptions...)
- return NewHTTPGetter(options...)
- },
-}
-
-var ociProvider = Provider{
- Schemes: []string{registry.OCIScheme},
- New: NewOCIGetter,
+func Getters(extraOpts ...Option) Providers {
+ return Providers{
+ Provider{
+ Schemes: []string{"http", "https"},
+ New: func(options ...Option) (Getter, error) {
+ options = append(options, defaultOptions...)
+ options = append(options, extraOpts...)
+ return NewHTTPGetter(options...)
+ },
+ },
+ Provider{
+ Schemes: []string{registry.OCIScheme},
+ New: func(options ...Option) (Getter, error) {
+ options = append(options, defaultOptions...)
+ options = append(options, extraOpts...)
+ return NewOCIGetter(options...)
+ },
+ },
+ }
}
// All finds all of the registered getters as a list of Provider instances.
// Currently, the built-in getters and the discovered plugins with downloader
// notations are collected.
-func All(settings *cli.EnvSettings) Providers {
- result := Providers{httpProvider, ociProvider}
- pluginDownloaders, _ := collectPlugins(settings)
+func All(settings *cli.EnvSettings, opts ...Option) Providers {
+ result := Getters(opts...)
+ pluginDownloaders, _ := collectGetterPlugins(settings)
result = append(result, pluginDownloaders...)
return result
}
diff --git a/pkg/getter/getter_test.go b/pkg/getter/getter_test.go
index ab14784ab..83920e809 100644
--- a/pkg/getter/getter_test.go
+++ b/pkg/getter/getter_test.go
@@ -17,8 +17,9 @@ package getter
import (
"testing"
+ "time"
- "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v4/pkg/cli"
)
const pluginDir = "testdata/plugins"
@@ -52,6 +53,23 @@ func TestProviders(t *testing.T) {
}
}
+func TestProvidersWithTimeout(t *testing.T) {
+ want := time.Hour
+ getters := Getters(WithTimeout(want))
+ getter, err := getters.ByScheme("http")
+ if err != nil {
+ t.Error(err)
+ }
+ client, err := getter.(*HTTPGetter).httpClient()
+ if err != nil {
+ t.Error(err)
+ }
+ got := client.Timeout
+ if got != want {
+ t.Errorf("Expected %q, got %q", want, got)
+ }
+}
+
func TestAll(t *testing.T) {
env := cli.New()
env.PluginsDirectory = pluginDir
diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go
index b53e558e3..110f45c54 100644
--- a/pkg/getter/httpgetter.go
+++ b/pkg/getter/httpgetter.go
@@ -18,21 +18,19 @@ package getter
import (
"bytes"
"crypto/tls"
+ "fmt"
"io"
"net/http"
"net/url"
"sync"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/internal/urlutil"
- "helm.sh/helm/v3/internal/version"
+ "helm.sh/helm/v4/internal/tlsutil"
+ "helm.sh/helm/v4/internal/version"
)
// HTTPGetter is the default HTTP(/S) backend handler
type HTTPGetter struct {
- opts options
+ opts getterOptions
transport *http.Transport
once sync.Once
}
@@ -53,6 +51,10 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) {
return nil, err
}
+ if g.opts.acceptHeader != "" {
+ req.Header.Set("Accept", g.opts.acceptHeader)
+ }
+
req.Header.Set("User-Agent", version.GetUserAgent())
if g.opts.userAgent != "" {
req.Header.Set("User-Agent", g.opts.userAgent)
@@ -62,11 +64,11 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) {
// with the basic auth is the one being fetched.
u1, err := url.Parse(g.opts.url)
if err != nil {
- return nil, errors.Wrap(err, "Unable to parse getter URL")
+ return nil, fmt.Errorf("unable to parse getter URL: %w", err)
}
u2, err := url.Parse(href)
if err != nil {
- return nil, errors.Wrap(err, "Unable to parse URL getting from")
+ return nil, fmt.Errorf("unable to parse URL getting from: %w", err)
}
// Host on URL (returned from url.Parse) contains the port if present.
@@ -89,7 +91,7 @@ func (g *HTTPGetter) get(href string) (*bytes.Buffer, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return nil, errors.Errorf("failed to fetch %s : %s", href, resp.Status)
+ return nil, fmt.Errorf("failed to fetch %s : %s", href, resp.Status)
}
buf := bytes.NewBuffer(nil)
@@ -120,20 +122,21 @@ func (g *HTTPGetter) httpClient() (*http.Client, error) {
g.transport = &http.Transport{
DisableCompression: true,
Proxy: http.ProxyFromEnvironment,
+ // Being nil would cause the tls.Config default to be used
+ // "NewTLSConfig" modifies an empty TLS config, not the default one
+ TLSClientConfig: &tls.Config{},
}
})
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
- tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS)
- if err != nil {
- return nil, errors.Wrap(err, "can't create TLS config for client")
- }
-
- sni, err := urlutil.ExtractHostname(g.opts.url)
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
+ tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
+ tlsutil.WithCAFile(g.opts.caFile),
+ )
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("can't create TLS config for client: %w", err)
}
- tlsConf.ServerName = sni
g.transport.TLSClientConfig = tlsConf
}
diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go
index c727d0d7c..f87d71877 100644
--- a/pkg/getter/httpgetter_test.go
+++ b/pkg/getter/httpgetter_test.go
@@ -28,11 +28,9 @@ import (
"testing"
"time"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/internal/version"
- "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v4/internal/tlsutil"
+ "helm.sh/helm/v4/internal/version"
+ "helm.sh/helm/v4/pkg/cli"
)
func TestHTTPGetter(t *testing.T) {
@@ -52,7 +50,7 @@ func TestHTTPGetter(t *testing.T) {
timeout := time.Second * 5
transport := &http.Transport{}
- // Test with options
+ // Test with getterOptions
g, err = NewHTTPGetter(
WithBasicAuth("I", "Am"),
WithPassCredentialsAll(false),
@@ -280,6 +278,29 @@ func TestDownload(t *testing.T) {
if got.String() != expect {
t.Errorf("Expected %q, got %q", expect, got.String())
}
+
+ // test server with varied Accept Header
+ const expectedAcceptHeader = "application/gzip,application/octet-stream"
+ acceptHeaderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Accept") != expectedAcceptHeader {
+ t.Errorf("Expected '%s', got '%s'", expectedAcceptHeader, r.Header.Get("Accept"))
+ }
+ fmt.Fprint(w, expect)
+ }))
+
+ defer acceptHeaderSrv.Close()
+
+ u, _ = url.ParseRequestURI(acceptHeaderSrv.URL)
+ httpgetter, err = NewHTTPGetter(
+ WithAcceptHeader(expectedAcceptHeader),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = httpgetter.Get(u.String())
+ if err != nil {
+ t.Fatal(err)
+ }
}
func TestDownloadTLS(t *testing.T) {
@@ -287,10 +308,14 @@ func TestDownloadTLS(t *testing.T) {
ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem")
insecureSkipTLSverify := false
- tlsSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
- tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecureSkipTLSverify)
+ tlsSrv := httptest.NewUnstartedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify),
+ tlsutil.WithCertKeyPairFiles(pub, priv),
+ tlsutil.WithCAFile(ca),
+ )
if err != nil {
- t.Fatal(errors.Wrap(err, "can't create TLS config for client"))
+ t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err))
}
tlsConf.ServerName = "helm.sh"
tlsSrv.TLS = tlsConf
@@ -331,8 +356,133 @@ func TestDownloadTLS(t *testing.T) {
}
}
+func TestDownloadTLSWithRedirect(t *testing.T) {
+ cd := "../../testdata"
+ srv2Resp := "hello"
+ insecureSkipTLSverify := false
+
+ // Server 2 that will actually fulfil the request.
+ ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "localhost-crt.pem"), filepath.Join(cd, "key.pem")
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithCAFile(ca),
+ tlsutil.WithCertKeyPairFiles(pub, priv),
+ tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify),
+ )
+
+ if err != nil {
+ t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err))
+ }
+
+ tlsSrv2 := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
+ rw.Header().Set("Content-Type", "text/plain")
+ rw.Write([]byte(srv2Resp))
+ }))
+
+ tlsSrv2.TLS = tlsConf
+ tlsSrv2.StartTLS()
+ defer tlsSrv2.Close()
+
+ // Server 1 responds with a redirect to Server 2.
+ ca, pub, priv = filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem")
+ tlsConf, err = tlsutil.NewTLSConfig(
+ tlsutil.WithCAFile(ca),
+ tlsutil.WithCertKeyPairFiles(pub, priv),
+ tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify),
+ )
+
+ if err != nil {
+ t.Fatal(fmt.Errorf("can't create TLS config for client: %w", err))
+ }
+
+ tlsSrv1 := httptest.NewUnstartedServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ u, _ := url.ParseRequestURI(tlsSrv2.URL)
+
+ // Make the request using the hostname 'localhost' (to which 'localhost-crt.pem' is issued)
+ // to verify that a successful TLS connection is made even if the client doesn't specify
+ // the hostname (SNI) in `tls.Config.ServerName`. By default the hostname is derived from the
+ // 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())
+
+ http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
+ }))
+
+ tlsSrv1.TLS = tlsConf
+ tlsSrv1.StartTLS()
+ defer tlsSrv1.Close()
+
+ u, _ := url.ParseRequestURI(tlsSrv1.URL)
+
+ t.Run("Test with TLS", func(t *testing.T) {
+ g, err := NewHTTPGetter(
+ WithURL(u.String()),
+ WithTLSClientConfig(pub, priv, ca),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := g.Get(u.String())
+ if err != nil {
+ t.Error(err)
+ }
+
+ b, err := io.ReadAll(buf)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if string(b) != srv2Resp {
+ t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b))
+ }
+ })
+
+ t.Run("Test with TLS config being passed along in .Get (see #6635)", func(t *testing.T) {
+ g, err := NewHTTPGetter()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig(pub, priv, ca))
+ if err != nil {
+ t.Error(err)
+ }
+
+ b, err := io.ReadAll(buf)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if string(b) != srv2Resp {
+ t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b))
+ }
+ })
+
+ t.Run("Test with only the CA file (see also #6635)", func(t *testing.T) {
+ g, err := NewHTTPGetter()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ buf, err := g.Get(u.String(), WithURL(u.String()), WithTLSClientConfig("", "", ca))
+ if err != nil {
+ t.Error(err)
+ }
+
+ b, err := io.ReadAll(buf)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if string(b) != srv2Resp {
+ t.Errorf("expected response from Server2 to be '%s', instead got: %s", srv2Resp, string(b))
+ }
+ })
+}
+
func TestDownloadInsecureSkipTLSVerify(t *testing.T) {
- ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ ts := httptest.NewTLSServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
defer ts.Close()
u, _ := url.ParseRequestURI(ts.URL)
@@ -364,7 +514,7 @@ func TestDownloadInsecureSkipTLSVerify(t *testing.T) {
}
func TestHTTPGetterTarDownload(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
f, _ := os.Open("testdata/empty-0.0.1.tgz")
defer f.Close()
@@ -423,12 +573,10 @@ func TestHttpClientInsecureSkipVerify(t *testing.T) {
if len(transport.TLSClientConfig.Certificates) <= 0 {
t.Fatal("transport.TLSClientConfig.Certificates is not present")
}
- if transport.TLSClientConfig.ServerName == "" {
- t.Fatal("TLSClientConfig.ServerName is blank")
- }
}
func verifyInsecureSkipVerify(t *testing.T, g *HTTPGetter, caseName string, expectedValue bool) *http.Transport {
+ t.Helper()
returnVal, err := g.httpClient()
if err != nil {
diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go
index 209786bd7..121e000c8 100644
--- a/pkg/getter/ocigetter.go
+++ b/pkg/getter/ocigetter.go
@@ -17,21 +17,23 @@ package getter
import (
"bytes"
+ "crypto/tls"
"fmt"
"net"
"net/http"
+ "path"
"strings"
"sync"
"time"
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/internal/urlutil"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/internal/tlsutil"
+ "helm.sh/helm/v4/internal/urlutil"
+ "helm.sh/helm/v4/pkg/registry"
)
// OCIGetter is the default HTTP(/S) backend handler
type OCIGetter struct {
- opts options
+ opts getterOptions
transport *http.Transport
once sync.Once
}
@@ -58,6 +60,15 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) {
ref := strings.TrimPrefix(href, fmt.Sprintf("%s://", registry.OCIScheme))
+ if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") {
+ ref = fmt.Sprintf("%s:%s", ref, version)
+ }
+ // Check if this is a plugin request
+ if g.opts.artifactType == "plugin" {
+ return g.getPlugin(client, ref)
+ }
+
+ // Default to chart behavior for backward compatibility
var pullOpts []registry.PullOption
requestingProv := strings.HasSuffix(ref, ".prov")
if requestingProv {
@@ -119,11 +130,19 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) {
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
+ Proxy: http.ProxyFromEnvironment,
+ // Being nil would cause the tls.Config default to be used
+ // "NewTLSConfig" modifies an empty TLS config, not the default one
+ TLSClientConfig: &tls.Config{},
}
})
if (g.opts.certFile != "" && g.opts.keyFile != "") || g.opts.caFile != "" || g.opts.insecureSkipVerifyTLS {
- tlsConf, err := tlsutil.NewClientTLS(g.opts.certFile, g.opts.keyFile, g.opts.caFile, g.opts.insecureSkipVerifyTLS)
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(g.opts.insecureSkipVerifyTLS),
+ tlsutil.WithCertKeyPairFiles(g.opts.certFile, g.opts.keyFile),
+ tlsutil.WithCAFile(g.opts.caFile),
+ )
if err != nil {
return nil, fmt.Errorf("can't create TLS config for client: %w", err)
}
@@ -153,3 +172,28 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) {
return client, nil
}
+
+// getPlugin handles plugin-specific OCI pulls
+func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) {
+ // Extract plugin name from the reference
+ // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name"
+ parts := strings.Split(ref, "/")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("invalid OCI reference: %s", ref)
+ }
+ lastPart := parts[len(parts)-1]
+ pluginName := lastPart
+ if idx := strings.LastIndex(lastPart, ":"); idx > 0 {
+ pluginName = lastPart[:idx]
+ }
+ if idx := strings.LastIndex(lastPart, "@"); idx > 0 {
+ pluginName = lastPart[:idx]
+ }
+
+ result, err := client.PullPlugin(ref, pluginName)
+ if err != nil {
+ return nil, err
+ }
+
+ return bytes.NewBuffer(result.PluginData), nil
+}
diff --git a/pkg/getter/ocigetter_test.go b/pkg/getter/ocigetter_test.go
index d0834d9fc..ef196afcc 100644
--- a/pkg/getter/ocigetter_test.go
+++ b/pkg/getter/ocigetter_test.go
@@ -21,7 +21,7 @@ import (
"testing"
"time"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/registry"
)
func TestOCIGetter(t *testing.T) {
@@ -42,7 +42,7 @@ func TestOCIGetter(t *testing.T) {
insecureSkipVerifyTLS := false
plainHTTP := false
- // Test with options
+ // Test with getterOptions
g, err = NewOCIGetter(
WithBasicAuth("I", "Am"),
WithTLSClientConfig(pub, priv, ca),
diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go
index 0d13ade57..2b7669f23 100644
--- a/pkg/getter/plugingetter.go
+++ b/pkg/getter/plugingetter.go
@@ -17,86 +17,109 @@ package getter
import (
"bytes"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
+ "context"
+ "fmt"
- "github.com/pkg/errors"
+ "net/url"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/plugin"
+ "helm.sh/helm/v4/internal/plugin"
+
+ "helm.sh/helm/v4/internal/plugin/schema"
+ "helm.sh/helm/v4/pkg/cli"
)
-// collectPlugins scans for getter plugins.
+// collectGetterPlugins scans for getter plugins.
// This will load plugins according to the cli.
-func collectPlugins(settings *cli.EnvSettings) (Providers, error) {
- plugins, err := plugin.FindPlugins(settings.PluginsDirectory)
+func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) {
+ d := plugin.Descriptor{
+ Type: "getter/v1",
+ }
+ plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d)
if err != nil {
return nil, err
}
- var result Providers
- for _, plugin := range plugins {
- for _, downloader := range plugin.Metadata.Downloaders {
- result = append(result, Provider{
- Schemes: downloader.Protocols,
- New: NewPluginGetter(
- downloader.Command,
- settings,
- plugin.Metadata.Name,
- plugin.Dir,
- ),
+ pluginConstructorBuilder := func(plg plugin.Plugin) Constructor {
+ return func(option ...Option) (Getter, error) {
+
+ return &getterPlugin{
+ options: append([]Option{}, option...),
+ plg: plg,
+ }, nil
+ }
+ }
+ results := make([]Provider, 0, len(plgs))
+ for _, plg := range plgs {
+ if c, ok := plg.Metadata().Config.(*plugin.ConfigGetter); ok {
+ results = append(results, Provider{
+ Schemes: c.Protocols,
+ New: pluginConstructorBuilder(plg),
})
}
}
- return result, nil
-}
-
-// pluginGetter is a generic type to invoke custom downloaders,
-// implemented in plugins.
-type pluginGetter struct {
- command string
- settings *cli.EnvSettings
- name string
- base string
- opts options
+ return results, nil
}
-// Get runs downloader plugin command
-func (p *pluginGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
+func convertOptions(globalOptions, options []Option) schema.GetterOptionsV1 {
+ opts := getterOptions{}
+ for _, opt := range globalOptions {
+ opt(&opts)
+ }
for _, opt := range options {
- opt(&p.opts)
+ opt(&opts)
}
- commands := strings.Split(p.command, " ")
- argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href)
- prog := exec.Command(filepath.Join(p.base, commands[0]), argv...)
- plugin.SetupPluginEnv(p.settings, p.name, p.base)
- prog.Env = os.Environ()
- buf := bytes.NewBuffer(nil)
- prog.Stdout = buf
- prog.Stderr = os.Stderr
- if err := prog.Run(); err != nil {
- if eerr, ok := err.(*exec.ExitError); ok {
- os.Stderr.Write(eerr.Stderr)
- return nil, errors.Errorf("plugin %q exited with error", p.command)
- }
- return nil, err
+
+ result := schema.GetterOptionsV1{
+ URL: opts.url,
+ CertFile: opts.certFile,
+ KeyFile: opts.keyFile,
+ CAFile: opts.caFile,
+ UNTar: opts.unTar,
+ InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS,
+ PlainHTTP: opts.plainHTTP,
+ AcceptHeader: opts.acceptHeader,
+ Username: opts.username,
+ Password: opts.password,
+ PassCredentialsAll: opts.passCredentialsAll,
+ UserAgent: opts.userAgent,
+ Version: opts.version,
+ Timeout: opts.timeout,
}
- return buf, nil
+
+ return result
}
-// NewPluginGetter constructs a valid plugin getter
-func NewPluginGetter(command string, settings *cli.EnvSettings, name, base string) Constructor {
- return func(options ...Option) (Getter, error) {
- result := &pluginGetter{
- command: command,
- settings: settings,
- name: name,
- base: base,
- }
- for _, opt := range options {
- opt(&result.opts)
- }
- return result, nil
+type getterPlugin struct {
+ options []Option
+ plg plugin.Plugin
+}
+
+func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error) {
+ opts := convertOptions(g.options, options)
+
+ // TODO optimization: pass this along to Get() instead of re-parsing here
+ u, err := url.Parse(href)
+ if err != nil {
+ return nil, err
}
+
+ input := &plugin.Input{
+ Message: schema.InputMessageGetterV1{
+ Href: href,
+ Options: opts,
+ Protocol: u.Scheme,
+ },
+ // TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins?
+ //Stdout: os.Stdout,
+ }
+ output, err := g.plg.Invoke(context.Background(), input)
+ if err != nil {
+ return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err)
+ }
+
+ outputMessage, ok := output.Message.(*schema.OutputMessageGetterV1)
+ if !ok {
+ return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name)
+ }
+
+ return bytes.NewBuffer(outputMessage.Data), nil
}
diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go
index a18fa302b..1c0f5593f 100644
--- a/pkg/getter/plugingetter_test.go
+++ b/pkg/getter/plugingetter_test.go
@@ -16,18 +16,25 @@ limitations under the License.
package getter
import (
- "runtime"
- "strings"
+ "context"
+
"testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "helm.sh/helm/v4/internal/plugin"
+ "helm.sh/helm/v4/internal/plugin/schema"
- "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v4/pkg/cli"
)
func TestCollectPlugins(t *testing.T) {
env := cli.New()
env.PluginsDirectory = pluginDir
- p, err := collectPlugins(env)
+ p, err := collectGetterPlugins(env)
if err != nil {
t.Fatal(err)
}
@@ -49,53 +56,91 @@ func TestCollectPlugins(t *testing.T) {
}
}
-func TestPluginGetter(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("TODO: refactor this test to work on windows")
+func TestConvertOptions(t *testing.T) {
+ opts := convertOptions(
+ []Option{
+ WithURL("example://foo"),
+ WithAcceptHeader("Accept-Header"),
+ WithBasicAuth("username", "password"),
+ WithPassCredentialsAll(true),
+ WithUserAgent("User-agent"),
+ WithInsecureSkipVerifyTLS(true),
+ WithTLSClientConfig("certFile.pem", "keyFile.pem", "caFile.pem"),
+ WithPlainHTTP(true),
+ WithTimeout(10),
+ WithTagName("1.2.3"),
+ WithUntar(),
+ },
+ []Option{
+ WithTimeout(20),
+ },
+ )
+
+ expected := schema.GetterOptionsV1{
+ URL: "example://foo",
+ CertFile: "certFile.pem",
+ KeyFile: "keyFile.pem",
+ CAFile: "caFile.pem",
+ UNTar: true,
+ Timeout: 20,
+ InsecureSkipVerifyTLS: true,
+ PlainHTTP: true,
+ AcceptHeader: "Accept-Header",
+ Username: "username",
+ Password: "password",
+ PassCredentialsAll: true,
+ UserAgent: "User-agent",
+ Version: "1.2.3",
}
+ assert.Equal(t, expected, opts)
+}
- env := cli.New()
- env.PluginsDirectory = pluginDir
- pg := NewPluginGetter("echo", env, "test", ".")
- g, err := pg()
- if err != nil {
- t.Fatal(err)
- }
+type TestPlugin struct {
+ t *testing.T
+ dir string
+}
- data, err := g.Get("test://foo/bar")
- if err != nil {
- t.Fatal(err)
- }
+func (t *TestPlugin) Dir() string {
+ return t.dir
+}
- expect := "test://foo/bar"
- got := strings.TrimSpace(data.String())
- if got != expect {
- t.Errorf("Expected %q, got %q", expect, got)
+func (t *TestPlugin) Metadata() plugin.Metadata {
+ return plugin.Metadata{
+ Name: "fake-plugin",
+ Type: "cli/v1",
+ APIVersion: "v1",
+ Runtime: "subprocess",
+ Config: &plugin.ConfigCLI{},
+ RuntimeConfig: &plugin.RuntimeConfigSubprocess{
+ PlatformCommands: []plugin.PlatformCommand{
+ {
+ Command: "echo fake-plugin",
+ },
+ },
+ },
}
}
-func TestPluginSubCommands(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("TODO: refactor this test to work on windows")
+func (t *TestPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) {
+ // Simulate a plugin invocation
+ output := &plugin.Output{
+ Message: &schema.OutputMessageGetterV1{
+ Data: []byte("fake-plugin output"),
+ },
}
+ return output, nil
+}
- env := cli.New()
- env.PluginsDirectory = pluginDir
+var _ plugin.Plugin = (*TestPlugin)(nil)
- pg := NewPluginGetter("echo -n", env, "test", ".")
- g, err := pg()
- if err != nil {
- t.Fatal(err)
+func TestGetterPlugin(t *testing.T) {
+ gp := getterPlugin{
+ options: []Option{},
+ plg: &TestPlugin{t: t, dir: "fake/dir"},
}
- data, err := g.Get("test://foo/bar")
- if err != nil {
- t.Fatal(err)
- }
+ buf, err := gp.Get("test://example.com", WithTimeout(5*time.Second))
+ require.NoError(t, err)
- expect := " test://foo/bar"
- got := data.String()
- if got != expect {
- t.Errorf("Expected %q, got %q", expect, got)
- }
+ assert.Equal(t, "fake-plugin output", buf.String())
}
diff --git a/pkg/getter/testdata/plugins/testgetter/get.sh b/pkg/getter/testdata/plugins/testgetter/get.sh
deleted file mode 100755
index cdd992369..000000000
--- a/pkg/getter/testdata/plugins/testgetter/get.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-echo ENVIRONMENT
-env
-
-echo ""
-echo ARGUMENTS
-echo $@
diff --git a/pkg/getter/testdata/plugins/testgetter/plugin.yaml b/pkg/getter/testdata/plugins/testgetter/plugin.yaml
index d1b929e3f..ca11b95ea 100644
--- a/pkg/getter/testdata/plugins/testgetter/plugin.yaml
+++ b/pkg/getter/testdata/plugins/testgetter/plugin.yaml
@@ -1,15 +1,13 @@
name: "testgetter"
version: "0.1.0"
-usage: "Fetch a package from a test:// source"
-description: |-
- Print the environment that the plugin was given, then exit.
-
- This registers the test:// protocol.
-
-command: "$HELM_PLUGIN_DIR/get.sh"
-ignoreFlags: true
-downloaders:
-#- command: "$HELM_PLUGIN_DIR/get.sh"
-- command: "echo"
+type: getter/v1
+apiVersion: v1
+runtime: subprocess
+config:
protocols:
- "test"
+runtimeConfig:
+ protocolCommands:
+ - command: "echo"
+ protocols:
+ - "test"
diff --git a/pkg/getter/testdata/plugins/testgetter2/get.sh b/pkg/getter/testdata/plugins/testgetter2/get.sh
deleted file mode 100755
index cdd992369..000000000
--- a/pkg/getter/testdata/plugins/testgetter2/get.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/bin/bash
-
-echo ENVIRONMENT
-env
-
-echo ""
-echo ARGUMENTS
-echo $@
diff --git a/pkg/getter/testdata/plugins/testgetter2/plugin.yaml b/pkg/getter/testdata/plugins/testgetter2/plugin.yaml
index f1a527ef9..1c944a7c7 100644
--- a/pkg/getter/testdata/plugins/testgetter2/plugin.yaml
+++ b/pkg/getter/testdata/plugins/testgetter2/plugin.yaml
@@ -1,10 +1,13 @@
name: "testgetter2"
version: "0.1.0"
-usage: "Fetch a different package from a test2:// source"
-description: "Handle test2 scheme"
-command: "$HELM_PLUGIN_DIR/get.sh"
-ignoreFlags: true
-downloaders:
-- command: "echo"
+type: getter/v1
+apiVersion: v1
+runtime: subprocess
+config:
protocols:
- "test2"
+runtimeConfig:
+ protocolCommands:
+ - command: "echo"
+ protocols:
+ - "test2"
diff --git a/pkg/helmpath/home_unix_test.go b/pkg/helmpath/home_unix_test.go
index 977002549..a64c9bcd6 100644
--- a/pkg/helmpath/home_unix_test.go
+++ b/pkg/helmpath/home_unix_test.go
@@ -16,17 +16,16 @@
package helmpath
import (
- "os"
"runtime"
"testing"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
func TestHelmHome(t *testing.T) {
- os.Setenv(xdg.CacheHomeEnvVar, "/cache")
- os.Setenv(xdg.ConfigHomeEnvVar, "/config")
- os.Setenv(xdg.DataHomeEnvVar, "/data")
+ 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 {
@@ -40,7 +39,7 @@ func TestHelmHome(t *testing.T) {
isEq(t, DataPath(), "/data/helm")
// test to see if lazy-loading environment variables at runtime works
- os.Setenv(xdg.CacheHomeEnvVar, "/cache2")
+ t.Setenv(xdg.CacheHomeEnvVar, "/cache2")
isEq(t, CachePath(), "/cache2/helm")
}
diff --git a/pkg/helmpath/home_windows_test.go b/pkg/helmpath/home_windows_test.go
index 073e6347f..38fe5e4f1 100644
--- a/pkg/helmpath/home_windows_test.go
+++ b/pkg/helmpath/home_windows_test.go
@@ -19,7 +19,7 @@ import (
"os"
"testing"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
func TestHelmHome(t *testing.T) {
diff --git a/pkg/helmpath/lazypath.go b/pkg/helmpath/lazypath.go
index 22d7bf0a1..c1f868754 100644
--- a/pkg/helmpath/lazypath.go
+++ b/pkg/helmpath/lazypath.go
@@ -17,7 +17,7 @@ import (
"os"
"path/filepath"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
const (
@@ -34,7 +34,7 @@ const (
DataHomeEnvVar = "HELM_DATA_HOME"
)
-// lazypath is an lazy-loaded path buffer for the XDG base directory specification.
+// lazypath is a lazy-loaded path buffer for the XDG base directory specification.
type lazypath string
func (l lazypath) path(helmEnvVar, xdgEnvVar string, defaultFn func() string, elem ...string) string {
diff --git a/pkg/helmpath/lazypath_darwin_test.go b/pkg/helmpath/lazypath_darwin_test.go
index d0503e0e1..e3006d0d5 100644
--- a/pkg/helmpath/lazypath_darwin_test.go
+++ b/pkg/helmpath/lazypath_darwin_test.go
@@ -22,7 +22,7 @@ import (
"k8s.io/client-go/util/homedir"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
const (
@@ -40,7 +40,7 @@ func TestDataPath(t *testing.T) {
t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile))
}
- os.Setenv(xdg.DataHomeEnvVar, "/tmp")
+ t.Setenv(xdg.DataHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
@@ -58,7 +58,7 @@ func TestConfigPath(t *testing.T) {
t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile))
}
- os.Setenv(xdg.ConfigHomeEnvVar, "/tmp")
+ t.Setenv(xdg.ConfigHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
@@ -76,7 +76,7 @@ func TestCachePath(t *testing.T) {
t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile))
}
- os.Setenv(xdg.CacheHomeEnvVar, "/tmp")
+ t.Setenv(xdg.CacheHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
diff --git a/pkg/helmpath/lazypath_unix_test.go b/pkg/helmpath/lazypath_unix_test.go
index 657982b2d..4b0f2429b 100644
--- a/pkg/helmpath/lazypath_unix_test.go
+++ b/pkg/helmpath/lazypath_unix_test.go
@@ -16,13 +16,12 @@
package helmpath
import (
- "os"
"path/filepath"
"testing"
"k8s.io/client-go/util/homedir"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
const (
@@ -32,15 +31,13 @@ const (
)
func TestDataPath(t *testing.T) {
- os.Unsetenv(xdg.DataHomeEnvVar)
-
expected := filepath.Join(homedir.HomeDir(), ".local", "share", appName, testFile)
if lazy.dataPath(testFile) != expected {
t.Errorf("expected '%s', got '%s'", expected, lazy.dataPath(testFile))
}
- os.Setenv(xdg.DataHomeEnvVar, "/tmp")
+ t.Setenv(xdg.DataHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
@@ -50,15 +47,13 @@ func TestDataPath(t *testing.T) {
}
func TestConfigPath(t *testing.T) {
- os.Unsetenv(xdg.ConfigHomeEnvVar)
-
expected := filepath.Join(homedir.HomeDir(), ".config", appName, testFile)
if lazy.configPath(testFile) != expected {
t.Errorf("expected '%s', got '%s'", expected, lazy.configPath(testFile))
}
- os.Setenv(xdg.ConfigHomeEnvVar, "/tmp")
+ t.Setenv(xdg.ConfigHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
@@ -68,15 +63,13 @@ func TestConfigPath(t *testing.T) {
}
func TestCachePath(t *testing.T) {
- os.Unsetenv(xdg.CacheHomeEnvVar)
-
expected := filepath.Join(homedir.HomeDir(), ".cache", appName, testFile)
if lazy.cachePath(testFile) != expected {
t.Errorf("expected '%s', got '%s'", expected, lazy.cachePath(testFile))
}
- os.Setenv(xdg.CacheHomeEnvVar, "/tmp")
+ t.Setenv(xdg.CacheHomeEnvVar, "/tmp")
expected = filepath.Join("/tmp", appName, testFile)
diff --git a/pkg/helmpath/lazypath_windows_test.go b/pkg/helmpath/lazypath_windows_test.go
index dedfd5720..ebd95e812 100644
--- a/pkg/helmpath/lazypath_windows_test.go
+++ b/pkg/helmpath/lazypath_windows_test.go
@@ -22,7 +22,7 @@ import (
"k8s.io/client-go/util/homedir"
- "helm.sh/helm/v3/pkg/helmpath/xdg"
+ "helm.sh/helm/v4/pkg/helmpath/xdg"
)
const (
diff --git a/internal/ignore/doc.go b/pkg/ignore/doc.go
similarity index 95%
rename from internal/ignore/doc.go
rename to pkg/ignore/doc.go
index a1f0fcfc8..a66066eb2 100644
--- a/internal/ignore/doc.go
+++ b/pkg/ignore/doc.go
@@ -26,7 +26,7 @@ The formatting rules are as follows:
- Parsing is line-by-line
- Empty lines are ignored
- - Lines the begin with # (comments) will be ignored
+ - Lines that begin with # (comments) will be ignored
- Leading and trailing spaces are always ignored
- Inline comments are NOT supported ('foo* # Any foo' does not contain a comment)
- There is no support for multi-line patterns
@@ -65,4 +65,4 @@ Notable differences from .gitignore:
- The evaluation of escape sequences has not been tested for compatibility
- There is no support for '\!' as a special leading sequence.
*/
-package ignore // import "helm.sh/helm/v3/internal/ignore"
+package ignore // import "helm.sh/helm/v4/pkg/ignore"
diff --git a/internal/ignore/rules.go b/pkg/ignore/rules.go
similarity index 90%
rename from internal/ignore/rules.go
rename to pkg/ignore/rules.go
index a80923baf..3511c2d40 100644
--- a/internal/ignore/rules.go
+++ b/pkg/ignore/rules.go
@@ -19,13 +19,12 @@ package ignore
import (
"bufio"
"bytes"
+ "errors"
"io"
- "log"
+ "log/slog"
"os"
"path/filepath"
"strings"
-
- "github.com/pkg/errors"
)
// HelmIgnore default name of an ignorefile.
@@ -102,7 +101,7 @@ func (r *Rules) Ignore(path string, fi os.FileInfo) bool {
}
for _, p := range r.patterns {
if p.match == nil {
- log.Printf("ignore: no matcher supplied for %q", p.raw)
+ slog.Info("this will be ignored no matcher supplied", "patterns", p.raw)
return false
}
@@ -171,35 +170,35 @@ func (r *Rules) parseRule(rule string) error {
rule = strings.TrimSuffix(rule, "/")
}
- if strings.HasPrefix(rule, "/") {
+ if after, ok := strings.CutPrefix(rule, "/"); ok {
// Require path matches the root path.
- p.match = func(n string, fi os.FileInfo) bool {
- rule = strings.TrimPrefix(rule, "/")
+ p.match = func(n string, _ os.FileInfo) bool {
+ rule = after
ok, err := filepath.Match(rule, n)
if err != nil {
- log.Printf("Failed to compile %q: %s", rule, err)
+ slog.Error("failed to compile", "rule", rule, slog.Any("error", err))
return false
}
return ok
}
} else if strings.Contains(rule, "/") {
// require structural match.
- p.match = func(n string, fi os.FileInfo) bool {
+ p.match = func(n string, _ os.FileInfo) bool {
ok, err := filepath.Match(rule, n)
if err != nil {
- log.Printf("Failed to compile %q: %s", rule, err)
+ slog.Error("failed to compile", "rule", rule, slog.Any("error", err))
return false
}
return ok
}
} else {
- p.match = func(n string, fi os.FileInfo) bool {
+ p.match = func(n string, _ os.FileInfo) bool {
// When there is no slash in the pattern, we evaluate ONLY the
// filename.
n = filepath.Base(n)
ok, err := filepath.Match(rule, n)
if err != nil {
- log.Printf("Failed to compile %q: %s", rule, err)
+ slog.Error("failed to compile", "rule", rule, slog.Any("error", err))
return false
}
return ok
diff --git a/internal/ignore/rules_test.go b/pkg/ignore/rules_test.go
similarity index 100%
rename from internal/ignore/rules_test.go
rename to pkg/ignore/rules_test.go
diff --git a/internal/ignore/testdata/.helmignore b/pkg/ignore/testdata/.helmignore
similarity index 100%
rename from internal/ignore/testdata/.helmignore
rename to pkg/ignore/testdata/.helmignore
diff --git a/pkg/ignore/testdata/.joonix b/pkg/ignore/testdata/.joonix
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/a.txt b/pkg/ignore/testdata/a.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/cargo/a.txt b/pkg/ignore/testdata/cargo/a.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/cargo/b.txt b/pkg/ignore/testdata/cargo/b.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/cargo/c.txt b/pkg/ignore/testdata/cargo/c.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/helm.txt b/pkg/ignore/testdata/helm.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/mast/a.txt b/pkg/ignore/testdata/mast/a.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/mast/b.txt b/pkg/ignore/testdata/mast/b.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/mast/c.txt b/pkg/ignore/testdata/mast/c.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/rudder.txt b/pkg/ignore/testdata/rudder.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/templates/.dotfile b/pkg/ignore/testdata/templates/.dotfile
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/ignore/testdata/tiller.txt b/pkg/ignore/testdata/tiller.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/kube/client.go b/pkg/kube/client.go
index 0772678d1..016055392 100644
--- a/pkg/kube/client.go
+++ b/pkg/kube/client.go
@@ -14,47 +14,46 @@ 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 "helm.sh/helm/v4/pkg/kube"
import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "log/slog"
+ "net/http"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
- "time"
- jsonpatch "github.com/evanphx/json-patch"
- "github.com/pkg/errors"
- batch "k8s.io/api/batch/v1"
+ jsonpatch "github.com/evanphx/json-patch/v5"
v1 "k8s.io/api/core/v1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
- multierror "github.com/hashicorp/go-multierror"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
- "k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
- "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/jsonmergepatch"
+ "k8s.io/apimachinery/pkg/util/mergepatch"
"k8s.io/apimachinery/pkg/util/strategicpatch"
- "k8s.io/apimachinery/pkg/watch"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
- cachetools "k8s.io/client-go/tools/cache"
- watchtools "k8s.io/client-go/tools/watch"
+ "k8s.io/client-go/util/retry"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
)
@@ -78,40 +77,107 @@ type Client struct {
// needs. The smaller surface area of the interface means there is a lower
// chance of it changing.
Factory Factory
- Log func(string, ...interface{})
// Namespace allows to bypass the kubeconfig file for the choice of the namespace
Namespace string
- kubeClient *kubernetes.Clientset
+ Waiter
+ kubeClient kubernetes.Interface
}
-var addToScheme sync.Once
+type WaitStrategy string
+
+const (
+ StatusWatcherStrategy WaitStrategy = "watcher"
+ LegacyStrategy WaitStrategy = "legacy"
+ HookOnlyStrategy WaitStrategy = "hookOnly"
+)
+
+type FieldValidationDirective string
+
+const (
+ FieldValidationDirectiveIgnore FieldValidationDirective = "Ignore"
+ FieldValidationDirectiveWarn FieldValidationDirective = "Warn"
+ FieldValidationDirectiveStrict FieldValidationDirective = "Strict"
+)
+
+func init() {
+ // Add CRDs to the scheme. They are missing by default.
+ if err := apiextv1.AddToScheme(scheme.Scheme); err != nil {
+ // This should never happen.
+ panic(err)
+ }
+ if err := apiextv1beta1.AddToScheme(scheme.Scheme); err != nil {
+ panic(err)
+ }
+}
+
+func (c *Client) newStatusWatcher() (*statusWaiter, error) {
+ cfg, err := c.Factory.ToRESTConfig()
+ if err != nil {
+ return nil, err
+ }
+ dynamicClient, err := c.Factory.DynamicClient()
+ if err != nil {
+ return nil, err
+ }
+ httpClient, err := rest.HTTPClientFor(cfg)
+ if err != nil {
+ return nil, err
+ }
+ restMapper, err := apiutil.NewDynamicRESTMapper(cfg, httpClient)
+ if err != nil {
+ return nil, err
+ }
+ return &statusWaiter{
+ restMapper: restMapper,
+ client: dynamicClient,
+ }, nil
+}
+
+func (c *Client) GetWaiter(strategy WaitStrategy) (Waiter, error) {
+ switch strategy {
+ case LegacyStrategy:
+ kc, err := c.Factory.KubernetesClientSet()
+ if err != nil {
+ return nil, err
+ }
+ return &legacyWaiter{kubeClient: kc}, nil
+ case StatusWatcherStrategy:
+ return c.newStatusWatcher()
+ case HookOnlyStrategy:
+ sw, err := c.newStatusWatcher()
+ if err != nil {
+ return nil, err
+ }
+ return &hookOnlyWaiter{sw: sw}, nil
+ default:
+ return nil, errors.New("unknown wait strategy")
+ }
+}
+
+func (c *Client) SetWaiter(ws WaitStrategy) error {
+ var err error
+ c.Waiter, err = c.GetWaiter(ws)
+ if err != nil {
+ return err
+ }
+ return nil
+}
// New creates a new Client.
func New(getter genericclioptions.RESTClientGetter) *Client {
if getter == nil {
getter = genericclioptions.NewConfigFlags(true)
}
- // Add CRDs to the scheme. They are missing by default.
- addToScheme.Do(func() {
- if err := apiextv1.AddToScheme(scheme.Scheme); err != nil {
- // This should never happen.
- panic(err)
- }
- if err := apiextv1beta1.AddToScheme(scheme.Scheme); err != nil {
- panic(err)
- }
- })
- return &Client{
- Factory: cmdutil.NewFactory(getter),
- Log: nopLogger,
+ factory := cmdutil.NewFactory(getter)
+ c := &Client{
+ Factory: factory,
}
+ return c
}
-var nopLogger = func(_ string, _ ...interface{}) {}
-
// getKubeClient get or create a new KubernetesClientSet
-func (c *Client) getKubeClient() (*kubernetes.Clientset, error) {
+func (c *Client) getKubeClient() (kubernetes.Interface, error) {
var err error
if c.kubeClient == nil {
c.kubeClient, err = c.Factory.KubernetesClientSet()
@@ -124,23 +190,115 @@ func (c *Client) getKubeClient() (*kubernetes.Clientset, error) {
func (c *Client) IsReachable() error {
client, err := c.getKubeClient()
if err == genericclioptions.ErrEmptyConfig {
- // re-replace kubernetes ErrEmptyConfig error with a friendy error
+ // re-replace kubernetes ErrEmptyConfig error with a friendly error
// moar workarounds for Kubernetes API breaking.
- return errors.New("Kubernetes cluster unreachable")
+ return errors.New("kubernetes cluster unreachable")
}
if err != nil {
- return errors.Wrap(err, "Kubernetes cluster unreachable")
+ return fmt.Errorf("kubernetes cluster unreachable: %w", err)
}
- if _, err := client.ServerVersion(); err != nil {
- return errors.Wrap(err, "Kubernetes cluster unreachable")
+ if _, err := client.Discovery().ServerVersion(); err != nil {
+ return fmt.Errorf("kubernetes cluster unreachable: %w", err)
}
return nil
}
+type clientCreateOptions struct {
+ serverSideApply bool
+ forceConflicts bool
+ dryRun bool
+ fieldValidationDirective FieldValidationDirective
+}
+
+type ClientCreateOption func(*clientCreateOptions) error
+
+// ClientUpdateOptionServerSideApply 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)
+// 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")
+ }
+
+ o.serverSideApply = serverSideApply
+ o.forceConflicts = forceConflicts
+
+ return nil
+ }
+}
+
+// ClientCreateOptionDryRun requests the server to perform non-mutating operations only
+func ClientCreateOptionDryRun(dryRun bool) ClientCreateOption {
+ return func(o *clientCreateOptions) error {
+ o.dryRun = dryRun
+
+ return nil
+ }
+}
+
+// ClientCreateOptionFieldValidationDirective specifies show API operations validate object's schema
+// - For client-side apply: this is ignored
+// - For server-side apply: the directive is sent to the server to perform the validation
+//
+// Defaults to `FieldValidationDirectiveStrict`
+func ClientCreateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientCreateOption {
+ return func(o *clientCreateOptions) error {
+ o.fieldValidationDirective = fieldValidationDirective
+
+ return nil
+ }
+}
+
// Create creates Kubernetes resources specified in the resource list.
-func (c *Client) Create(resources ResourceList) (*Result, error) {
- c.Log("creating %d resource(s)", len(resources))
- if err := perform(resources, createResource); err != nil {
+func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) {
+ slog.Debug("creating resource(s)", "resources", len(resources))
+
+ createOptions := clientCreateOptions{
+ serverSideApply: true, // Default to server-side apply
+ fieldValidationDirective: FieldValidationDirectiveStrict,
+ }
+
+ errs := make([]error, 0, len(options))
+ for _, o := range options {
+ errs = append(errs, o(&createOptions))
+ }
+ if err := errors.Join(errs...); err != nil {
+ return nil, fmt.Errorf("invalid client create option(s): %w", err)
+ }
+
+ if createOptions.forceConflicts && !createOptions.serverSideApply {
+ return nil, fmt.Errorf("invalid operation: force conflicts can only be used with server-side apply")
+ }
+
+ makeCreateApplyFunc := func() func(target *resource.Info) error {
+ if createOptions.serverSideApply {
+ slog.Debug("using server-side apply for resource creation", slog.Bool("forceConflicts", createOptions.forceConflicts), slog.Bool("dryRun", createOptions.dryRun), slog.String("fieldValidationDirective", string(createOptions.fieldValidationDirective)))
+ return func(target *resource.Info) error {
+ err := patchResourceServerSide(target, createOptions.dryRun, createOptions.forceConflicts, createOptions.fieldValidationDirective)
+
+ logger := slog.With(
+ slog.String("namespace", target.Namespace),
+ slog.String("name", target.Name),
+ slog.String("gvk", target.Mapping.GroupVersionKind.String()))
+ if err != nil {
+ logger.Debug("Error patching resource", slog.Any("error", err))
+ return err
+ }
+
+ logger.Debug("Patched resource")
+
+ return nil
+ }
+ }
+
+ slog.Debug("using client-side apply for resource creation")
+ return createResource
+ }
+
+ if err := perform(resources, makeCreateApplyFunc()); err != nil {
return nil, err
}
return &Result{Created: resources}, nil
@@ -191,7 +349,7 @@ func (c *Client) Get(resources ResourceList, related bool) (map[string][]runtime
objs, err = c.getSelectRelationPod(info, objs, isTable, &podSelectors)
if err != nil {
- c.Log("Warning: get the relation pod is failed, err:%s", err.Error())
+ slog.Warn("get the relation pod is failed", slog.Any("error", err))
}
}
}
@@ -209,7 +367,7 @@ func (c *Client) getSelectRelationPod(info *resource.Info, objs map[string][]run
if info == nil {
return objs, nil
}
- c.Log("get relation pod of object: %s/%s/%s", info.Namespace, info.Mapping.GroupVersionKind.Kind, info.Name)
+ slog.Debug("get relation pod of object", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind)
selector, ok, _ := getSelectorFromObject(info.Object)
if !ok {
return objs, nil
@@ -281,45 +439,6 @@ func getResource(info *resource.Info) (runtime.Object, error) {
return obj, nil
}
-// Wait waits up to the given timeout for the specified resources to be ready.
-func (c *Client) Wait(resources ResourceList, timeout time.Duration) error {
- cs, err := c.getKubeClient()
- if err != nil {
- return err
- }
- checker := NewReadyChecker(cs, c.Log, PausedAsReady(true))
- w := waiter{
- c: checker,
- log: c.Log,
- timeout: timeout,
- }
- return w.waitForResources(resources)
-}
-
-// WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs.
-func (c *Client) WaitWithJobs(resources ResourceList, timeout time.Duration) error {
- cs, err := c.getKubeClient()
- if err != nil {
- return err
- }
- checker := NewReadyChecker(cs, c.Log, PausedAsReady(true), CheckJobs(true))
- w := waiter{
- c: checker,
- log: c.Log,
- timeout: timeout,
- }
- return w.waitForResources(resources)
-}
-
-// WaitForDelete wait up to the given timeout for the specified resources to be deleted.
-func (c *Client) WaitForDelete(resources ResourceList, timeout time.Duration) error {
- w := waiter{
- log: c.Log,
- timeout: timeout,
- }
- return w.waitForDeletedResources(resources)
-}
-
func (c *Client) namespace() string {
if c.Namespace != "" {
return c.Namespace
@@ -330,103 +449,98 @@ func (c *Client) namespace() string {
return v1.NamespaceDefault
}
-// newBuilder returns a new resource builder for structured api objects.
-func (c *Client) newBuilder() *resource.Builder {
- return c.Factory.NewBuilder().
- ContinueOnError().
- NamespaceParam(c.namespace()).
- DefaultNamespace().
- Flatten()
-}
-
-// Build validates for Kubernetes objects and returns unstructured infos.
-func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) {
- validationDirective := metav1.FieldValidationIgnore
+func determineFieldValidationDirective(validate bool) FieldValidationDirective {
if validate {
- validationDirective = metav1.FieldValidationStrict
+ return FieldValidationDirectiveStrict
}
- schema, err := c.Factory.Validator(validationDirective)
+ return FieldValidationDirectiveIgnore
+}
+
+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
}
- result, err := c.newBuilder().
+
+ builder := f.NewBuilder().
+ ContinueOnError().
+ NamespaceParam(namespace).
+ DefaultNamespace().
+ Flatten().
Unstructured().
Schema(schema).
- Stream(reader, "").
- Do().Infos()
+ Stream(reader, "")
+ if transformRequest != nil {
+ builder.TransformRequests(transformRequest)
+ }
+ result, err := builder.Do().Infos()
return result, scrubValidationError(err)
}
+// Build validates for Kubernetes objects and returns unstructured infos.
+func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) {
+ return buildResourceList(
+ c.Factory,
+ c.namespace(),
+ determineFieldValidationDirective(validate),
+ reader,
+ nil)
+}
+
// BuildTable validates for Kubernetes objects and returns unstructured infos.
// The returned kind is a Table.
func (c *Client) BuildTable(reader io.Reader, validate bool) (ResourceList, error) {
- validationDirective := metav1.FieldValidationIgnore
- if validate {
- validationDirective = metav1.FieldValidationStrict
- }
-
- schema, err := c.Factory.Validator(validationDirective)
- if err != nil {
- return nil, err
- }
- result, err := c.newBuilder().
- Unstructured().
- Schema(schema).
- Stream(reader, "").
- TransformRequests(transformRequests).
- Do().Infos()
- return result, scrubValidationError(err)
+ return buildResourceList(
+ c.Factory,
+ c.namespace(),
+ determineFieldValidationDirective(validate),
+ reader,
+ transformRequests)
}
-// Update takes the current list of objects and target list of objects and
-// creates resources that don't already exist, updates resources that have been
-// modified in the target configuration, and deletes resources from the current
-// configuration that are not present in the target configuration. If an error
-// occurs, a Result will still be returned with the error, containing all
-// resource updates, creations, and deletions that were attempted. These can be
-// used for cleanup or other logging purposes.
-func (c *Client) Update(original, target ResourceList, force bool) (*Result, error) {
- updateErrors := []string{}
+func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateApplyFunc) (*Result, error) {
+ updateErrors := []error{}
res := &Result{}
- c.Log("checking %d resources for changes", len(target))
- err := target.Visit(func(info *resource.Info, err error) error {
+ slog.Debug("checking resources for changes", "resources", len(targets))
+ err := targets.Visit(func(target *resource.Info, err error) error {
if err != nil {
return err
}
- helper := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager())
- if _, err := helper.Get(info.Namespace, info.Name); err != nil {
+ helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager())
+ if _, err := helper.Get(target.Namespace, target.Name); err != nil {
if !apierrors.IsNotFound(err) {
- return errors.Wrap(err, "could not get information about the resource")
+ return fmt.Errorf("could not get information about the resource: %w", err)
}
// Append the created resource to the results, even if something fails
- res.Created = append(res.Created, info)
+ res.Created = append(res.Created, target)
// Since the resource does not exist, create it.
- if err := createResource(info); err != nil {
- return errors.Wrap(err, "failed to create resource")
+ if err := createResource(target); err != nil {
+ return fmt.Errorf("failed to create resource: %w", err)
}
- kind := info.Mapping.GroupVersionKind.Kind
- c.Log("Created a new %s called %q in %s\n", kind, info.Name, info.Namespace)
+ kind := target.Mapping.GroupVersionKind.Kind
+ slog.Debug("created a new resource", "namespace", target.Namespace, "name", target.Name, "kind", kind)
return nil
}
- originalInfo := original.Get(info)
- if originalInfo == nil {
- kind := info.Mapping.GroupVersionKind.Kind
- return errors.Errorf("no %s with the name %q found", kind, info.Name)
+ original := originals.Get(target)
+ if original == nil {
+ kind := target.Mapping.GroupVersionKind.Kind
+ return fmt.Errorf("original object %s with the name %q not found", kind, target.Name)
}
- if err := updateResource(c, info, originalInfo.Object, force); err != nil {
- c.Log("error updating the resource %q:\n\t %v", info.Name, err)
- updateErrors = append(updateErrors, err.Error())
+ if err := updateApplyFunc(original, target); err != nil {
+ updateErrors = append(updateErrors, err)
}
+
// Because we check for errors later, append the info regardless
- res.Updated = append(res.Updated, info)
+ res.Updated = append(res.Updated, target)
return nil
})
@@ -435,26 +549,26 @@ func (c *Client) Update(original, target ResourceList, force bool) (*Result, err
case err != nil:
return res, err
case len(updateErrors) != 0:
- return res, errors.Errorf(strings.Join(updateErrors, " && "))
+ return res, joinErrors(updateErrors, " && ")
}
- for _, info := range original.Difference(target) {
- c.Log("Deleting %s %q in namespace %s...", info.Mapping.GroupVersionKind.Kind, info.Name, info.Namespace)
+ for _, info := range originals.Difference(targets) {
+ slog.Debug("deleting resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind)
if err := info.Get(); err != nil {
- c.Log("Unable to get obj %q, err: %s", info.Name, err)
+ slog.Debug("unable to get object", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
continue
}
annotations, err := metadataAccessor.Annotations(info.Object)
if err != nil {
- c.Log("Unable to get annotations on %q, err: %s", info.Name, err)
+ slog.Debug("unable to get annotations", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
}
if annotations != nil && annotations[ResourcePolicyAnno] == KeepPolicy {
- c.Log("Skipping delete of %q due to annotation [%s=%s]", info.Name, ResourcePolicyAnno, KeepPolicy)
+ slog.Debug("skipping delete due to annotation", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, "annotation", ResourcePolicyAnno, "value", KeepPolicy)
continue
}
if err := deleteResource(info, metav1.DeletePropagationBackground); err != nil {
- c.Log("Failed to delete %q, err: %s", info.ObjectName(), err)
+ slog.Debug("failed to delete resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
continue
}
res.Deleted = append(res.Deleted, info)
@@ -462,12 +576,171 @@ func (c *Client) Update(original, target ResourceList, force bool) (*Result, err
return res, nil
}
+type clientUpdateOptions struct {
+ threeWayMergeForUnstructured bool
+ serverSideApply bool
+ forceReplace bool
+ forceConflicts bool
+ dryRun bool
+ fieldValidationDirective FieldValidationDirective
+}
+
+type ClientUpdateOption func(*clientUpdateOptions) error
+
+// ClientUpdateOptionThreeWayMergeForUnstructured enables performing three-way merge for unstructured objects
+// Must not be enabled when ClientUpdateOptionServerSideApply is enabled
+func ClientUpdateOptionThreeWayMergeForUnstructured(threeWayMergeForUnstructured bool) ClientUpdateOption {
+ return func(o *clientUpdateOptions) error {
+ o.threeWayMergeForUnstructured = threeWayMergeForUnstructured
+
+ return nil
+ }
+}
+
+// ClientUpdateOptionServerSideApply enables performing object apply server-side (default)
+// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/
+// Must not be enabled when ClientUpdateOptionThreeWayMerge is enabled
+//
+// `forceConflicts` forces conflicts to be resolved (may be enabled when serverSideApply enabled only)
+// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
+func ClientUpdateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientUpdateOption {
+ return func(o *clientUpdateOptions) error {
+ if !serverSideApply && forceConflicts {
+ return fmt.Errorf("forceConflicts enabled when serverSideApply disabled")
+ }
+
+ o.serverSideApply = serverSideApply
+ o.forceConflicts = forceConflicts
+
+ return nil
+ }
+}
+
+// ClientUpdateOptionForceReplace forces objects to be replaced rather than updated via patch
+// Must not be enabled when ClientUpdateOptionForceConflicts is enabled
+func ClientUpdateOptionForceReplace(forceReplace bool) ClientUpdateOption {
+ return func(o *clientUpdateOptions) error {
+ o.forceReplace = forceReplace
+
+ return nil
+ }
+}
+
+// ClientUpdateOptionDryRun requests the server to perform non-mutating operations only
+func ClientUpdateOptionDryRun(dryRun bool) ClientUpdateOption {
+ return func(o *clientUpdateOptions) error {
+ o.dryRun = dryRun
+
+ return nil
+ }
+}
+
+// ClientUpdateOptionFieldValidationDirective specifies show API operations validate object's schema
+// - For client-side apply: this is ignored
+// - For server-side apply: the directive is sent to the server to perform the validation
+//
+// Defaults to `FieldValidationDirectiveStrict`
+func ClientUpdateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientCreateOption {
+ return func(o *clientCreateOptions) error {
+ o.fieldValidationDirective = fieldValidationDirective
+
+ return nil
+ }
+}
+
+type UpdateApplyFunc func(original, target *resource.Info) error
+
+// Update takes the current list of objects and target list of objects and
+// creates resources that don't already exist, updates resources that have been
+// modified in the target configuration, and deletes resources from the current
+// configuration that are not present in the target configuration. If an error
+// occurs, a Result will still be returned with the error, containing all
+// resource updates, creations, and deletions that were attempted. These can be
+// used for cleanup or other logging purposes.
+//
+// The default is to use server-side apply, equivalent to: `ClientUpdateOptionServerSideApply(true)`
+func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdateOption) (*Result, error) {
+ updateOptions := clientUpdateOptions{
+ serverSideApply: true, // Default to server-side apply
+ fieldValidationDirective: FieldValidationDirectiveStrict,
+ }
+
+ errs := make([]error, 0, len(options))
+ for _, o := range options {
+ errs = append(errs, o(&updateOptions))
+ }
+ if err := errors.Join(errs...); err != nil {
+ return nil, fmt.Errorf("invalid client update option(s): %w", err)
+ }
+
+ if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply {
+ return nil, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together")
+ }
+
+ if updateOptions.forceConflicts && updateOptions.forceReplace {
+ return nil, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together")
+ }
+
+ if updateOptions.serverSideApply && updateOptions.forceReplace {
+ return nil, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together")
+ }
+
+ makeUpdateApplyFunc := func() UpdateApplyFunc {
+ if updateOptions.forceReplace {
+ slog.Debug(
+ "using resource replace update strategy",
+ slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective)))
+ return func(original, target *resource.Info) error {
+ if err := replaceResource(target, updateOptions.fieldValidationDirective); err != nil {
+ slog.Debug("error replacing the resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
+ return err
+ }
+
+ originalObject := original.Object
+ kind := target.Mapping.GroupVersionKind.Kind
+ slog.Debug("replace succeeded", "name", original.Name, "initialKind", originalObject.GetObjectKind().GroupVersionKind().Kind, "kind", kind)
+
+ return nil
+ }
+ } else if updateOptions.serverSideApply {
+ slog.Debug(
+ "using server-side apply for resource update",
+ slog.Bool("forceConflicts", updateOptions.forceConflicts),
+ slog.Bool("dryRun", updateOptions.dryRun),
+ slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective)))
+ return func(_, target *resource.Info) error {
+ err := patchResourceServerSide(target, updateOptions.dryRun, updateOptions.forceConflicts, updateOptions.fieldValidationDirective)
+
+ logger := slog.With(
+ slog.String("namespace", target.Namespace),
+ slog.String("name", target.Name),
+ slog.String("gvk", target.Mapping.GroupVersionKind.String()))
+ if err != nil {
+ logger.Debug("Error patching resource", slog.Any("error", err))
+ return err
+ }
+
+ logger.Debug("Patched resource")
+
+ return nil
+ }
+ }
+
+ slog.Debug("using client-side apply for resource update", slog.Bool("threeWayMergeForUnstructured", updateOptions.threeWayMergeForUnstructured))
+ return func(original, target *resource.Info) error {
+ return patchResourceClientSide(original.Object, target, updateOptions.threeWayMergeForUnstructured)
+ }
+ }
+
+ return c.update(originals, targets, makeUpdateApplyFunc())
+}
+
// Delete deletes Kubernetes resources specified in the resources list with
// background cascade deletion. It will attempt to delete all resources even
// if one or more fail and collect any errors. All successfully deleted items
// will be returned in the `Deleted` ResourceList that is part of the result.
func (c *Client) Delete(resources ResourceList) (*Result, []error) {
- return delete(c, resources, metav1.DeletePropagationBackground)
+ return deleteResources(resources, metav1.DeletePropagationBackground)
}
// Delete deletes Kubernetes resources specified in the resources list with
@@ -475,23 +748,23 @@ func (c *Client) Delete(resources ResourceList) (*Result, []error) {
// if one or more fail and collect any errors. All successfully deleted items
// will be returned in the `Deleted` ResourceList that is part of the result.
func (c *Client) DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) {
- return delete(c, resources, policy)
+ return deleteResources(resources, policy)
}
-func delete(c *Client, resources ResourceList, propagation metav1.DeletionPropagation) (*Result, []error) {
+func deleteResources(resources ResourceList, propagation metav1.DeletionPropagation) (*Result, []error) {
var errs []error
res := &Result{}
mtx := sync.Mutex{}
- err := perform(resources, func(info *resource.Info) error {
- c.Log("Starting delete for %q %s", info.Name, info.Mapping.GroupVersionKind.Kind)
- err := deleteResource(info, propagation)
+ err := perform(resources, func(target *resource.Info) error {
+ slog.Debug("starting delete resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind)
+ err := deleteResource(target, propagation)
if err == nil || apierrors.IsNotFound(err) {
if err != nil {
- c.Log("Ignoring delete failure for %q %s: %v", info.Name, info.Mapping.GroupVersionKind, err)
+ slog.Debug("ignoring delete failure", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err))
}
mtx.Lock()
defer mtx.Unlock()
- res.Deleted = append(res.Deleted, info)
+ res.Deleted = append(res.Deleted, target)
return nil
}
mtx.Lock()
@@ -512,30 +785,35 @@ func delete(c *Client, resources ResourceList, propagation metav1.DeletionPropag
return res, nil
}
-func (c *Client) watchTimeout(t time.Duration) func(*resource.Info) error {
- return func(info *resource.Info) error {
- return c.watchUntilReady(t, info)
+// https://github.com/kubernetes/kubectl/blob/197123726db24c61aa0f78d1f0ba6e91a2ec2f35/pkg/cmd/apply/apply.go#L439
+func isIncompatibleServerError(err error) bool {
+ // 415: Unsupported media type means we're talking to a server which doesn't
+ // support server-side apply.
+ if _, ok := err.(*apierrors.StatusError); !ok {
+ // Non-StatusError means the error isn't because the server is incompatible.
+ return false
}
+ return err.(*apierrors.StatusError).Status().Code == http.StatusUnsupportedMediaType
}
-// WatchUntilReady watches the resources given and waits until it is ready.
-//
-// This method is mainly for hook implementations. It watches for a resource to
-// hit a particular milestone. The milestone depends on the Kind.
-//
-// For most kinds, it checks to see if the resource is marked as Added or Modified
-// by the Kubernetes event stream. For some kinds, it does more:
-//
-// - Jobs: A job is marked "Ready" when it has successfully completed. This is
-// ascertained by watching the Status fields in a job's output.
-// - Pods: A pod is marked "Ready" when it has successfully completed. This is
-// ascertained by watching the status.phase field in a pod's output.
-//
-// Handling for other kinds will be added as necessary.
-func (c *Client) WatchUntilReady(resources ResourceList, timeout time.Duration) error {
- // For jobs, there's also the option to do poll c.Jobs(namespace).Get():
- // https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300
- return perform(resources, c.watchTimeout(timeout))
+// 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
+ }
+
+ // When no manager is set and no calling application can be found it is unknown
+ if len(os.Args[0]) == 0 {
+ return "unknown"
+ }
+
+ // When there is an application that can be determined and no set manager
+ // use the base name. This is one of the ways Kubernetes libs handle figuring
+ // names out.
+ return filepath.Base(os.Args[0])
}
func perform(infos ResourceList, fn func(*resource.Info) error) error {
@@ -551,93 +829,89 @@ func perform(infos ResourceList, fn func(*resource.Info) error) error {
for range infos {
err := <-errs
if err != nil {
- result = multierror.Append(result, err)
+ result = errors.Join(result, err)
}
}
return result
}
-// 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
- }
-
- // When no manager is set and no calling application can be found it is unknown
- if len(os.Args[0]) == 0 {
- return "unknown"
- }
-
- // When there is an application that can be determined and no set manager
- // use the base name. This is one of the ways Kubernetes libs handle figuring
- // names out.
- return filepath.Base(os.Args[0])
-}
-
func batchPerform(infos ResourceList, fn func(*resource.Info) error, errs chan<- error) {
var kind string
var wg sync.WaitGroup
+ defer wg.Wait()
+
for _, info := range infos {
currentKind := info.Object.GetObjectKind().GroupVersionKind().Kind
if kind != currentKind {
wg.Wait()
kind = currentKind
}
+
wg.Add(1)
- go func(i *resource.Info) {
- errs <- fn(i)
+ go func(info *resource.Info) {
+ errs <- fn(info)
wg.Done()
}(info)
}
}
+var createMutex sync.Mutex
+
func createResource(info *resource.Info) error {
- obj, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).Create(info.Namespace, true, info.Object)
- if err != nil {
- return err
- }
- return info.Refresh(obj, true)
+ return retry.RetryOnConflict(
+ retry.DefaultRetry,
+ func() error {
+ createMutex.Lock()
+ defer createMutex.Unlock()
+ obj, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).Create(info.Namespace, true, info.Object)
+ if err != nil {
+ return err
+ }
+
+ return info.Refresh(obj, true)
+ })
}
func deleteResource(info *resource.Info, policy metav1.DeletionPropagation) error {
- opts := &metav1.DeleteOptions{PropagationPolicy: &policy}
- _, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).DeleteWithOptions(info.Namespace, info.Name, opts)
- return err
+ return retry.RetryOnConflict(
+ retry.DefaultRetry,
+ func() error {
+ opts := &metav1.DeleteOptions{PropagationPolicy: &policy}
+ _, err := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()).DeleteWithOptions(info.Namespace, info.Name, opts)
+ return err
+ })
}
-func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.PatchType, error) {
- oldData, err := json.Marshal(current)
+func createPatch(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) ([]byte, types.PatchType, error) {
+ oldData, err := json.Marshal(original)
if err != nil {
- return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration")
+ return nil, types.StrategicMergePatchType, fmt.Errorf("serializing current configuration: %w", err)
}
newData, err := json.Marshal(target.Object)
if err != nil {
- return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing target configuration")
+ return nil, types.StrategicMergePatchType, fmt.Errorf("serializing target configuration: %w", err)
}
// Fetch the current object for the three way merge
helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager())
currentObj, err := helper.Get(target.Namespace, target.Name)
if err != nil && !apierrors.IsNotFound(err) {
- return nil, types.StrategicMergePatchType, errors.Wrapf(err, "unable to get data for current object %s/%s", target.Namespace, target.Name)
+ return nil, types.StrategicMergePatchType, fmt.Errorf("unable to get data for current object %s/%s: %w", target.Namespace, target.Name, err)
}
// Even if currentObj is nil (because it was not found), it will marshal just fine
currentData, err := json.Marshal(currentObj)
if err != nil {
- return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing live configuration")
+ return nil, types.StrategicMergePatchType, fmt.Errorf("serializing live configuration: %w", err)
}
// Get a versioned object
versionedObject := AsVersioned(target)
- // Unstructured objects, such as CRDs, may not have an not registered error
+ // Unstructured objects, such as CRDs, may not have a not registered error
// returned from ConvertToVersion. Anything that's unstructured should
- // use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported
+ // use generic JSON merge patch. Strategic Merge Patch is not supported
// on objects like CRDs.
_, isUnstructured := versionedObject.(runtime.Unstructured)
@@ -645,6 +919,19 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P
_, isCRD := versionedObject.(*apiextv1beta1.CustomResourceDefinition)
if isUnstructured || isCRD {
+ if threeWayMergeForUnstructured {
+ // from https://github.com/kubernetes/kubectl/blob/b83b2ec7d15f286720bccf7872b5c72372cb8e80/pkg/cmd/apply/patcher.go#L129
+ preconditions := []mergepatch.PreconditionFunc{
+ mergepatch.RequireKeyUnchanged("apiVersion"),
+ mergepatch.RequireKeyUnchanged("kind"),
+ mergepatch.RequireMetadataKeyUnchanged("name"),
+ }
+ patch, err := jsonmergepatch.CreateThreeWayJSONMergePatch(oldData, newData, currentData, preconditions...)
+ if err != nil && mergepatch.IsPreconditionFailed(err) {
+ err = fmt.Errorf("%w: at least one field was changed: apiVersion, kind or name", err)
+ }
+ return patch, types.MergePatchType, err
+ }
// fall back to generic JSON merge patch
patch, err := jsonpatch.CreateMergePatch(oldData, newData)
return patch, types.MergePatchType, err
@@ -652,156 +939,139 @@ func createPatch(target *resource.Info, current runtime.Object) ([]byte, types.P
patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject)
if err != nil {
- return nil, types.StrategicMergePatchType, errors.Wrap(err, "unable to create patch metadata from object")
+ return nil, types.StrategicMergePatchType, fmt.Errorf("unable to create patch metadata from object: %w", err)
}
patch, err := strategicpatch.CreateThreeWayMergePatch(oldData, newData, currentData, patchMeta, true)
return patch, types.StrategicMergePatchType, err
}
-func updateResource(c *Client, target *resource.Info, currentObj runtime.Object, force bool) error {
- var (
- obj runtime.Object
- helper = resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager())
- kind = target.Mapping.GroupVersionKind.Kind
- )
+func replaceResource(target *resource.Info, fieldValidationDirective FieldValidationDirective) error {
- // if --force is applied, attempt to replace the existing resource with the new object.
- if force {
- var err error
- obj, err = helper.Replace(target.Namespace, target.Name, true, target.Object)
- if err != nil {
- return errors.Wrap(err, "failed to replace object")
- }
- c.Log("Replaced %q with kind %s for kind %s", target.Name, currentObj.GetObjectKind().GroupVersionKind().Kind, kind)
- } else {
- patch, patchType, err := createPatch(target, currentObj)
- if err != nil {
- return errors.Wrap(err, "failed to create patch")
- }
+ helper := resource.NewHelper(target.Client, target.Mapping).
+ WithFieldValidation(string(fieldValidationDirective)).
+ WithFieldManager(getManagedFieldsManager())
- if patch == nil || string(patch) == "{}" {
- c.Log("Looks like there are no changes for %s %q", kind, target.Name)
- // This needs to happen to make sure that Helm has the latest info from the API
- // Otherwise there will be no labels and other functions that use labels will panic
- if err := target.Get(); err != nil {
- return errors.Wrap(err, "failed to refresh resource information")
- }
- return nil
- }
- // send patch to server
- c.Log("Patch %s %q in namespace %s", kind, target.Name, target.Namespace)
- obj, err = helper.Patch(target.Namespace, target.Name, patchType, patch, nil)
- if err != nil {
- return errors.Wrapf(err, "cannot patch %q with kind %s", target.Name, kind)
- }
+ obj, err := helper.Replace(target.Namespace, target.Name, true, target.Object)
+ if err != nil {
+ return fmt.Errorf("failed to replace object: %w", err)
+ }
+
+ if err := target.Refresh(obj, true); err != nil {
+ return fmt.Errorf("failed to refresh object after replace: %w", err)
}
- target.Refresh(obj, true)
return nil
+
}
-func (c *Client) watchUntilReady(timeout time.Duration, info *resource.Info) error {
- kind := info.Mapping.GroupVersionKind.Kind
- switch kind {
- case "Job", "Pod":
- default:
- 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)
}
- c.Log("Watching for changes to %s %s with timeout of %v", kind, info.Name, timeout)
+ kind := target.Mapping.GroupVersionKind.Kind
+ if patch == nil || string(patch) == "{}" {
+ slog.Debug("no changes detected", "kind", kind, "name", target.Name)
+ // This needs to happen to make sure that Helm has the latest info from the API
+ // Otherwise there will be no labels and other functions that use labels will panic
+ if err := target.Get(); err != nil {
+ return fmt.Errorf("failed to refresh resource information: %w", err)
+ }
+ return nil
+ }
- // 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))
+ // send patch to server
+ slog.Debug("patching resource", "kind", kind, "name", target.Name, "namespace", target.Namespace)
+ helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager())
+ obj, err := helper.Patch(target.Namespace, target.Name, patchType, patch, nil)
if err != nil {
- return err
+ return fmt.Errorf("cannot patch %q with kind %s: %w", target.Name, kind, err)
}
- lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector)
-
- // What we watch for depends on the Kind.
- // - For a Job, we watch for completion.
- // - For all else, we watch until Ready.
- // In the future, we might want to add some special logic for types
- // like Ingress, Volume, etc.
-
- ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), timeout)
- defer cancel()
- _, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) {
- // Make sure the incoming object is versioned as we use unstructured
- // objects when we build manifests
- obj := convertWithMapper(e.Object, info.Mapping)
- switch e.Type {
- case watch.Added, watch.Modified:
- // For things like a secret or a config map, this is the best indicator
- // we get. We care mostly about jobs, where what we want to see is
- // the status go into a good state. For other types, like ReplicaSet
- // we don't really do anything to support these as hooks.
- c.Log("Add/Modify event for %s: %v", info.Name, e.Type)
- switch kind {
- case "Job":
- return c.waitForJob(obj, info.Name)
- case "Pod":
- return c.waitForPodSuccess(obj, info.Name)
- }
- return true, nil
- case watch.Deleted:
- c.Log("Deleted event for %s", info.Name)
- return true, nil
- case watch.Error:
- // Handle error and return with an error.
- c.Log("Error event for %s", info.Name)
- return true, errors.Errorf("failed to deploy %s", info.Name)
- default:
- return false, nil
- }
- })
- return err
+
+ target.Refresh(obj, true)
+
+ return nil
}
-// waitForJob is a helper that waits for a job to complete.
-//
-// This operates on an event returned from a watcher.
-func (c *Client) waitForJob(obj runtime.Object, name string) (bool, error) {
- o, ok := obj.(*batch.Job)
- if !ok {
- return true, errors.Errorf("expected %s to be a *batch.Job, got %T", name, obj)
+// Patch reource using server-side apply
+func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts bool, fieldValidationDirective FieldValidationDirective) error {
+ helper := resource.NewHelper(
+ target.Client,
+ target.Mapping).
+ DryRun(dryRun).
+ WithFieldManager(ManagedFieldsManager).
+ WithFieldValidation(string(fieldValidationDirective))
+
+ // Send the full object to be applied on the server side.
+ data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, target.Object)
+ if err != nil {
+ return fmt.Errorf("failed to encode object %s/%s with kind %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.Kind, err)
+ }
+ options := metav1.PatchOptions{
+ Force: &forceConflicts,
}
+ obj, err := helper.Patch(
+ target.Namespace,
+ target.Name,
+ types.ApplyPatchType,
+ data,
+ &options,
+ )
+ if err != nil {
+ if isIncompatibleServerError(err) {
+ return fmt.Errorf("server-side apply not available on the server: %v", err)
+ }
- for _, c := range o.Status.Conditions {
- if c.Type == batch.JobComplete && c.Status == "True" {
- return true, nil
- } else if c.Type == batch.JobFailed && c.Status == "True" {
- return true, errors.Errorf("job failed: %s", c.Reason)
+ if apierrors.IsConflict(err) {
+ return fmt.Errorf("conflict occurred while applying %s/%s with kind %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.Kind, err)
}
+
+ return err
}
- c.Log("%s: Jobs active: %d, jobs failed: %d, jobs succeeded: %d", name, o.Status.Active, o.Status.Failed, o.Status.Succeeded)
- return false, nil
+ return target.Refresh(obj, true)
}
-// waitForPodSuccess is a helper that waits for a pod to complete.
-//
-// This operates on an event returned from a watcher.
-func (c *Client) waitForPodSuccess(obj runtime.Object, name string) (bool, error) {
- o, ok := obj.(*v1.Pod)
- if !ok {
- return true, errors.Errorf("expected %s to be a *v1.Pod, got %T", name, obj)
+// GetPodList uses the kubernetes interface to get the list of pods filtered by listOptions
+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 podList, nil
+}
- switch o.Status.Phase {
- case v1.PodSucceeded:
- c.Log("Pod %s succeeded", o.Name)
- return true, nil
- case v1.PodFailed:
- return true, errors.Errorf("pod %s failed", o.Name)
- case v1.PodPending:
- c.Log("Pod %s pending", o.Name)
- case v1.PodRunning:
- c.Log("Pod %s running", o.Name)
+// OutputContainerLogsForPodList is a helper that outputs logs for a list of pods
+func (c *Client) OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error {
+ for _, pod := range podList.Items {
+ for _, container := range pod.Spec.Containers {
+ options := &v1.PodLogOptions{
+ Container: container.Name,
+ }
+ request := c.kubeClient.CoreV1().Pods(namespace).GetLogs(pod.Name, options)
+ err2 := copyRequestStreamToWriter(request, pod.Name, container.Name, writerFunc(namespace, pod.Name, container.Name))
+ if err2 != nil {
+ return err2
+ }
+ }
}
+ return nil
+}
- return false, nil
+func copyRequestStreamToWriter(request *rest.Request, podName, containerName string, writer io.Writer) error {
+ readCloser, err := request.Stream(context.Background())
+ if err != nil {
+ return fmt.Errorf("failed to stream pod logs for pod: %s, container: %s", podName, containerName)
+ }
+ defer readCloser.Close()
+ _, err = io.Copy(writer, readCloser)
+ if err != nil {
+ return fmt.Errorf("failed to copy IO from logs for pod: %s, container: %s", podName, containerName)
+ }
+ return nil
}
// scrubValidationError removes kubectl info from the message.
@@ -817,34 +1087,26 @@ func scrubValidationError(err error) error {
return err
}
-// WaitAndGetCompletedPodPhase waits up to a timeout until a pod enters a completed phase
-// and returns said phase (PodSucceeded or PodFailed qualify).
-func (c *Client) WaitAndGetCompletedPodPhase(name string, timeout time.Duration) (v1.PodPhase, error) {
- client, err := c.getKubeClient()
- if err != nil {
- return v1.PodUnknown, err
- }
- to := int64(timeout)
- watcher, err := client.CoreV1().Pods(c.namespace()).Watch(context.Background(), metav1.ListOptions{
- FieldSelector: fmt.Sprintf("metadata.name=%s", name),
- TimeoutSeconds: &to,
- })
- if err != nil {
- return v1.PodUnknown, err
+type joinedErrors struct {
+ errs []error
+ sep string
+}
+
+func joinErrors(errs []error, sep string) error {
+ return &joinedErrors{
+ errs: errs,
+ sep: sep,
}
+}
- for event := range watcher.ResultChan() {
- p, ok := event.Object.(*v1.Pod)
- if !ok {
- return v1.PodUnknown, fmt.Errorf("%s not a pod", name)
- }
- switch p.Status.Phase {
- case v1.PodFailed:
- return v1.PodFailed, nil
- case v1.PodSucceeded:
- return v1.PodSucceeded, nil
- }
+func (e *joinedErrors) Error() string {
+ errs := make([]string, 0, len(e.errs))
+ for _, err := range e.errs {
+ errs = append(errs, err.Error())
}
+ return strings.Join(errs, e.sep)
+}
- return v1.PodUnknown, err
+func (e *joinedErrors) Unwrap() []error {
+ return e.errs
}
diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go
index 55aa5d8ed..5060a5fc2 100644
--- a/pkg/kube/client_test.go
+++ b/pkg/kube/client_test.go
@@ -18,22 +18,40 @@ package kube
import (
"bytes"
+ "errors"
+ "fmt"
"io"
"net/http"
"strings"
+ "sync"
"testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
v1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ jsonserializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/kubernetes"
+ k8sfake "k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest/fake"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
)
-var unstructuredSerializer = resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer
-var codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
+var (
+ unstructuredSerializer = resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer
+ codec = scheme.Codecs.LegacyCodec(scheme.Scheme.PrioritizedVersionsAllGroups()...)
+)
func objBody(obj runtime.Object) io.ReadCloser {
return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj))))
@@ -90,108 +108,222 @@ func newResponse(code int, obj runtime.Object) (*http.Response, error) {
return &http.Response{StatusCode: code, Header: header, Body: body}, nil
}
+func newResponseJSON(code int, json []byte) (*http.Response, error) {
+ header := http.Header{}
+ header.Set("Content-Type", runtime.ContentTypeJSON)
+ body := io.NopCloser(bytes.NewReader(json))
+ return &http.Response{StatusCode: code, Header: header, Body: body}, nil
+}
+
func newTestClient(t *testing.T) *Client {
+ t.Helper()
testFactory := cmdtesting.NewTestFactory()
t.Cleanup(testFactory.Cleanup)
return &Client{
- Factory: testFactory.WithNamespace("default"),
- Log: nopLogger,
+ Factory: testFactory.WithNamespace(v1.NamespaceDefault),
}
}
-func TestUpdate(t *testing.T) {
- listA := newPodList("starfish", "otter", "squid")
- listB := newPodList("starfish", "otter", "dolphin")
- listC := newPodList("starfish", "otter", "dolphin")
- listB.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
- listC.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+type RequestResponseAction struct {
+ Request http.Request
+ Response http.Response
+ Error error
+}
- var actions []string
+type RoundTripperTestFunc func(previous []RequestResponseAction, req *http.Request) (*http.Response, error)
- c := newTestClient(t)
- c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{
- NegotiatedSerializer: unstructuredSerializer,
- Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
- p, m := req.URL.Path, req.Method
- actions = append(actions, p+":"+m)
- t.Logf("got request %s %s", p, m)
- switch {
- case p == "/namespaces/default/pods/starfish" && m == "GET":
- return newResponse(200, &listA.Items[0])
- case p == "/namespaces/default/pods/otter" && m == "GET":
- return newResponse(200, &listA.Items[1])
- case p == "/namespaces/default/pods/otter" && m == "PATCH":
- data, err := io.ReadAll(req.Body)
- if err != nil {
- t.Fatalf("could not dump request: %s", err)
- }
- req.Body.Close()
- expected := `{}`
- if string(data) != expected {
- t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data))
- }
- return newResponse(200, &listB.Items[0])
- case p == "/namespaces/default/pods/dolphin" && m == "GET":
- return newResponse(404, notFoundBody())
- case p == "/namespaces/default/pods/starfish" && m == "PATCH":
- data, err := io.ReadAll(req.Body)
- if err != nil {
- t.Fatalf("could not dump request: %s", err)
- }
- req.Body.Close()
- expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}`
- if string(data) != expected {
- t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data))
- }
- return newResponse(200, &listB.Items[0])
- case p == "/namespaces/default/pods" && m == "POST":
- return newResponse(200, &listB.Items[1])
- case p == "/namespaces/default/pods/squid" && m == "DELETE":
- return newResponse(200, &listB.Items[1])
- case p == "/namespaces/default/pods/squid" && m == "GET":
- return newResponse(200, &listB.Items[2])
- default:
- t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
- return nil, nil
- }
- }),
+func NewRequestResponseLogClient(t *testing.T, cb RoundTripperTestFunc) RequestResponseLogClient {
+ t.Helper()
+ return RequestResponseLogClient{
+ t: t,
+ cb: cb,
}
- first, err := c.Build(objBody(&listA), false)
- if err != nil {
- t.Fatal(err)
+}
+
+// RequestResponseLogClient is a test client that logs requests and responses
+// Satifying http.RoundTripper interface, it can be used to mock HTTP requests in tests.
+// Forwarding requests to a callback function (cb) that can be used to simulate server responses.
+type RequestResponseLogClient struct {
+ t *testing.T
+ cb RoundTripperTestFunc
+ actionsLock sync.Mutex
+ Actions []RequestResponseAction
+}
+
+func (r *RequestResponseLogClient) Do(req *http.Request) (*http.Response, error) {
+ t := r.t
+ t.Helper()
+
+ readBodyBytes := func(body io.ReadCloser) []byte {
+ if body == nil {
+ return []byte{}
+ }
+
+ defer body.Close()
+ bodyBytes, err := io.ReadAll(body)
+ require.NoError(t, err)
+
+ return bodyBytes
}
- second, err := c.Build(objBody(&listB), false)
- if err != nil {
- t.Fatal(err)
+
+ reqBytes := readBodyBytes(req.Body)
+
+ t.Logf("Request: %s %s %s", req.Method, req.URL.String(), reqBytes)
+ if req.Body != nil {
+ req.Body = io.NopCloser(bytes.NewReader(reqBytes))
}
- result, err := c.Update(first, second, false)
- if err != nil {
- t.Fatal(err)
+ resp, err := r.cb(r.Actions, req)
+
+ respBytes := readBodyBytes(resp.Body)
+ t.Logf("Response: %d %s", resp.StatusCode, string(respBytes))
+ if resp.Body != nil {
+ resp.Body = io.NopCloser(bytes.NewReader(respBytes))
}
- if len(result.Created) != 1 {
- t.Errorf("expected 1 resource created, got %d", len(result.Created))
+ r.actionsLock.Lock()
+ defer r.actionsLock.Unlock()
+ r.Actions = append(r.Actions, RequestResponseAction{
+ Request: *req,
+ Response: *resp,
+ Error: err,
+ })
+
+ return resp, err
+}
+
+func TestCreate(t *testing.T) {
+ // Note: c.Create with the fake client can currently only test creation of a single pod/object in the same list. When testing
+ // with more than one pod, c.Create will run into a data race as it calls perform->batchPerform which performs creation
+ // in batches. The race is something in the fake client itself in `func (c *RESTClient) do(...)`
+ // when it stores the req: c.Req = req and cannot (?) be fixed easily.
+
+ type testCase struct {
+ Name string
+ Pods v1.PodList
+ Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error)
+ ServerSideApply bool
+ ExpectedActions []string
+ ExpectedErrorContains string
+ }
+
+ testCases := map[string]testCase{
+ "Create success (client-side apply)": {
+ Pods: newPodList("starfish"),
+ ServerSideApply: false,
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, _ *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ if len(previous) < 2 { // simulate a conflict
+ return newResponseJSON(http.StatusConflict, resourceQuotaConflict)
+ }
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ ExpectedActions: []string{
+ "/namespaces/default/pods:POST",
+ "/namespaces/default/pods:POST",
+ "/namespaces/default/pods:POST",
+ },
+ },
+ "Create success (server-side apply)": {
+ Pods: newPodList("whale"),
+ ServerSideApply: true,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ ExpectedActions: []string{
+ "/namespaces/default/pods/whale:PATCH",
+ },
+ },
+ "Create fail: incompatible server (server-side apply)": {
+ Pods: newPodList("lobster"),
+ ServerSideApply: true,
+ Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return &http.Response{
+ StatusCode: http.StatusUnsupportedMediaType,
+ Request: req,
+ }, nil
+ },
+ ExpectedErrorContains: "server-side apply not available on the server:",
+ ExpectedActions: []string{
+ "/namespaces/default/pods/lobster:PATCH",
+ },
+ },
+ "Create fail: quota (server-side apply)": {
+ Pods: newPodList("dolphin"),
+ ServerSideApply: true,
+ Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return newResponseJSON(http.StatusConflict, resourceQuotaConflict)
+ },
+ ExpectedErrorContains: "Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; " +
+ "please apply your changes to the latest version and try again",
+ ExpectedActions: []string{
+ "/namespaces/default/pods/dolphin:PATCH",
+ },
+ },
}
- if len(result.Updated) != 2 {
- t.Errorf("expected 2 resource updated, got %d", len(result.Updated))
+
+ 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)
+ })
+
+ c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(client.Do),
+ }
+
+ list, err := c.Build(objBody(&tc.Pods), false)
+ require.NoError(t, err)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := c.Create(
+ list,
+ ClientCreateOptionServerSideApply(tc.ServerSideApply, false))
+ if tc.ExpectedErrorContains != "" {
+ require.ErrorContains(t, err, tc.ExpectedErrorContains)
+ } else {
+ require.NoError(t, err)
+
+ // See note above about limitations in supporting more than a single object
+ assert.Len(t, result.Created, 1, "expected 1 object created, got %d", len(result.Created))
+ }
+
+ actions := []string{}
+ for _, action := range client.Actions {
+ path, method := action.Request.URL.Path, action.Request.Method
+ actions = append(actions, path+":"+method)
+ }
+
+ assert.Equal(t, tc.ExpectedActions, actions)
+
+ })
}
- if len(result.Deleted) != 1 {
- t.Errorf("expected 1 resource deleted, got %d", len(result.Deleted))
+}
+
+func TestUpdate(t *testing.T) {
+ type testCase struct {
+ OriginalPods v1.PodList
+ TargetPods v1.PodList
+ ThreeWayMergeForUnstructured bool
+ ServerSideApply bool
+ ExpectedActions []string
}
- // TODO: Find a way to test methods that use Client Set
- // Test with a wait
- // if err := c.Update("test", objBody(codec, &listB), objBody(codec, &listC), false, 300, true); err != nil {
- // t.Fatal(err)
- // }
- // Test with a wait should fail
- // TODO: A way to make this not based off of an extremely short timeout?
- // if err := c.Update("test", objBody(codec, &listC), objBody(codec, &listA), false, 2, true); err != nil {
- // t.Fatal(err)
- // }
- expectedActions := []string{
+ expectedActionsClientSideApply := []string{
"/namespaces/default/pods/starfish:GET",
"/namespaces/default/pods/starfish:GET",
"/namespaces/default/pods/starfish:PATCH",
@@ -199,17 +331,157 @@ func TestUpdate(t *testing.T) {
"/namespaces/default/pods/otter:GET",
"/namespaces/default/pods/otter:GET",
"/namespaces/default/pods/dolphin:GET",
- "/namespaces/default/pods:POST",
+ "/namespaces/default/pods:POST", // create dolphin
+ "/namespaces/default/pods:POST", // retry due to 409
+ "/namespaces/default/pods:POST", // retry due to 409
+ "/namespaces/default/pods/squid:GET",
+ "/namespaces/default/pods/squid:DELETE",
+ }
+
+ expectedActionsServerSideApply := []string{
+ "/namespaces/default/pods/starfish:GET",
+ "/namespaces/default/pods/starfish:PATCH",
+ "/namespaces/default/pods/otter:GET",
+ "/namespaces/default/pods/otter:PATCH",
+ "/namespaces/default/pods/dolphin:GET",
+ "/namespaces/default/pods:POST", // create dolphin
+ "/namespaces/default/pods:POST", // retry due to 409
+ "/namespaces/default/pods:POST", // retry due to 409
"/namespaces/default/pods/squid:GET",
"/namespaces/default/pods/squid:DELETE",
}
- if len(expectedActions) != len(actions) {
- t.Fatalf("unexpected number of requests, expected %d, got %d", len(expectedActions), len(actions))
+
+ testCases := map[string]testCase{
+ "client-side apply": {
+ OriginalPods: newPodList("starfish", "otter", "squid"),
+ TargetPods: func() v1.PodList {
+ listTarget := newPodList("starfish", "otter", "dolphin")
+ listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return listTarget
+ }(),
+ ThreeWayMergeForUnstructured: false,
+ ServerSideApply: false,
+ ExpectedActions: expectedActionsClientSideApply,
+ },
+ "client-side apply (three-way merge for unstructured)": {
+ OriginalPods: newPodList("starfish", "otter", "squid"),
+ TargetPods: func() v1.PodList {
+ listTarget := newPodList("starfish", "otter", "dolphin")
+ listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return listTarget
+ }(),
+ ThreeWayMergeForUnstructured: true,
+ ServerSideApply: false,
+ ExpectedActions: expectedActionsClientSideApply,
+ },
+ "serverSideApply": {
+ OriginalPods: newPodList("starfish", "otter", "squid"),
+ TargetPods: func() v1.PodList {
+ listTarget := newPodList("starfish", "otter", "dolphin")
+ listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return listTarget
+ }(),
+ ThreeWayMergeForUnstructured: false,
+ ServerSideApply: true,
+ ExpectedActions: expectedActionsServerSideApply,
+ },
}
- for k, v := range expectedActions {
- if actions[k] != v {
- t.Errorf("expected %s request got %s", v, actions[k])
- }
+
+ c := newTestClient(t)
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+
+ listOriginal := tc.OriginalPods
+ listTarget := tc.TargetPods
+
+ iterationCounter := 0
+ cb := func(_ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ p, m := req.URL.Path, req.Method
+
+ switch {
+ case p == "/namespaces/default/pods/starfish" && m == http.MethodGet:
+ return newResponse(http.StatusOK, &listOriginal.Items[0])
+ case p == "/namespaces/default/pods/otter" && m == http.MethodGet:
+ return newResponse(http.StatusOK, &listOriginal.Items[1])
+ case p == "/namespaces/default/pods/otter" && m == http.MethodPatch:
+ if !tc.ServerSideApply {
+ defer req.Body.Close()
+ data, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+
+ assert.Equal(t, `{}`, string(data))
+ }
+
+ return newResponse(http.StatusOK, &listTarget.Items[0])
+ case p == "/namespaces/default/pods/dolphin" && m == http.MethodGet:
+ return newResponse(http.StatusNotFound, notFoundBody())
+ case p == "/namespaces/default/pods/starfish" && m == http.MethodPatch:
+ if !tc.ServerSideApply {
+ // Ensure client-side apply specifies correct patch
+ defer req.Body.Close()
+ data, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+
+ expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}`
+ assert.Equal(t, expected, string(data))
+ }
+
+ return newResponse(http.StatusOK, &listTarget.Items[0])
+ case p == "/namespaces/default/pods" && m == http.MethodPost:
+ if iterationCounter < 2 {
+ iterationCounter++
+ return newResponseJSON(http.StatusConflict, resourceQuotaConflict)
+ }
+
+ return newResponse(http.StatusOK, &listTarget.Items[1])
+ case p == "/namespaces/default/pods/squid" && m == http.MethodDelete:
+ return newResponse(http.StatusOK, &listTarget.Items[1])
+ case p == "/namespaces/default/pods/squid" && m == http.MethodGet:
+ return newResponse(http.StatusOK, &listTarget.Items[2])
+ default:
+ }
+
+ t.Fail()
+ return nil, nil
+ }
+
+ client := NewRequestResponseLogClient(t, cb)
+
+ c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(client.Do),
+ }
+
+ first, err := c.Build(objBody(&listOriginal), false)
+ require.NoError(t, err)
+
+ second, err := c.Build(objBody(&listTarget), false)
+ require.NoError(t, err)
+
+ result, err := c.Update(
+ first,
+ second,
+ ClientUpdateOptionThreeWayMergeForUnstructured(tc.ThreeWayMergeForUnstructured),
+ ClientUpdateOptionForceReplace(false),
+ ClientUpdateOptionServerSideApply(tc.ServerSideApply, false))
+ require.NoError(t, err)
+
+ 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))
+ assert.Len(t, result.Deleted, 1, "expected 1 resource deleted, got %d", len(result.Deleted))
+
+ actions := []string{}
+ for _, action := range client.Actions {
+ path, method := action.Request.URL.Path, action.Request.Method
+ actions = append(actions, path+":"+method)
+ }
+
+ assert.Equal(t, tc.ExpectedActions, actions)
+ })
}
}
@@ -341,6 +613,219 @@ func TestPerform(t *testing.T) {
}
}
+func TestWait(t *testing.T) {
+ podList := newPodList("starfish", "otter", "squid")
+
+ var created *time.Time
+
+ c := newTestClient(t)
+ c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
+ p, m := req.URL.Path, req.Method
+ t.Logf("got request %s %s", p, m)
+ switch {
+ case p == "/api/v1/namespaces/default/pods/starfish" && m == http.MethodGet:
+ pod := &podList.Items[0]
+ if created != nil && time.Since(*created) >= time.Second*5 {
+ pod.Status.Conditions = []v1.PodCondition{
+ {
+ Type: v1.PodReady,
+ Status: v1.ConditionTrue,
+ },
+ }
+ }
+ return newResponse(http.StatusOK, pod)
+ case p == "/api/v1/namespaces/default/pods/otter" && m == http.MethodGet:
+ pod := &podList.Items[1]
+ if created != nil && time.Since(*created) >= time.Second*5 {
+ pod.Status.Conditions = []v1.PodCondition{
+ {
+ Type: v1.PodReady,
+ Status: v1.ConditionTrue,
+ },
+ }
+ }
+ return newResponse(http.StatusOK, pod)
+ case p == "/api/v1/namespaces/default/pods/squid" && m == http.MethodGet:
+ pod := &podList.Items[2]
+ if created != nil && time.Since(*created) >= time.Second*5 {
+ pod.Status.Conditions = []v1.PodCondition{
+ {
+ Type: v1.PodReady,
+ Status: v1.ConditionTrue,
+ },
+ }
+ }
+ return newResponse(http.StatusOK, pod)
+ case p == "/namespaces/default/pods" && m == http.MethodPost:
+ resources, err := c.Build(req.Body, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ created = &now
+ return newResponse(http.StatusOK, resources[0].Object)
+ default:
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
+ return nil, nil
+ }
+ }),
+ }
+ var err error
+ c.Waiter, err = c.GetWaiter(LegacyStrategy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resources, err := c.Build(objBody(&podList), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := c.Create(
+ resources,
+ ClientCreateOptionServerSideApply(false, false))
+
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Created) != 3 {
+ t.Errorf("expected 3 resource created, got %d", len(result.Created))
+ }
+
+ if err := c.Wait(resources, time.Second*30); err != nil {
+ t.Errorf("expected wait without error, got %s", err)
+ }
+
+ if time.Since(*created) < time.Second*5 {
+ t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*created))
+ }
+}
+
+func TestWaitJob(t *testing.T) {
+ job := newJob("starfish", 0, intToInt32(1), 0, 0)
+
+ var created *time.Time
+
+ c := newTestClient(t)
+ c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
+ p, m := req.URL.Path, req.Method
+ t.Logf("got request %s %s", p, m)
+ switch {
+ case p == "/apis/batch/v1/namespaces/default/jobs/starfish" && m == http.MethodGet:
+ if created != nil && time.Since(*created) >= time.Second*5 {
+ job.Status.Succeeded = 1
+ }
+ return newResponse(http.StatusOK, job)
+ case p == "/namespaces/default/jobs" && m == http.MethodPost:
+ resources, err := c.Build(req.Body, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ created = &now
+ return newResponse(http.StatusOK, resources[0].Object)
+ default:
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
+ return nil, nil
+ }
+ }),
+ }
+ var err error
+ c.Waiter, err = c.GetWaiter(LegacyStrategy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resources, err := c.Build(objBody(job), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := c.Create(
+ resources,
+ ClientCreateOptionServerSideApply(false, false))
+
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Created) != 1 {
+ t.Errorf("expected 1 resource created, got %d", len(result.Created))
+ }
+
+ if err := c.WaitWithJobs(resources, time.Second*30); err != nil {
+ t.Errorf("expected wait without error, got %s", err)
+ }
+
+ if time.Since(*created) < time.Second*5 {
+ t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*created))
+ }
+}
+
+func TestWaitDelete(t *testing.T) {
+ pod := newPod("starfish")
+
+ var deleted *time.Time
+
+ c := newTestClient(t)
+ c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
+ p, m := req.URL.Path, req.Method
+ t.Logf("got request %s %s", p, m)
+ switch {
+ case p == "/namespaces/default/pods/starfish" && m == http.MethodGet:
+ if deleted != nil && time.Since(*deleted) >= time.Second*5 {
+ return newResponse(http.StatusNotFound, notFoundBody())
+ }
+ return newResponse(http.StatusOK, &pod)
+ case p == "/namespaces/default/pods/starfish" && m == http.MethodDelete:
+ now := time.Now()
+ deleted = &now
+ return newResponse(http.StatusOK, &pod)
+ case p == "/namespaces/default/pods" && m == http.MethodPost:
+ resources, err := c.Build(req.Body, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return newResponse(http.StatusOK, resources[0].Object)
+ default:
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path)
+ return nil, nil
+ }
+ }),
+ }
+ var err error
+ c.Waiter, err = c.GetWaiter(LegacyStrategy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resources, err := c.Build(objBody(&pod), false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err := c.Create(
+ resources,
+ ClientCreateOptionServerSideApply(false, false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(result.Created) != 1 {
+ t.Errorf("expected 1 resource created, got %d", len(result.Created))
+ }
+ if _, err := c.Delete(resources); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := c.WaitForDelete(resources, time.Second*30); err != nil {
+ t.Errorf("expected wait without error, got %s", err)
+ }
+
+ if time.Since(*deleted) < time.Second*5 {
+ t.Errorf("expected to wait at least 5 seconds before ready status was detected, but got %s", time.Since(*deleted))
+ }
+}
+
func TestReal(t *testing.T) {
t.Skip("This is a live test, comment this line to run")
c := New(nil)
@@ -381,6 +866,37 @@ func TestReal(t *testing.T) {
}
}
+func TestGetPodList(t *testing.T) {
+ namespace := "some-namespace"
+ names := []string{"dave", "jimmy"}
+ var responsePodList v1.PodList
+ for _, name := range names {
+ responsePodList.Items = append(responsePodList.Items, newPodWithStatus(name, v1.PodStatus{}, namespace))
+ }
+
+ kubeClient := k8sfake.NewSimpleClientset(&responsePodList)
+ c := Client{Namespace: namespace, kubeClient: kubeClient}
+
+ podList, err := c.GetPodList(namespace, metav1.ListOptions{})
+ clientAssertions := assert.New(t)
+ clientAssertions.NoError(err)
+ clientAssertions.Equal(&responsePodList, podList)
+}
+
+func TestOutputContainerLogsForPodList(t *testing.T) {
+ namespace := "some-namespace"
+ somePodList := newPodList("jimmy", "three", "structs")
+
+ kubeClient := k8sfake.NewSimpleClientset(&somePodList)
+ c := Client{Namespace: namespace, kubeClient: kubeClient}
+ outBuffer := &bytes.Buffer{}
+ outBufferFunc := func(_, _, _ string) io.Writer { return outBuffer }
+ err := c.OutputContainerLogsForPodList(&somePodList, namespace, outBufferFunc)
+ clientAssertions := assert.New(t)
+ clientAssertions.NoError(err)
+ clientAssertions.Equal("fake logsfake logsfake logs", outBuffer.String())
+}
+
const testServiceManifest = `
kind: Service
apiVersion: v1
@@ -451,11 +967,11 @@ spec:
apiVersion: v1
kind: Service
metadata:
- name: redis-slave
+ name: redis-replica
labels:
app: redis
tier: backend
- role: slave
+ role: replica
spec:
ports:
# the port that this service should serve on
@@ -463,24 +979,24 @@ spec:
selector:
app: redis
tier: backend
- role: slave
+ role: replica
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
- name: redis-slave
+ name: redis-replica
spec:
replicas: 2
template:
metadata:
labels:
app: redis
- role: slave
+ role: replica
tier: backend
spec:
containers:
- - name: slave
- image: gcr.io/google_samples/gb-redisslave:v1
+ - name: replica
+ image: gcr.io/google_samples/gb-redisreplica:v1
resources:
requests:
cpu: 100m
@@ -558,3 +1074,678 @@ spec:
ports:
- containerPort: 80
`
+
+var resourceQuotaConflict = []byte(`
+{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; please apply your changes to the latest version and try again","reason":"Conflict","details":{"name":"quota","kind":"resourcequotas"},"code":409}`)
+
+type createPatchTestCase struct {
+ name string
+
+ // The target state.
+ target *unstructured.Unstructured
+ // The state as it exists in the release.
+ original *unstructured.Unstructured
+ // The actual state as it exists in the cluster.
+ actual *unstructured.Unstructured
+
+ threeWayMergeForUnstructured bool
+ // The patch is supposed to transfer the current state to the target state,
+ // thereby preserving the actual state, wherever possible.
+ expectedPatch string
+ expectedPatchType types.PatchType
+}
+
+func (c createPatchTestCase) run(t *testing.T) {
+ scheme := runtime.NewScheme()
+ v1.AddToScheme(scheme)
+ encoder := jsonserializer.NewSerializerWithOptions(
+ jsonserializer.DefaultMetaFactory, scheme, scheme, jsonserializer.SerializerOptions{
+ Yaml: false, Pretty: false, Strict: true,
+ },
+ )
+ objBody := func(obj runtime.Object) io.ReadCloser {
+ return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(encoder, obj))))
+ }
+ header := make(http.Header)
+ header.Set("Content-Type", runtime.ContentTypeJSON)
+ restClient := &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Body: objBody(c.actual),
+ Header: header,
+ },
+ }
+
+ targetInfo := &resource.Info{
+ Client: restClient,
+ Namespace: "default",
+ Name: "test-obj",
+ Object: c.target,
+ Mapping: &meta.RESTMapping{
+ Resource: schema.GroupVersionResource{
+ Group: "crd.com",
+ Version: "v1",
+ Resource: "datas",
+ },
+ Scope: meta.RESTScopeNamespace,
+ },
+ }
+
+ patch, patchType, err := createPatch(c.original, targetInfo, c.threeWayMergeForUnstructured)
+ if err != nil {
+ t.Fatalf("Failed to create patch: %v", err)
+ }
+
+ if c.expectedPatch != string(patch) {
+ t.Errorf("Unexpected patch.\nTarget:\n%s\nOriginal:\n%s\nActual:\n%s\n\nExpected:\n%s\nGot:\n%s",
+ c.target,
+ c.original,
+ c.actual,
+ c.expectedPatch,
+ string(patch),
+ )
+ }
+
+ if patchType != types.MergePatchType {
+ t.Errorf("Expected patch type %s, got %s", types.MergePatchType, patchType)
+ }
+}
+
+func newTestCustomResourceData(metadata map[string]string, spec map[string]interface{}) *unstructured.Unstructured {
+ if metadata == nil {
+ metadata = make(map[string]string)
+ }
+ if _, ok := metadata["name"]; !ok {
+ metadata["name"] = "test-obj"
+ }
+ if _, ok := metadata["namespace"]; !ok {
+ metadata["namespace"] = "default"
+ }
+ o := map[string]interface{}{
+ "apiVersion": "crd.com/v1",
+ "kind": "Data",
+ "metadata": metadata,
+ }
+ if len(spec) > 0 {
+ o["spec"] = spec
+ }
+ return &unstructured.Unstructured{
+ Object: o,
+ }
+}
+
+func TestCreatePatchCustomResourceMetadata(t *testing.T) {
+ target := newTestCustomResourceData(map[string]string{
+ "meta.helm.sh/release-name": "foo-simple",
+ "meta.helm.sh/release-namespace": "default",
+ "objectset.rio.cattle.io/id": "default-foo-simple",
+ }, nil)
+ testCase := createPatchTestCase{
+ name: "take ownership of resource",
+ target: target,
+ original: target,
+ actual: newTestCustomResourceData(nil, map[string]interface{}{
+ "color": "red",
+ }),
+ threeWayMergeForUnstructured: true,
+ expectedPatch: `{"metadata":{"meta.helm.sh/release-name":"foo-simple","meta.helm.sh/release-namespace":"default","objectset.rio.cattle.io/id":"default-foo-simple"}}`,
+ expectedPatchType: types.MergePatchType,
+ }
+ t.Run(testCase.name, testCase.run)
+
+ // Previous behavior.
+ testCase.threeWayMergeForUnstructured = false
+ testCase.expectedPatch = `{}`
+ t.Run(testCase.name, testCase.run)
+}
+
+func TestCreatePatchCustomResourceSpec(t *testing.T) {
+ target := newTestCustomResourceData(nil, map[string]interface{}{
+ "color": "red",
+ "size": "large",
+ })
+ testCase := createPatchTestCase{
+ name: "merge with spec of existing custom resource",
+ target: target,
+ original: target,
+ actual: newTestCustomResourceData(nil, map[string]interface{}{
+ "color": "red",
+ "weight": "heavy",
+ }),
+ threeWayMergeForUnstructured: true,
+ expectedPatch: `{"spec":{"size":"large"}}`,
+ expectedPatchType: types.MergePatchType,
+ }
+ t.Run(testCase.name, testCase.run)
+
+ // Previous behavior.
+ testCase.threeWayMergeForUnstructured = false
+ testCase.expectedPatch = `{}`
+ t.Run(testCase.name, testCase.run)
+}
+
+type errorFactory struct {
+ *cmdtesting.TestFactory
+ err error
+}
+
+func (f *errorFactory) KubernetesClientSet() (*kubernetes.Clientset, error) {
+ return nil, f.err
+}
+
+func newTestClientWithDiscoveryError(t *testing.T, err error) *Client {
+ t.Helper()
+ c := newTestClient(t)
+ c.Factory.(*cmdtesting.TestFactory).Client = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Path == "/version" {
+ return nil, err
+ }
+ resp, respErr := newResponse(http.StatusOK, &v1.Pod{})
+ return resp, respErr
+ }),
+ }
+ return c
+}
+
+func TestIsReachable(t *testing.T) {
+ const (
+ expectedUnreachableMsg = "kubernetes cluster unreachable"
+ )
+ tests := []struct {
+ name string
+ setupClient func(*testing.T) *Client
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "successful reachability test",
+ setupClient: func(t *testing.T) *Client {
+ t.Helper()
+ client := newTestClient(t)
+ client.kubeClient = k8sfake.NewSimpleClientset()
+ return client
+ },
+ expectError: false,
+ },
+ {
+ name: "client creation error with ErrEmptyConfig",
+ setupClient: func(t *testing.T) *Client {
+ t.Helper()
+ client := newTestClient(t)
+ client.Factory = &errorFactory{err: genericclioptions.ErrEmptyConfig}
+ return client
+ },
+ expectError: true,
+ errorContains: expectedUnreachableMsg,
+ },
+ {
+ name: "client creation error with general error",
+ setupClient: func(t *testing.T) *Client {
+ t.Helper()
+ client := newTestClient(t)
+ client.Factory = &errorFactory{err: errors.New("connection refused")}
+ return client
+ },
+ expectError: true,
+ errorContains: "kubernetes cluster unreachable: connection refused",
+ },
+ {
+ name: "discovery error with cluster unreachable",
+ setupClient: func(t *testing.T) *Client {
+ t.Helper()
+ return newTestClientWithDiscoveryError(t, http.ErrServerClosed)
+ },
+ expectError: true,
+ errorContains: expectedUnreachableMsg,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := tt.setupClient(t)
+ err := client.IsReachable()
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("expected error but got nil")
+ return
+ }
+
+ 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)
+ }
+ }
+ })
+ }
+}
+
+func TestIsIncompatibleServerError(t *testing.T) {
+ testCases := map[string]struct {
+ Err error
+ Want bool
+ }{
+ "Unsupported media type": {
+ Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusUnsupportedMediaType}},
+ Want: true,
+ },
+ "Not found error": {
+ Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusNotFound}},
+ Want: false,
+ },
+ "Generic error": {
+ Err: fmt.Errorf("some generic error"),
+ Want: false,
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ if got := isIncompatibleServerError(tc.Err); got != tc.Want {
+ t.Errorf("isIncompatibleServerError() = %v, want %v", got, tc.Want)
+ }
+ })
+ }
+}
+
+func TestReplaceResource(t *testing.T) {
+ type testCase struct {
+ Pods v1.PodList
+ Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error)
+ ExpectedErrorContains string
+ }
+
+ testCases := map[string]testCase{
+ "normal": {
+ Pods: newPodList("whale"),
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ switch len(previous) {
+ case 0:
+ assert.Equal(t, "GET", req.Method)
+ case 1:
+ assert.Equal(t, "PUT", req.Method)
+ }
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "conflict": {
+ Pods: newPodList("whale"),
+ Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return &http.Response{
+ StatusCode: http.StatusConflict,
+ Request: req,
+ }, nil
+ },
+ ExpectedErrorContains: "failed to replace object: the server reported a conflict",
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+
+ testFactory := cmdtesting.NewTestFactory()
+ t.Cleanup(testFactory.Cleanup)
+
+ client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return tc.Callback(t, tc, previous, req)
+ })
+
+ testFactory.UnstructuredClient = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(client.Do),
+ }
+
+ resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.Pods), nil)
+ require.NoError(t, err)
+
+ require.Len(t, resourceList, 1)
+ info := resourceList[0]
+
+ err = replaceResource(info, FieldValidationDirectiveStrict)
+ if tc.ExpectedErrorContains != "" {
+ require.ErrorContains(t, err, tc.ExpectedErrorContains)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, info.Object)
+ }
+ })
+ }
+}
+
+func TestPatchResourceClientSide(t *testing.T) {
+ type testCase struct {
+ OriginalPods v1.PodList
+ TargetPods v1.PodList
+ ThreeWayMergeForUnstructured bool
+ Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error)
+ ExpectedErrorContains string
+ }
+
+ testCases := map[string]testCase{
+ "normal": {
+ OriginalPods: newPodList("whale"),
+ TargetPods: func() v1.PodList {
+ pods := newPodList("whale")
+ pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return pods
+ }(),
+ ThreeWayMergeForUnstructured: false,
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ switch len(previous) {
+ case 0:
+ assert.Equal(t, "GET", req.Method)
+ return newResponse(http.StatusOK, &tc.OriginalPods.Items[0])
+ case 1:
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type"))
+ return newResponse(http.StatusOK, &tc.TargetPods.Items[0])
+ }
+
+ t.Fail()
+ return nil, nil
+ },
+ },
+ "three way merge for unstructured": {
+ OriginalPods: newPodList("whale"),
+ TargetPods: func() v1.PodList {
+ pods := newPodList("whale")
+ pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return pods
+ }(),
+ ThreeWayMergeForUnstructured: true,
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ switch len(previous) {
+ case 0:
+ assert.Equal(t, "GET", req.Method)
+ return newResponse(http.StatusOK, &tc.OriginalPods.Items[0])
+ case 1:
+ t.Logf("patcher: %+v", req.Header)
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type"))
+ return newResponse(http.StatusOK, &tc.TargetPods.Items[0])
+ }
+
+ t.Fail()
+ return nil, nil
+ },
+ },
+ "conflict": {
+ OriginalPods: newPodList("whale"),
+ TargetPods: func() v1.PodList {
+ pods := newPodList("whale")
+ pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}}
+
+ return pods
+ }(),
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ switch len(previous) {
+ case 0:
+ assert.Equal(t, "GET", req.Method)
+ return newResponse(http.StatusOK, &tc.OriginalPods.Items[0])
+ case 1:
+ assert.Equal(t, "PATCH", req.Method)
+ return &http.Response{
+ StatusCode: http.StatusConflict,
+ Request: req,
+ }, nil
+ }
+
+ t.Fail()
+ return nil, nil
+
+ },
+ ExpectedErrorContains: "cannot patch \"whale\" with kind Pod: the server reported a conflict",
+ },
+ "no patch": {
+ OriginalPods: newPodList("whale"),
+ TargetPods: newPodList("whale"),
+ Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ switch len(previous) {
+ case 0:
+ assert.Equal(t, "GET", req.Method)
+ return newResponse(http.StatusOK, &tc.OriginalPods.Items[0])
+ case 1:
+ assert.Equal(t, "GET", req.Method)
+ return newResponse(http.StatusOK, &tc.TargetPods.Items[0])
+ }
+
+ 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)
+
+ client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ return tc.Callback(t, tc, previous, req)
+ })
+
+ testFactory.UnstructuredClient = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(client.Do),
+ }
+
+ resourceListOriginal, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.OriginalPods), nil)
+ require.NoError(t, err)
+ require.Len(t, resourceListOriginal, 1)
+
+ resourceListTarget, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.TargetPods), nil)
+ require.NoError(t, err)
+ require.Len(t, resourceListTarget, 1)
+
+ original := resourceListOriginal[0]
+ target := resourceListTarget[0]
+
+ err = patchResourceClientSide(original.Object, target, tc.ThreeWayMergeForUnstructured)
+ if tc.ExpectedErrorContains != "" {
+ require.ErrorContains(t, err, tc.ExpectedErrorContains)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, target.Object)
+ }
+ })
+ }
+}
+
+func TestPatchResourceServerSide(t *testing.T) {
+ type testCase struct {
+ Pods v1.PodList
+ DryRun bool
+ ForceConflicts bool
+ FieldValidationDirective FieldValidationDirective
+ Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error)
+ ExpectedErrorContains string
+ }
+
+ testCases := map[string]testCase{
+ "normal": {
+ Pods: newPodList("whale"),
+ DryRun: false,
+ ForceConflicts: false,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type"))
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ assert.Equal(t, "false", req.URL.Query().Get("force"))
+ assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation"))
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "dry run": {
+ Pods: newPodList("whale"),
+ DryRun: true,
+ ForceConflicts: false,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type"))
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ assert.Equal(t, "All", req.URL.Query().Get("dryRun"))
+ assert.Equal(t, "false", req.URL.Query().Get("force"))
+ assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation"))
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "force conflicts": {
+ Pods: newPodList("whale"),
+ DryRun: false,
+ ForceConflicts: true,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type"))
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ assert.Equal(t, "true", req.URL.Query().Get("force"))
+ assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation"))
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "dry run + force conflicts": {
+ Pods: newPodList("whale"),
+ DryRun: true,
+ ForceConflicts: true,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type"))
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ assert.Equal(t, "All", req.URL.Query().Get("dryRun"))
+ assert.Equal(t, "true", req.URL.Query().Get("force"))
+ assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation"))
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "field validation ignore": {
+ Pods: newPodList("whale"),
+ DryRun: false,
+ ForceConflicts: false,
+ FieldValidationDirective: FieldValidationDirectiveIgnore,
+ Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ assert.Equal(t, "PATCH", req.Method)
+ assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type"))
+ assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path)
+ assert.Equal(t, "false", req.URL.Query().Get("force"))
+ assert.Equal(t, "Ignore", req.URL.Query().Get("fieldValidation"))
+
+ return newResponse(http.StatusOK, &tc.Pods.Items[0])
+ },
+ },
+ "incompatible server": {
+ Pods: newPodList("whale"),
+ DryRun: false,
+ ForceConflicts: false,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return &http.Response{
+ StatusCode: http.StatusUnsupportedMediaType,
+ Request: req,
+ }, nil
+ },
+ ExpectedErrorContains: "server-side apply not available on the server:",
+ },
+ "conflict": {
+ Pods: newPodList("whale"),
+ DryRun: false,
+ ForceConflicts: false,
+ FieldValidationDirective: FieldValidationDirectiveStrict,
+ Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ t.Helper()
+
+ return &http.Response{
+ StatusCode: http.StatusConflict,
+ Request: req,
+ }, nil
+ },
+ ExpectedErrorContains: "the server reported a conflict",
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+
+ testFactory := cmdtesting.NewTestFactory()
+ t.Cleanup(testFactory.Cleanup)
+
+ client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) {
+ return tc.Callback(t, tc, previous, req)
+ })
+
+ testFactory.UnstructuredClient = &fake.RESTClient{
+ NegotiatedSerializer: unstructuredSerializer,
+ Client: fake.CreateHTTPClient(client.Do),
+ }
+
+ resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, tc.FieldValidationDirective, objBody(&tc.Pods), nil)
+ require.NoError(t, err)
+
+ require.Len(t, resourceList, 1)
+ info := resourceList[0]
+
+ err = patchResourceServerSide(info, tc.DryRun, tc.ForceConflicts, tc.FieldValidationDirective)
+ if tc.ExpectedErrorContains != "" {
+ require.ErrorContains(t, err, tc.ExpectedErrorContains)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, info.Object)
+ }
+ })
+ }
+}
+
+func TestDetermineFieldValidationDirective(t *testing.T) {
+
+ assert.Equal(t, FieldValidationDirectiveIgnore, determineFieldValidationDirective(false))
+ assert.Equal(t, FieldValidationDirectiveStrict, determineFieldValidationDirective(true))
+}
diff --git a/pkg/kube/config.go b/pkg/kube/config.go
deleted file mode 100644
index e00c9acb1..000000000
--- a/pkg/kube/config.go
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
-Copyright The Helm Authors.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package kube // import "helm.sh/helm/v3/pkg/kube"
-
-import "k8s.io/cli-runtime/pkg/genericclioptions"
-
-// GetConfig returns a Kubernetes client config.
-//
-// Deprecated
-func GetConfig(kubeconfig, context, namespace string) *genericclioptions.ConfigFlags {
- cf := genericclioptions.NewConfigFlags(true)
- cf.Namespace = &namespace
- cf.Context = &context
- cf.KubeConfig = &kubeconfig
- return cf
-}
diff --git a/pkg/kube/converter.go b/pkg/kube/converter.go
index 3bf0e358c..ac6d95fb4 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/v3/pkg/kube"
+package kube // import "helm.sh/helm/v4/pkg/kube"
import (
"sync"
diff --git a/pkg/kube/factory.go b/pkg/kube/factory.go
index f19d62dc3..1d237c307 100644
--- a/pkg/kube/factory.go
+++ b/pkg/kube/factory.go
@@ -14,12 +14,13 @@ 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 "helm.sh/helm/v4/pkg/kube"
import (
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
+ "k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/kubectl/pkg/validation"
)
@@ -33,6 +34,9 @@ import (
// Helm does not need are not impacted or exposed. This minimizes the impact of Kubernetes changes
// being exposed.
type Factory interface {
+ // ToRESTConfig returns restconfig
+ ToRESTConfig() (*rest.Config, error)
+
// ToRawKubeConfigLoader return kubeconfig loader as-is
ToRawKubeConfigLoader() clientcmd.ClientConfig
diff --git a/pkg/kube/fake/fake.go b/pkg/kube/fake/fake.go
index 267020d57..588bba83d 100644
--- a/pkg/kube/fake/fake.go
+++ b/pkg/kube/fake/fake.go
@@ -21,12 +21,11 @@ import (
"io"
"time"
- v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/resource"
- "helm.sh/helm/v3/pkg/kube"
+ "helm.sh/helm/v4/pkg/kube"
)
// FailingKubeClient implements KubeClient for testing purposes. It also has
@@ -34,27 +33,38 @@ import (
// delegates all its calls to `PrintingKubeClient`
type FailingKubeClient struct {
PrintingKubeClient
- CreateError error
- GetError error
- WaitError error
- DeleteError error
- DeleteWithPropagationError error
- WatchUntilReadyError error
- UpdateError error
- BuildError error
- BuildTableError error
- BuildDummy bool
- BuildUnstructuredError error
- WaitAndGetCompletedPodPhaseError error
- WaitDuration time.Duration
+ CreateError error
+ GetError error
+ DeleteError error
+ DeleteWithPropagationError error
+ UpdateError error
+ BuildError error
+ BuildTableError error
+ BuildDummy bool
+ DummyResources kube.ResourceList
+ BuildUnstructuredError error
+ WaitError error
+ WaitForDeleteError error
+ WatchUntilReadyError error
+ WaitDuration time.Duration
+}
+
+// FailingKubeWaiter implements kube.Waiter for testing purposes.
+// It also has additional errors you can set to fail different functions, otherwise it delegates all its calls to `PrintingKubeWaiter`
+type FailingKubeWaiter struct {
+ *PrintingKubeWaiter
+ waitError error
+ waitForDeleteError error
+ watchUntilReadyError error
+ waitDuration time.Duration
}
// Create returns the configured error if set or prints
-func (f *FailingKubeClient) Create(resources kube.ResourceList) (*kube.Result, error) {
+func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) {
if f.CreateError != nil {
return nil, f.CreateError
}
- return f.PrintingKubeClient.Create(resources)
+ return f.PrintingKubeClient.Create(resources, options...)
}
// Get returns the configured error if set or prints
@@ -66,28 +76,28 @@ func (f *FailingKubeClient) Get(resources kube.ResourceList, related bool) (map[
}
// Waits the amount of time defined on f.WaitDuration, then returns the configured error if set or prints.
-func (f *FailingKubeClient) Wait(resources kube.ResourceList, d time.Duration) error {
- time.Sleep(f.WaitDuration)
- if f.WaitError != nil {
- return f.WaitError
+func (f *FailingKubeWaiter) Wait(resources kube.ResourceList, d time.Duration) error {
+ time.Sleep(f.waitDuration)
+ if f.waitError != nil {
+ return f.waitError
}
- return f.PrintingKubeClient.Wait(resources, d)
+ return f.PrintingKubeWaiter.Wait(resources, d)
}
// WaitWithJobs returns the configured error if set or prints
-func (f *FailingKubeClient) WaitWithJobs(resources kube.ResourceList, d time.Duration) error {
- if f.WaitError != nil {
- return f.WaitError
+func (f *FailingKubeWaiter) WaitWithJobs(resources kube.ResourceList, d time.Duration) error {
+ if f.waitError != nil {
+ return f.waitError
}
- return f.PrintingKubeClient.WaitWithJobs(resources, d)
+ return f.PrintingKubeWaiter.WaitWithJobs(resources, d)
}
// WaitForDelete returns the configured error if set or prints
-func (f *FailingKubeClient) WaitForDelete(resources kube.ResourceList, d time.Duration) error {
- if f.WaitError != nil {
- return f.WaitError
+func (f *FailingKubeWaiter) WaitForDelete(resources kube.ResourceList, d time.Duration) error {
+ if f.waitForDeleteError != nil {
+ return f.waitForDeleteError
}
- return f.PrintingKubeClient.WaitForDelete(resources, d)
+ return f.PrintingKubeWaiter.WaitForDelete(resources, d)
}
// Delete returns the configured error if set or prints
@@ -99,19 +109,19 @@ func (f *FailingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, [
}
// WatchUntilReady returns the configured error if set or prints
-func (f *FailingKubeClient) WatchUntilReady(resources kube.ResourceList, d time.Duration) error {
- if f.WatchUntilReadyError != nil {
- return f.WatchUntilReadyError
+func (f *FailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, d time.Duration) error {
+ if f.watchUntilReadyError != nil {
+ return f.watchUntilReadyError
}
- return f.PrintingKubeClient.WatchUntilReady(resources, d)
+ return f.PrintingKubeWaiter.WatchUntilReady(resources, d)
}
// Update returns the configured error if set or prints
-func (f *FailingKubeClient) Update(r, modified kube.ResourceList, ignoreMe bool) (*kube.Result, error) {
+func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kube.ClientUpdateOption) (*kube.Result, error) {
if f.UpdateError != nil {
return &kube.Result{}, f.UpdateError
}
- return f.PrintingKubeClient.Update(r, modified, ignoreMe)
+ return f.PrintingKubeClient.Update(r, modified, options...)
}
// Build returns the configured error if set or prints
@@ -119,6 +129,9 @@ func (f *FailingKubeClient) Build(r io.Reader, _ bool) (kube.ResourceList, error
if f.BuildError != nil {
return []*resource.Info{}, f.BuildError
}
+ if f.DummyResources != nil {
+ return f.DummyResources, nil
+ }
if f.BuildDummy {
return createDummyResourceList(), nil
}
@@ -133,14 +146,6 @@ func (f *FailingKubeClient) BuildTable(r io.Reader, _ bool) (kube.ResourceList,
return f.PrintingKubeClient.BuildTable(r, false)
}
-// WaitAndGetCompletedPodPhase returns the configured error if set or prints
-func (f *FailingKubeClient) WaitAndGetCompletedPodPhase(s string, d time.Duration) (v1.PodPhase, error) {
- if f.WaitAndGetCompletedPodPhaseError != nil {
- return v1.PodSucceeded, f.WaitAndGetCompletedPodPhaseError
- }
- return f.PrintingKubeClient.WaitAndGetCompletedPodPhase(s, d)
-}
-
// DeleteWithPropagationPolicy returns the configured error if set or prints
func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) {
if f.DeleteWithPropagationError != nil {
@@ -149,6 +154,18 @@ func (f *FailingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceL
return f.PrintingKubeClient.DeleteWithPropagationPolicy(resources, policy)
}
+func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) {
+ waiter, _ := f.PrintingKubeClient.GetWaiter(ws)
+ printingKubeWaiter, _ := waiter.(*PrintingKubeWaiter)
+ return &FailingKubeWaiter{
+ PrintingKubeWaiter: printingKubeWaiter,
+ waitError: f.WaitError,
+ waitForDeleteError: f.WaitForDeleteError,
+ watchUntilReadyError: f.WatchUntilReadyError,
+ waitDuration: f.WaitDuration,
+ }, nil
+}
+
func createDummyResourceList() kube.ResourceList {
var resInfo resource.Info
resInfo.Name = "dummyName"
@@ -156,5 +173,4 @@ func createDummyResourceList() kube.ResourceList {
var resourceList kube.ResourceList
resourceList.Append(&resInfo)
return resourceList
-
}
diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go
index e6c4b6207..16c93615a 100644
--- a/pkg/kube/fake/printer.go
+++ b/pkg/kube/fake/printer.go
@@ -17,6 +17,7 @@ limitations under the License.
package fake
import (
+ "fmt"
"io"
"strings"
"time"
@@ -26,13 +27,20 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/resource"
- "helm.sh/helm/v3/pkg/kube"
+ "helm.sh/helm/v4/pkg/kube"
)
// PrintingKubeClient implements KubeClient, but simply prints the reader to
// the given output.
type PrintingKubeClient struct {
- Out io.Writer
+ Out io.Writer
+ LogOutput io.Writer
+}
+
+// PrintingKubeWaiter implements kube.Waiter, but simply prints the reader to the given output
+type PrintingKubeWaiter struct {
+ Out io.Writer
+ LogOutput io.Writer
}
// IsReachable checks if the cluster is reachable
@@ -41,7 +49,7 @@ func (p *PrintingKubeClient) IsReachable() error {
}
// Create prints the values of what would be created with a real KubeClient.
-func (p *PrintingKubeClient) Create(resources kube.ResourceList) (*kube.Result, error) {
+func (p *PrintingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) {
_, err := io.Copy(p.Out, bufferize(resources))
if err != nil {
return nil, err
@@ -49,7 +57,7 @@ func (p *PrintingKubeClient) Create(resources kube.ResourceList) (*kube.Result,
return &kube.Result{Created: resources}, nil
}
-func (p *PrintingKubeClient) Get(resources kube.ResourceList, related bool) (map[string][]runtime.Object, error) {
+func (p *PrintingKubeClient) Get(resources kube.ResourceList, _ bool) (map[string][]runtime.Object, error) {
_, err := io.Copy(p.Out, bufferize(resources))
if err != nil {
return nil, err
@@ -57,17 +65,23 @@ func (p *PrintingKubeClient) Get(resources kube.ResourceList, related bool) (map
return make(map[string][]runtime.Object), nil
}
-func (p *PrintingKubeClient) Wait(resources kube.ResourceList, _ time.Duration) error {
+func (p *PrintingKubeWaiter) Wait(resources kube.ResourceList, _ time.Duration) error {
+ _, err := io.Copy(p.Out, bufferize(resources))
+ return err
+}
+
+func (p *PrintingKubeWaiter) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error {
_, err := io.Copy(p.Out, bufferize(resources))
return err
}
-func (p *PrintingKubeClient) WaitWithJobs(resources kube.ResourceList, _ time.Duration) error {
+func (p *PrintingKubeWaiter) WaitForDelete(resources kube.ResourceList, _ time.Duration) error {
_, err := io.Copy(p.Out, bufferize(resources))
return err
}
-func (p *PrintingKubeClient) WaitForDelete(resources kube.ResourceList, _ time.Duration) error {
+// WatchUntilReady implements KubeClient WatchUntilReady.
+func (p *PrintingKubeWaiter) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error {
_, err := io.Copy(p.Out, bufferize(resources))
return err
}
@@ -83,14 +97,8 @@ func (p *PrintingKubeClient) Delete(resources kube.ResourceList) (*kube.Result,
return &kube.Result{Deleted: resources}, nil
}
-// WatchUntilReady implements KubeClient WatchUntilReady.
-func (p *PrintingKubeClient) WatchUntilReady(resources kube.ResourceList, _ time.Duration) error {
- _, err := io.Copy(p.Out, bufferize(resources))
- return err
-}
-
// Update implements KubeClient Update.
-func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kube.Result, error) {
+func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) {
_, err := io.Copy(p.Out, bufferize(modified))
if err != nil {
return nil, err
@@ -116,10 +124,21 @@ func (p *PrintingKubeClient) WaitAndGetCompletedPodPhase(_ string, _ time.Durati
return v1.PodSucceeded, nil
}
+// GetPodList implements KubeClient GetPodList.
+func (p *PrintingKubeClient) GetPodList(_ string, _ metav1.ListOptions) (*v1.PodList, error) {
+ return &v1.PodList{}, nil
+}
+
+// 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)))
+ return err
+}
+
// DeleteWithPropagationPolicy implements KubeClient delete.
//
// It only prints out the content to be deleted.
-func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, policy metav1.DeletionPropagation) (*kube.Result, []error) {
+func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.ResourceList, _ metav1.DeletionPropagation) (*kube.Result, []error) {
_, err := io.Copy(p.Out, bufferize(resources))
if err != nil {
return nil, []error{err}
@@ -127,6 +146,10 @@ func (p *PrintingKubeClient) DeleteWithPropagationPolicy(resources kube.Resource
return &kube.Result{Deleted: resources}, nil
}
+func (p *PrintingKubeClient) GetWaiter(_ kube.WaitStrategy) (kube.Waiter, error) {
+ return &PrintingKubeWaiter{Out: p.Out, LogOutput: p.LogOutput}, nil
+}
+
func bufferize(resources kube.ResourceList) io.Reader {
var builder strings.Builder
for _, info := range resources {
diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go
index ce42ed950..7339ae0ff 100644
--- a/pkg/kube/interface.go
+++ b/pkg/kube/interface.go
@@ -30,16 +30,39 @@ import (
// A KubernetesClient must be concurrency safe.
type Interface interface {
// Create creates one or more resources.
- Create(resources ResourceList) (*Result, error)
+ Create(resources ResourceList, options ...ClientCreateOption) (*Result, error)
+ // Delete destroys one or more resources.
+ Delete(resources ResourceList) (*Result, []error)
+
+ // Update updates one or more resources or creates the resource
+ // if it doesn't exist.
+ Update(original, target ResourceList, options ...ClientUpdateOption) (*Result, error)
+
+ // Build creates a resource list from a Reader.
+ //
+ // Reader must contain a YAML stream (one or more YAML documents separated
+ // by "\n---\n")
+ //
+ // Validates against OpenAPI schema if validate is true.
+ Build(reader io.Reader, validate bool) (ResourceList, error)
+ // IsReachable checks whether the client is able to connect to the cluster.
+ IsReachable() error
+
+ // Get Waiter gets the Kube.Waiter
+ GetWaiter(ws WaitStrategy) (Waiter, error)
+}
+
+// Waiter defines methods related to waiting for resource states.
+type Waiter interface {
// Wait waits up to the given timeout for the specified resources to be ready.
Wait(resources ResourceList, timeout time.Duration) error
// WaitWithJobs wait up to the given timeout for the specified resources to be ready, including jobs.
WaitWithJobs(resources ResourceList, timeout time.Duration) error
- // Delete destroys one or more resources.
- Delete(resources ResourceList) (*Result, []error)
+ // WaitForDelete wait up to the given timeout for the specified resources to be deleted.
+ WaitForDelete(resources ResourceList, timeout time.Duration) error
// WatchUntilReady watches the resources given and waits until it is ready.
//
@@ -51,40 +74,24 @@ type Interface interface {
// For all other kinds, it means the kind was created or modified without
// error.
WatchUntilReady(resources ResourceList, timeout time.Duration) error
-
- // Update updates one or more resources or creates the resource
- // if it doesn't exist.
- Update(original, target ResourceList, force bool) (*Result, error)
-
- // Build creates a resource list from a Reader.
- //
- // Reader must contain a YAML stream (one or more YAML documents separated
- // by "\n---\n")
- //
- // Validates against OpenAPI schema if validate is true.
- Build(reader io.Reader, validate bool) (ResourceList, error)
-
- // WaitAndGetCompletedPodPhase waits up to a timeout until a pod enters a completed phase
- // and returns said phase (PodSucceeded or PodFailed qualify).
- WaitAndGetCompletedPodPhase(name string, timeout time.Duration) (v1.PodPhase, error)
-
- // IsReachable checks whether the client is able to connect to the cluster.
- IsReachable() error
}
-// InterfaceExt is introduced to avoid breaking backwards compatibility for Interface implementers.
+// InterfaceLogs was introduced to avoid breaking backwards compatibility for Interface implementers.
//
-// TODO Helm 4: Remove InterfaceExt and integrate its method(s) into the Interface.
-type InterfaceExt interface {
- // WaitForDelete wait up to the given timeout for the specified resources to be deleted.
- WaitForDelete(resources ResourceList, timeout time.Duration) error
+// TODO Helm 4: Remove InterfaceLogs and integrate its method(s) into the Interface.
+type InterfaceLogs interface {
+ // GetPodList list all pods that match the specified listOptions
+ GetPodList(namespace string, listOptions metav1.ListOptions) (*v1.PodList, error)
+
+ // OutputContainerLogsForPodList output the logs for a pod list
+ OutputContainerLogsForPodList(podList *v1.PodList, namespace string, writerFunc func(namespace, pod, container string) io.Writer) error
}
// InterfaceDeletionPropagation is introduced to avoid breaking backwards compatibility for Interface implementers.
//
// TODO Helm 4: Remove InterfaceDeletionPropagation and integrate its method(s) into the Interface.
type InterfaceDeletionPropagation interface {
- // Delete destroys one or more resources. The deletion propagation is handled as per the given deletion propagation value.
+ // DeleteWithPropagationPolicy destroys one or more resources. The deletion propagation is handled as per the given deletion propagation value.
DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error)
}
@@ -111,6 +118,6 @@ type InterfaceResources interface {
}
var _ Interface = (*Client)(nil)
-var _ InterfaceExt = (*Client)(nil)
+var _ InterfaceLogs = (*Client)(nil)
var _ InterfaceDeletionPropagation = (*Client)(nil)
var _ InterfaceResources = (*Client)(nil)
diff --git a/pkg/kube/ready.go b/pkg/kube/ready.go
index 7172a42bc..7a06c72f9 100644
--- a/pkg/kube/ready.go
+++ b/pkg/kube/ready.go
@@ -14,18 +14,16 @@ 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 "helm.sh/helm/v4/pkg/kube"
import (
"context"
"fmt"
+ "log/slog"
appsv1 "k8s.io/api/apps/v1"
- appsv1beta1 "k8s.io/api/apps/v1beta1"
- appsv1beta2 "k8s.io/api/apps/v1beta2"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
- extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -35,7 +33,7 @@ import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
- deploymentutil "helm.sh/helm/v3/internal/third_party/k8s.io/kubernetes/deployment/util"
+ deploymentutil "helm.sh/helm/v4/internal/third_party/k8s.io/kubernetes/deployment/util"
)
// ReadyCheckerOption is a function that configures a ReadyChecker.
@@ -60,13 +58,9 @@ func CheckJobs(checkJobs bool) ReadyCheckerOption {
// NewReadyChecker creates a new checker. Passed ReadyCheckerOptions can
// be used to override defaults.
-func NewReadyChecker(cl kubernetes.Interface, log func(string, ...interface{}), opts ...ReadyCheckerOption) ReadyChecker {
+func NewReadyChecker(cl kubernetes.Interface, opts ...ReadyCheckerOption) ReadyChecker {
c := ReadyChecker{
client: cl,
- log: log,
- }
- if c.log == nil {
- c.log = nopLogger
}
for _, opt := range opts {
opt(&c)
@@ -77,7 +71,6 @@ func NewReadyChecker(cl kubernetes.Interface, log func(string, ...interface{}),
// ReadyChecker is a type that can check core Kubernetes types for readiness.
type ReadyChecker struct {
client kubernetes.Interface
- log func(string, ...interface{})
checkJobs bool
pausedAsReady bool
}
@@ -90,13 +83,6 @@ type ReadyChecker struct {
// IsReady will fetch the latest state of the object from the server prior to
// performing readiness checks, and it will return any error encountered.
func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, error) {
- var (
- // This defaults to true, otherwise we get to a point where
- // things will always return false unless one of the objects
- // that manages pods has been hit
- ok = true
- err error
- )
switch value := AsVersioned(v).(type) {
case *corev1.Pod:
pod, err := c.client.CoreV1().Pods(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
@@ -112,7 +98,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err
ready, err := c.jobReady(job)
return ready, err
}
- case *appsv1.Deployment, *appsv1beta1.Deployment, *appsv1beta2.Deployment, *extensionsv1beta1.Deployment:
+ case *appsv1.Deployment:
currentDeployment, err := c.client.AppsV1().Deployments(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
if err != nil {
return false, err
@@ -145,7 +131,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err
if !c.serviceReady(svc) {
return false, nil
}
- case *extensionsv1beta1.DaemonSet, *appsv1.DaemonSet, *appsv1beta2.DaemonSet:
+ case *appsv1.DaemonSet:
ds, err := c.client.AppsV1().DaemonSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
if err != nil {
return false, err
@@ -175,7 +161,7 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err
if !c.crdReady(*crd) {
return false, nil
}
- case *appsv1.StatefulSet, *appsv1beta1.StatefulSet, *appsv1beta2.StatefulSet:
+ case *appsv1.StatefulSet:
sts, err := c.client.AppsV1().StatefulSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
if err != nil {
return false, err
@@ -183,11 +169,30 @@ func (c *ReadyChecker) IsReady(ctx context.Context, v *resource.Info) (bool, err
if !c.statefulSetReady(sts) {
return false, nil
}
- case *corev1.ReplicationController, *extensionsv1beta1.ReplicaSet, *appsv1beta2.ReplicaSet, *appsv1.ReplicaSet:
- ok, err = c.podsReadyForObject(ctx, v.Namespace, value)
- }
- if !ok || err != nil {
- return false, err
+ case *corev1.ReplicationController:
+ rc, err := c.client.CoreV1().ReplicationControllers(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
+ if err != nil {
+ return false, err
+ }
+ if !c.replicationControllerReady(rc) {
+ return false, nil
+ }
+ ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
+ if !ready || err != nil {
+ return false, err
+ }
+ case *appsv1.ReplicaSet:
+ rs, err := c.client.AppsV1().ReplicaSets(v.Namespace).Get(ctx, v.Name, metav1.GetOptions{})
+ if err != nil {
+ return false, err
+ }
+ if !c.replicaSetReady(rs) {
+ return false, nil
+ }
+ ready, err := c.podsReadyForObject(ctx, v.Namespace, value)
+ if !ready || err != nil {
+ return false, err
+ }
}
return true, nil
}
@@ -221,20 +226,21 @@ func (c *ReadyChecker) isPodReady(pod *corev1.Pod) bool {
return true
}
}
- c.log("Pod is not ready: %s/%s", pod.GetNamespace(), pod.GetName())
+ slog.Debug("Pod is not ready", "namespace", pod.GetNamespace(), "name", pod.GetName())
return false
}
func (c *ReadyChecker) jobReady(job *batchv1.Job) (bool, error) {
if job.Status.Failed > *job.Spec.BackoffLimit {
- c.log("Job is failed: %s/%s", job.GetNamespace(), job.GetName())
+ slog.Debug("Job is failed", "namespace", job.GetNamespace(), "name", job.GetName())
// If a job is failed, it can't recover, so throw an error
return false, fmt.Errorf("job is failed: %s/%s", job.GetNamespace(), job.GetName())
}
if job.Spec.Completions != nil && job.Status.Succeeded < *job.Spec.Completions {
- c.log("Job is not completed: %s/%s", job.GetNamespace(), job.GetName())
+ slog.Debug("Job is not completed", "namespace", job.GetNamespace(), "name", job.GetName())
return false, nil
}
+ slog.Debug("Job is completed", "namespace", job.GetNamespace(), "name", job.GetName())
return true, nil
}
@@ -246,7 +252,7 @@ func (c *ReadyChecker) serviceReady(s *corev1.Service) bool {
// Ensure that the service cluster IP is not empty
if s.Spec.ClusterIP == "" {
- c.log("Service does not have cluster IP address: %s/%s", s.GetNamespace(), s.GetName())
+ slog.Debug("Service does not have cluster IP address", "namespace", s.GetNamespace(), "name", s.GetName())
return false
}
@@ -254,37 +260,55 @@ func (c *ReadyChecker) serviceReady(s *corev1.Service) bool {
if s.Spec.Type == corev1.ServiceTypeLoadBalancer {
// do not wait when at least 1 external IP is set
if len(s.Spec.ExternalIPs) > 0 {
- c.log("Service %s/%s has external IP addresses (%v), marking as ready", s.GetNamespace(), s.GetName(), s.Spec.ExternalIPs)
+ slog.Debug("Service has external IP addresses", "namespace", s.GetNamespace(), "name", s.GetName(), "externalIPs", s.Spec.ExternalIPs)
return true
}
if s.Status.LoadBalancer.Ingress == nil {
- c.log("Service does not have load balancer ingress IP address: %s/%s", s.GetNamespace(), s.GetName())
+ slog.Debug("Service does not have load balancer ingress IP address", "namespace", s.GetNamespace(), "name", s.GetName())
return false
}
}
-
+ slog.Debug("Service is ready", "namespace", s.GetNamespace(), "name", s.GetName(), "clusterIP", s.Spec.ClusterIP, "externalIPs", s.Spec.ExternalIPs)
return true
}
func (c *ReadyChecker) volumeReady(v *corev1.PersistentVolumeClaim) bool {
if v.Status.Phase != corev1.ClaimBound {
- c.log("PersistentVolumeClaim is not bound: %s/%s", v.GetNamespace(), v.GetName())
+ slog.Debug("PersistentVolumeClaim is not bound", "namespace", v.GetNamespace(), "name", v.GetName())
return false
}
+ slog.Debug("PersistentVolumeClaim is bound", "namespace", v.GetNamespace(), "name", v.GetName(), "phase", v.Status.Phase)
return true
}
func (c *ReadyChecker) deploymentReady(rs *appsv1.ReplicaSet, dep *appsv1.Deployment) bool {
+ // Verify the replicaset readiness
+ if !c.replicaSetReady(rs) {
+ return false
+ }
+ // Verify the generation observed by the deployment controller matches the spec generation
+ if dep.Status.ObservedGeneration != dep.Generation {
+ slog.Debug("Deployment is not ready, observedGeneration does not match spec generation", "namespace", dep.GetNamespace(), "name", dep.GetName(), "actualGeneration", dep.Status.ObservedGeneration, "expectedGeneration", dep.Generation)
+ return false
+ }
+
expectedReady := *dep.Spec.Replicas - deploymentutil.MaxUnavailable(*dep)
- if !(rs.Status.ReadyReplicas >= expectedReady) {
- c.log("Deployment is not ready: %s/%s. %d out of %d expected pods are ready", dep.Namespace, dep.Name, rs.Status.ReadyReplicas, expectedReady)
+ if rs.Status.ReadyReplicas < expectedReady {
+ slog.Debug("Deployment does not have enough pods ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
return false
}
+ slog.Debug("Deployment is ready", "namespace", dep.GetNamespace(), "name", dep.GetName(), "readyPods", rs.Status.ReadyReplicas, "totalPods", expectedReady)
return true
}
func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool {
+ // Verify the generation observed by the daemonSet controller matches the spec generation
+ if ds.Status.ObservedGeneration != ds.Generation {
+ slog.Debug("DaemonSet is not ready, observedGeneration does not match spec generation", "namespace", ds.GetNamespace(), "name", ds.GetName(), "observedGeneration", ds.Status.ObservedGeneration, "expectedGeneration", ds.Generation)
+ return false
+ }
+
// If the update strategy is not a rolling update, there will be nothing to wait for
if ds.Spec.UpdateStrategy.Type != appsv1.RollingUpdateDaemonSetStrategyType {
return true
@@ -292,7 +316,7 @@ func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool {
// Make sure all the updated pods have been scheduled
if ds.Status.UpdatedNumberScheduled != ds.Status.DesiredNumberScheduled {
- c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", ds.Namespace, ds.Name, ds.Status.UpdatedNumberScheduled, ds.Status.DesiredNumberScheduled)
+ slog.Debug("DaemonSet does not have enough Pods scheduled", "namespace", ds.GetNamespace(), "name", ds.GetName(), "scheduledPods", ds.Status.UpdatedNumberScheduled, "totalPods", ds.Status.DesiredNumberScheduled)
return false
}
maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(ds.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable, int(ds.Status.DesiredNumberScheduled), true)
@@ -304,10 +328,11 @@ func (c *ReadyChecker) daemonSetReady(ds *appsv1.DaemonSet) bool {
}
expectedReady := int(ds.Status.DesiredNumberScheduled) - maxUnavailable
- if !(int(ds.Status.NumberReady) >= expectedReady) {
- c.log("DaemonSet is not ready: %s/%s. %d out of %d expected pods are ready", ds.Namespace, ds.Name, ds.Status.NumberReady, expectedReady)
+ if int(ds.Status.NumberReady) < expectedReady {
+ slog.Debug("DaemonSet does not have enough Pods ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
return false
}
+ slog.Debug("DaemonSet is ready", "namespace", ds.GetNamespace(), "name", ds.GetName(), "readyPods", ds.Status.NumberReady, "totalPods", expectedReady)
return true
}
@@ -355,22 +380,22 @@ func (c *ReadyChecker) crdReady(crd apiextv1.CustomResourceDefinition) bool {
}
func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool {
+ // Verify the generation observed by the statefulSet controller matches the spec generation
+ if sts.Status.ObservedGeneration != sts.Generation {
+ slog.Debug("StatefulSet is not ready, observedGeneration doest not match spec generation", "namespace", sts.GetNamespace(), "name", sts.GetName(), "actualGeneration", sts.Status.ObservedGeneration, "expectedGeneration", sts.Generation)
+ return false
+ }
+
// If the update strategy is not a rolling update, there will be nothing to wait for
if sts.Spec.UpdateStrategy.Type != appsv1.RollingUpdateStatefulSetStrategyType {
- c.log("StatefulSet skipped ready check: %s/%s. updateStrategy is %v", sts.Namespace, sts.Name, sts.Spec.UpdateStrategy.Type)
+ slog.Debug("StatefulSet skipped ready check", "namespace", sts.GetNamespace(), "name", sts.GetName(), "updateStrategy", sts.Spec.UpdateStrategy.Type)
return true
}
- // Make sure the status is up-to-date with the StatefulSet changes
- if sts.Status.ObservedGeneration < sts.Generation {
- c.log("StatefulSet is not ready: %s/%s. update has not yet been observed", sts.Namespace, sts.Name)
- return false
- }
-
// Dereference all the pointers because StatefulSets like them
var partition int
// 1 is the default for replicas if not set
- var replicas = 1
+ replicas := 1
// For some reason, even if the update strategy is a rolling update, the
// actual rollingUpdate field can be nil. If it is, we can safely assume
// there is no partition value
@@ -389,23 +414,40 @@ func (c *ReadyChecker) statefulSetReady(sts *appsv1.StatefulSet) bool {
// Make sure all the updated pods have been scheduled
if int(sts.Status.UpdatedReplicas) < expectedReplicas {
- c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods have been scheduled", sts.Namespace, sts.Name, sts.Status.UpdatedReplicas, expectedReplicas)
+ slog.Debug("StatefulSet does not have enough Pods scheduled", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.UpdatedReplicas, "totalPods", expectedReplicas)
return false
}
if int(sts.Status.ReadyReplicas) != replicas {
- c.log("StatefulSet is not ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas)
+ slog.Debug("StatefulSet does not have enough Pods ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
return false
}
// This check only makes sense when all partitions are being upgraded otherwise during a
- // partioned rolling upgrade, this condition will never evaluate to true, leading to
+ // partitioned rolling upgrade, this condition will never evaluate to true, leading to
// error.
if partition == 0 && sts.Status.CurrentRevision != sts.Status.UpdateRevision {
- c.log("StatefulSet is not ready: %s/%s. currentRevision %s does not yet match updateRevision %s", sts.Namespace, sts.Name, sts.Status.CurrentRevision, sts.Status.UpdateRevision)
+ slog.Debug("StatefulSet is not ready, currentRevision does not match updateRevision", "namespace", sts.GetNamespace(), "name", sts.GetName(), "currentRevision", sts.Status.CurrentRevision, "updateRevision", sts.Status.UpdateRevision)
+ return false
+ }
+ slog.Debug("StatefulSet is ready", "namespace", sts.GetNamespace(), "name", sts.GetName(), "readyPods", sts.Status.ReadyReplicas, "totalPods", replicas)
+ return true
+}
+
+func (c *ReadyChecker) replicationControllerReady(rc *corev1.ReplicationController) bool {
+ // Verify the generation observed by the replicationController controller matches the spec generation
+ if rc.Status.ObservedGeneration != rc.Generation {
+ slog.Debug("ReplicationController is not ready, observedGeneration doest not match spec generation", "namespace", rc.GetNamespace(), "name", rc.GetName(), "actualGeneration", rc.Status.ObservedGeneration, "expectedGeneration", rc.Generation)
return false
}
+ return true
+}
- c.log("StatefulSet is ready: %s/%s. %d out of %d expected pods are ready", sts.Namespace, sts.Name, sts.Status.ReadyReplicas, replicas)
+func (c *ReadyChecker) replicaSetReady(rs *appsv1.ReplicaSet) bool {
+ // Verify the generation observed by the replicaSet controller matches the spec generation
+ if rs.Status.ObservedGeneration != rs.Generation {
+ slog.Debug("ReplicaSet is not ready, observedGeneration doest not match spec generation", "namespace", rs.GetNamespace(), "name", rs.GetName(), "actualGeneration", rs.Status.ObservedGeneration, "expectedGeneration", rs.Generation)
+ return false
+ }
return true
}
diff --git a/pkg/kube/ready_test.go b/pkg/kube/ready_test.go
index e8e71d8aa..db0d02cbe 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/v3/pkg/kube"
+package kube // import "helm.sh/helm/v4/pkg/kube"
import (
"context"
@@ -22,14 +22,677 @@ import (
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
+ apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+ apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
+ "k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
)
const defaultNamespace = metav1.NamespaceDefault
+func Test_ReadyChecker_IsReady_Pod(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ pod *corev1.Pod
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady Pod",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.Pod{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ pod: newPodWithCondition("foo", corev1.ConditionTrue),
+ want: true,
+ wantErr: false,
+ },
+ {
+ name: "IsReady Pod returns error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.Pod{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ pod: newPodWithCondition("bar", corev1.ConditionTrue),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(t.Context(), tt.pod, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create Pod error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_Job(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ job *batchv1.Job
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady Job error while getting job",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &batchv1.Job{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ job: newJob("bar", 1, intToInt32(1), 1, 0),
+ want: false,
+ wantErr: true,
+ },
+ {
+ name: "IsReady Job",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &batchv1.Job{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ job: newJob("foo", 1, intToInt32(1), 1, 0),
+ want: true,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.BatchV1().Jobs(defaultNamespace).Create(t.Context(), tt.job, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create Job error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_Deployment(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ replicaSet *appsv1.ReplicaSet
+ deployment *appsv1.Deployment
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady Deployments error while getting current Deployment",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.Deployment{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ replicaSet: newReplicaSet("foo", 0, 0, true),
+ deployment: newDeployment("bar", 1, 1, 0, true),
+ want: false,
+ wantErr: true,
+ },
+ {
+ name: "IsReady Deployments", //TODO fix this one
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.Deployment{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ replicaSet: newReplicaSet("foo", 0, 0, true),
+ deployment: newDeployment("foo", 1, 1, 0, true),
+ want: false,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.AppsV1().Deployments(defaultNamespace).Create(t.Context(), tt.deployment, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create Deployment error: %v", err)
+ return
+ }
+ if _, err := c.client.AppsV1().ReplicaSets(defaultNamespace).Create(t.Context(), tt.replicaSet, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create ReplicaSet error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_PersistentVolumeClaim(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ pvc *corev1.PersistentVolumeClaim
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady PersistentVolumeClaim",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.PersistentVolumeClaim{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ pvc: newPersistentVolumeClaim("foo", corev1.ClaimPending),
+ want: false,
+ wantErr: false,
+ },
+ {
+ name: "IsReady PersistentVolumeClaim with error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.PersistentVolumeClaim{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ pvc: newPersistentVolumeClaim("bar", corev1.ClaimPending),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.CoreV1().PersistentVolumeClaims(defaultNamespace).Create(t.Context(), tt.pvc, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create PersistentVolumeClaim error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_Service(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ svc *corev1.Service
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady Service",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.Service{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ svc: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: ""}),
+ want: false,
+ wantErr: false,
+ },
+ {
+ name: "IsReady Service with error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.Service{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ svc: newService("bar", corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName, ClusterIP: ""}),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.CoreV1().Services(defaultNamespace).Create(t.Context(), tt.svc, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create Service error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_DaemonSet(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ ds *appsv1.DaemonSet
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady DaemonSet",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.DaemonSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ ds: newDaemonSet("foo", 0, 0, 1, 0, true),
+ want: false,
+ wantErr: false,
+ },
+ {
+ name: "IsReady DaemonSet with error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.DaemonSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ ds: newDaemonSet("bar", 0, 1, 1, 1, true),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.AppsV1().DaemonSets(defaultNamespace).Create(t.Context(), tt.ds, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create DaemonSet error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_StatefulSet(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ ss *appsv1.StatefulSet
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady StatefulSet",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.StatefulSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ ss: newStatefulSet("foo", 1, 0, 0, 1, true),
+ want: false,
+ wantErr: false,
+ },
+ {
+ name: "IsReady StatefulSet with error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.StatefulSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ ss: newStatefulSet("bar", 1, 0, 1, 1, true),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.AppsV1().StatefulSets(defaultNamespace).Create(t.Context(), tt.ss, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create StatefulSet error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_ReplicationController(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ rc *corev1.ReplicationController
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady ReplicationController",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ rc: newReplicationController("foo", false),
+ want: false,
+ wantErr: false,
+ },
+ {
+ name: "IsReady ReplicationController with error",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ rc: newReplicationController("bar", false),
+ want: false,
+ wantErr: true,
+ },
+ {
+ name: "IsReady ReplicationController and pods not ready for object",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &corev1.ReplicationController{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ rc: newReplicationController("foo", true),
+ want: true,
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ if _, err := c.client.CoreV1().ReplicationControllers(defaultNamespace).Create(t.Context(), tt.rc, metav1.CreateOptions{}); err != nil {
+ t.Errorf("Failed to create ReplicationController error: %v", err)
+ return
+ }
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_IsReady_ReplicaSet(t *testing.T) {
+ type fields struct {
+ client kubernetes.Interface
+ checkJobs bool
+ pausedAsReady bool
+ }
+ type args struct {
+ ctx context.Context
+ resource *resource.Info
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ rs *appsv1.ReplicaSet
+ want bool
+ wantErr bool
+ }{
+ {
+ name: "IsReady ReplicaSet",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.ReplicaSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ rs: newReplicaSet("foo", 1, 1, true),
+ want: false,
+ wantErr: true,
+ },
+ {
+ name: "IsReady ReplicaSet not ready",
+ fields: fields{
+ client: fake.NewClientset(),
+ checkJobs: true,
+ pausedAsReady: false,
+ },
+ args: args{
+ ctx: t.Context(),
+ resource: &resource.Info{Object: &appsv1.ReplicaSet{}, Name: "foo", Namespace: defaultNamespace},
+ },
+ rs: newReplicaSet("bar", 1, 1, false),
+ want: false,
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := &ReadyChecker{
+ client: tt.fields.client,
+ checkJobs: tt.fields.checkJobs,
+ pausedAsReady: tt.fields.pausedAsReady,
+ }
+ //
+ got, err := c.IsReady(tt.args.ctx, tt.args.resource)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("IsReady() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if got != tt.want {
+ t.Errorf("IsReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func Test_ReadyChecker_deploymentReady(t *testing.T) {
type args struct {
rs *appsv1.ReplicaSet
@@ -43,31 +706,55 @@ func Test_ReadyChecker_deploymentReady(t *testing.T) {
{
name: "deployment is ready",
args: args{
- rs: newReplicaSet("foo", 1, 1),
- dep: newDeployment("foo", 1, 1, 0),
+ rs: newReplicaSet("foo", 1, 1, true),
+ dep: newDeployment("foo", 1, 1, 0, true),
},
want: true,
},
{
name: "deployment is not ready",
args: args{
- rs: newReplicaSet("foo", 0, 0),
- dep: newDeployment("foo", 1, 1, 0),
+ rs: newReplicaSet("foo", 0, 0, true),
+ dep: newDeployment("foo", 1, 1, 0, true),
},
want: false,
},
{
name: "deployment is ready when maxUnavailable is set",
args: args{
- rs: newReplicaSet("foo", 2, 1),
- dep: newDeployment("foo", 2, 1, 1),
+ rs: newReplicaSet("foo", 2, 1, true),
+ dep: newDeployment("foo", 2, 1, 1, true),
},
want: true,
},
+ {
+ name: "deployment is not ready when replicaset generations are out of sync",
+ args: args{
+ rs: newReplicaSet("foo", 1, 1, false),
+ dep: newDeployment("foo", 1, 1, 0, true),
+ },
+ want: false,
+ },
+ {
+ name: "deployment is not ready when deployment generations are out of sync",
+ args: args{
+ rs: newReplicaSet("foo", 1, 1, true),
+ dep: newDeployment("foo", 1, 1, 0, false),
+ },
+ want: false,
+ },
+ {
+ name: "deployment is not ready when generations are out of sync",
+ args: args{
+ rs: newReplicaSet("foo", 1, 1, false),
+ dep: newDeployment("foo", 1, 1, 0, false),
+ },
+ want: false,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
if got := c.deploymentReady(tt.args.rs, tt.args.dep); got != tt.want {
t.Errorf("deploymentReady() = %v, want %v", got, tt.want)
}
@@ -75,6 +762,74 @@ func Test_ReadyChecker_deploymentReady(t *testing.T) {
}
}
+func Test_ReadyChecker_replicaSetReady(t *testing.T) {
+ type args struct {
+ rs *appsv1.ReplicaSet
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "replicaSet is ready",
+ args: args{
+ rs: newReplicaSet("foo", 1, 1, true),
+ },
+ want: true,
+ },
+ {
+ name: "replicaSet is not ready when generations are out of sync",
+ args: args{
+ rs: newReplicaSet("foo", 1, 1, false),
+ },
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewReadyChecker(fake.NewClientset())
+ if got := c.replicaSetReady(tt.args.rs); got != tt.want {
+ t.Errorf("replicaSetReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_replicationControllerReady(t *testing.T) {
+ type args struct {
+ rc *corev1.ReplicationController
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "replicationController is ready",
+ args: args{
+ rc: newReplicationController("foo", true),
+ },
+ want: true,
+ },
+ {
+ name: "replicationController is not ready when generations are out of sync",
+ args: args{
+ rc: newReplicationController("foo", false),
+ },
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewReadyChecker(fake.NewClientset())
+ if got := c.replicationControllerReady(tt.args.rc); got != tt.want {
+ t.Errorf("replicationControllerReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
func Test_ReadyChecker_daemonSetReady(t *testing.T) {
type args struct {
ds *appsv1.DaemonSet
@@ -87,35 +842,42 @@ func Test_ReadyChecker_daemonSetReady(t *testing.T) {
{
name: "daemonset is ready",
args: args{
- ds: newDaemonSet("foo", 0, 1, 1, 1),
+ ds: newDaemonSet("foo", 0, 1, 1, 1, true),
},
want: true,
},
{
name: "daemonset is not ready",
args: args{
- ds: newDaemonSet("foo", 0, 0, 1, 1),
+ ds: newDaemonSet("foo", 0, 0, 1, 1, true),
},
want: false,
},
{
name: "daemonset pods have not been scheduled successfully",
args: args{
- ds: newDaemonSet("foo", 0, 0, 1, 0),
+ ds: newDaemonSet("foo", 0, 0, 1, 0, true),
},
want: false,
},
{
name: "daemonset is ready when maxUnavailable is set",
args: args{
- ds: newDaemonSet("foo", 1, 1, 2, 2),
+ ds: newDaemonSet("foo", 1, 1, 2, 2, true),
},
want: true,
},
+ {
+ name: "daemonset is not ready when generations are out of sync",
+ args: args{
+ ds: newDaemonSet("foo", 0, 1, 1, 1, false),
+ },
+ want: false,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
if got := c.daemonSetReady(tt.args.ds); got != tt.want {
t.Errorf("daemonSetReady() = %v, want %v", got, tt.want)
}
@@ -135,70 +897,63 @@ func Test_ReadyChecker_statefulSetReady(t *testing.T) {
{
name: "statefulset is ready",
args: args{
- sts: newStatefulSet("foo", 1, 0, 1, 1),
+ sts: newStatefulSet("foo", 1, 0, 1, 1, true),
},
want: true,
},
{
name: "statefulset is not ready",
args: args{
- sts: newStatefulSet("foo", 1, 0, 0, 1),
+ sts: newStatefulSet("foo", 1, 0, 0, 1, true),
},
want: false,
},
{
name: "statefulset is ready when partition is specified",
args: args{
- sts: newStatefulSet("foo", 2, 1, 2, 1),
+ sts: newStatefulSet("foo", 2, 1, 2, 1, true),
},
want: true,
},
{
name: "statefulset is not ready when partition is set",
args: args{
- sts: newStatefulSet("foo", 2, 1, 1, 0),
+ sts: newStatefulSet("foo", 2, 1, 1, 0, true),
},
want: false,
},
{
name: "statefulset is ready when partition is set and no change in template",
args: args{
- sts: newStatefulSet("foo", 2, 1, 2, 2),
+ sts: newStatefulSet("foo", 2, 1, 2, 2, true),
},
want: true,
},
{
name: "statefulset is ready when partition is greater than replicas",
args: args{
- sts: newStatefulSet("foo", 1, 2, 1, 1),
+ sts: newStatefulSet("foo", 1, 2, 1, 1, true),
},
want: true,
},
{
- name: "statefulset is not ready when status of latest generation has not yet been observed",
- args: args{
- sts: newStatefulSetWithNewGeneration("foo", 1, 0, 1, 1),
- },
- want: false,
- },
- {
- name: "statefulset is not ready when current revision for current replicas does not match update revision for updated replicas",
+ name: "statefulset is not ready when generations are out of sync",
args: args{
- sts: newStatefulSetWithUpdateRevision("foo", 1, 0, 1, 1, "foo-bbbbbbb"),
+ sts: newStatefulSet("foo", 1, 0, 1, 1, false),
},
want: false,
},
{
name: "statefulset is ready when current revision for current replicas does not match update revision for updated replicas when using partition !=0",
args: args{
- sts: newStatefulSetWithUpdateRevision("foo", 3, 2, 3, 3, "foo-bbbbbbb"),
+ sts: newStatefulSetWithUpdateRevision("foo", 3, 2, 3, 3, "foo-bbbbbbb", true),
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
if got := c.statefulSetReady(tt.args.sts); got != tt.want {
t.Errorf("statefulSetReady() = %v, want %v", got, tt.want)
}
@@ -222,7 +977,7 @@ func Test_ReadyChecker_podsReadyForObject(t *testing.T) {
name: "pods ready for a replicaset",
args: args{
namespace: defaultNamespace,
- obj: newReplicaSet("foo", 1, 1),
+ obj: newReplicaSet("foo", 1, 1, true),
},
existPods: []corev1.Pod{
*newPodWithCondition("foo", corev1.ConditionTrue),
@@ -234,7 +989,7 @@ func Test_ReadyChecker_podsReadyForObject(t *testing.T) {
name: "pods not ready for a replicaset",
args: args{
namespace: defaultNamespace,
- obj: newReplicaSet("foo", 1, 1),
+ obj: newReplicaSet("foo", 1, 1, true),
},
existPods: []corev1.Pod{
*newPodWithCondition("foo", corev1.ConditionFalse),
@@ -242,17 +997,29 @@ func Test_ReadyChecker_podsReadyForObject(t *testing.T) {
want: false,
wantErr: false,
},
+ {
+ name: "ReplicaSet not set",
+ args: args{
+ namespace: defaultNamespace,
+ obj: nil,
+ },
+ existPods: []corev1.Pod{
+ *newPodWithCondition("foo", corev1.ConditionFalse),
+ },
+ want: false,
+ wantErr: true,
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
for _, pod := range tt.existPods {
- if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(context.TODO(), &pod, metav1.CreateOptions{}); err != nil {
+ if _, err := c.client.CoreV1().Pods(defaultNamespace).Create(t.Context(), &pod, metav1.CreateOptions{}); err != nil {
t.Errorf("Failed to create Pod error: %v", err)
return
}
}
- got, err := c.podsReadyForObject(context.TODO(), tt.args.namespace, tt.args.obj)
+ got, err := c.podsReadyForObject(t.Context(), tt.args.namespace, tt.args.obj)
if (err != nil) != tt.wantErr {
t.Errorf("podsReadyForObject() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -324,7 +1091,7 @@ func Test_ReadyChecker_jobReady(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
got, err := c.jobReady(tt.args.job)
if (err != nil) != tt.wantErr {
t.Errorf("jobReady() error = %v, wantErr %v", err, tt.wantErr)
@@ -363,7 +1130,7 @@ func Test_ReadyChecker_volumeReady(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- c := NewReadyChecker(fake.NewSimpleClientset(), nil)
+ c := NewReadyChecker(fake.NewClientset())
if got := c.volumeReady(tt.args.v); got != tt.want {
t.Errorf("volumeReady() = %v, want %v", got, tt.want)
}
@@ -371,11 +1138,206 @@ func Test_ReadyChecker_volumeReady(t *testing.T) {
}
}
-func newDaemonSet(name string, maxUnavailable, numberReady, desiredNumberScheduled, updatedNumberScheduled int) *appsv1.DaemonSet {
+func Test_ReadyChecker_serviceReady(t *testing.T) {
+ type args struct {
+ service *corev1.Service
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "service type is of external name",
+ args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeExternalName, ClusterIP: ""})},
+ want: true,
+ },
+ {
+ name: "service cluster ip is empty",
+ args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: ""})},
+ want: false,
+ },
+ {
+ name: "service has a cluster ip that is greater than 0",
+ args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: "bar", ExternalIPs: []string{"bar"}})},
+ want: true,
+ },
+ {
+ name: "service has a cluster ip that is less than 0 and ingress is nil",
+ args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeLoadBalancer, ClusterIP: "bar"})},
+ want: false,
+ },
+ {
+ name: "service has a cluster ip that is less than 0 and ingress is nil",
+ args: args{service: newService("foo", corev1.ServiceSpec{Type: corev1.ServiceTypeClusterIP, ClusterIP: "bar"})},
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewReadyChecker(fake.NewClientset())
+ got := c.serviceReady(tt.args.service)
+ if got != tt.want {
+ t.Errorf("serviceReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_crdBetaReady(t *testing.T) {
+ type args struct {
+ crdBeta apiextv1beta1.CustomResourceDefinition
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "crdBeta type is Establish and Conditional is true",
+ args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1beta1.Established,
+ Status: apiextv1beta1.ConditionTrue,
+ },
+ },
+ })},
+ want: true,
+ },
+ {
+ name: "crdBeta type is Establish and Conditional is false",
+ args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1beta1.Established,
+ Status: apiextv1beta1.ConditionFalse,
+ },
+ },
+ })},
+ want: false,
+ },
+ {
+ name: "crdBeta type is NamesAccepted and Conditional is true",
+ args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1beta1.NamesAccepted,
+ Status: apiextv1beta1.ConditionTrue,
+ },
+ },
+ })},
+ want: false,
+ },
+ {
+ name: "crdBeta type is NamesAccepted and Conditional is false",
+ args: args{crdBeta: newcrdBetaReady("foo", apiextv1beta1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1beta1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1beta1.NamesAccepted,
+ Status: apiextv1beta1.ConditionFalse,
+ },
+ },
+ })},
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewReadyChecker(fake.NewClientset())
+ got := c.crdBetaReady(tt.args.crdBeta)
+ if got != tt.want {
+ t.Errorf("crdBetaReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_ReadyChecker_crdReady(t *testing.T) {
+ type args struct {
+ crdBeta apiextv1.CustomResourceDefinition
+ }
+ tests := []struct {
+ name string
+ args args
+ want bool
+ }{
+ {
+ name: "crdBeta type is Establish and Conditional is true",
+ args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1.Established,
+ Status: apiextv1.ConditionTrue,
+ },
+ },
+ })},
+ want: true,
+ },
+ {
+ name: "crdBeta type is Establish and Conditional is false",
+ args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1.Established,
+ Status: apiextv1.ConditionFalse,
+ },
+ },
+ })},
+ want: false,
+ },
+ {
+ name: "crdBeta type is NamesAccepted and Conditional is true",
+ args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1.NamesAccepted,
+ Status: apiextv1.ConditionTrue,
+ },
+ },
+ })},
+ want: false,
+ },
+ {
+ name: "crdBeta type is NamesAccepted and Conditional is false",
+ args: args{crdBeta: newcrdReady("foo", apiextv1.CustomResourceDefinitionStatus{
+ Conditions: []apiextv1.CustomResourceDefinitionCondition{
+ {
+ Type: apiextv1.NamesAccepted,
+ Status: apiextv1.ConditionFalse,
+ },
+ },
+ })},
+ want: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ c := NewReadyChecker(fake.NewClientset())
+ got := c.crdReady(tt.args.crdBeta)
+ if got != tt.want {
+ t.Errorf("crdBetaReady() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func newStatefulSetWithUpdateRevision(name string, replicas, partition, readyReplicas, updatedReplicas int, updateRevision string, generationInSync bool) *appsv1.StatefulSet {
+ ss := newStatefulSet(name, replicas, partition, readyReplicas, updatedReplicas, generationInSync)
+ ss.Status.UpdateRevision = updateRevision
+ return ss
+}
+
+func newDaemonSet(name string, maxUnavailable, numberReady, desiredNumberScheduled, updatedNumberScheduled int, generationInSync bool) *appsv1.DaemonSet {
+ var generation, observedGeneration int64 = 1, 1
+ if !generationInSync {
+ generation = 2
+ }
return &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
- Name: name,
- Namespace: defaultNamespace,
+ Name: name,
+ Namespace: defaultNamespace,
+ Generation: generation,
},
Spec: appsv1.DaemonSetSpec{
UpdateStrategy: appsv1.DaemonSetUpdateStrategy{
@@ -403,16 +1365,21 @@ func newDaemonSet(name string, maxUnavailable, numberReady, desiredNumberSchedul
DesiredNumberScheduled: int32(desiredNumberScheduled),
NumberReady: int32(numberReady),
UpdatedNumberScheduled: int32(updatedNumberScheduled),
+ ObservedGeneration: observedGeneration,
},
}
}
-func newStatefulSet(name string, replicas, partition, readyReplicas, updatedReplicas int) *appsv1.StatefulSet {
+func newStatefulSet(name string, replicas, partition, readyReplicas, updatedReplicas int, generationInSync bool) *appsv1.StatefulSet {
+ var generation, observedGeneration int64 = 1, 1
+ if !generationInSync {
+ generation = 2
+ }
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: defaultNamespace,
- Generation: int64(1),
+ Generation: generation,
},
Spec: appsv1.StatefulSetSpec{
UpdateStrategy: appsv1.StatefulSetUpdateStrategy{
@@ -438,32 +1405,23 @@ func newStatefulSet(name string, replicas, partition, readyReplicas, updatedRepl
},
},
Status: appsv1.StatefulSetStatus{
- ObservedGeneration: int64(1),
- CurrentRevision: name + "-aaaaaaa",
- UpdateRevision: name + "-aaaaaaa",
UpdatedReplicas: int32(updatedReplicas),
ReadyReplicas: int32(readyReplicas),
+ ObservedGeneration: observedGeneration,
},
}
}
-func newStatefulSetWithNewGeneration(name string, replicas, partition, readyReplicas, updatedReplicas int) *appsv1.StatefulSet {
- ss := newStatefulSet(name, replicas, partition, readyReplicas, updatedReplicas)
- ss.Generation++
- return ss
-}
-
-func newStatefulSetWithUpdateRevision(name string, replicas, partition, readyReplicas, updatedReplicas int, updateRevision string) *appsv1.StatefulSet {
- ss := newStatefulSet(name, replicas, partition, readyReplicas, updatedReplicas)
- ss.Status.UpdateRevision = updateRevision
- return ss
-}
-
-func newDeployment(name string, replicas, maxSurge, maxUnavailable int) *appsv1.Deployment {
+func newDeployment(name string, replicas, maxSurge, maxUnavailable int, generationInSync bool) *appsv1.Deployment {
+ var generation, observedGeneration int64 = 1, 1
+ if !generationInSync {
+ generation = 2
+ }
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
- Name: name,
- Namespace: defaultNamespace,
+ Name: name,
+ Namespace: defaultNamespace,
+ Generation: generation,
},
Spec: appsv1.DeploymentSpec{
Strategy: appsv1.DeploymentStrategy{
@@ -489,17 +1447,37 @@ func newDeployment(name string, replicas, maxSurge, maxUnavailable int) *appsv1.
},
},
},
+ Status: appsv1.DeploymentStatus{
+ ObservedGeneration: observedGeneration,
+ },
}
}
-func newReplicaSet(name string, replicas int, readyReplicas int) *appsv1.ReplicaSet {
- d := newDeployment(name, replicas, 0, 0)
+func newReplicationController(name string, generationInSync bool) *corev1.ReplicationController {
+ var generation, observedGeneration int64 = 1, 1
+ if !generationInSync {
+ generation = 2
+ }
+ return &corev1.ReplicationController{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Generation: generation,
+ },
+ Status: corev1.ReplicationControllerStatus{
+ ObservedGeneration: observedGeneration,
+ },
+ }
+}
+
+func newReplicaSet(name string, replicas int, readyReplicas int, generationInSync bool) *appsv1.ReplicaSet {
+ d := newDeployment(name, replicas, 0, 0, generationInSync)
return &appsv1.ReplicaSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: defaultNamespace,
Labels: d.Spec.Selector.MatchLabels,
OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(d, d.GroupVersionKind())},
+ Generation: d.Generation,
},
Spec: appsv1.ReplicaSetSpec{
Selector: d.Spec.Selector,
@@ -507,7 +1485,8 @@ func newReplicaSet(name string, replicas int, readyReplicas int) *appsv1.Replica
Template: d.Spec.Template,
},
Status: appsv1.ReplicaSetStatus{
- ReadyReplicas: int32(readyReplicas),
+ ReadyReplicas: int32(readyReplicas),
+ ObservedGeneration: d.Status.ObservedGeneration,
},
}
}
@@ -579,6 +1558,43 @@ func newJob(name string, backoffLimit int, completions *int32, succeeded int, fa
}
}
+func newService(name string, serviceSpec corev1.ServiceSpec) *corev1.Service {
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: defaultNamespace,
+ },
+ Spec: serviceSpec,
+ Status: corev1.ServiceStatus{
+ LoadBalancer: corev1.LoadBalancerStatus{
+ Ingress: nil,
+ },
+ },
+ }
+}
+
+func newcrdBetaReady(name string, crdBetaStatus apiextv1beta1.CustomResourceDefinitionStatus) apiextv1beta1.CustomResourceDefinition {
+ return apiextv1beta1.CustomResourceDefinition{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: defaultNamespace,
+ },
+ Spec: apiextv1beta1.CustomResourceDefinitionSpec{},
+ Status: crdBetaStatus,
+ }
+}
+
+func newcrdReady(name string, crdBetaStatus apiextv1.CustomResourceDefinitionStatus) apiextv1.CustomResourceDefinition {
+ return apiextv1.CustomResourceDefinition{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: defaultNamespace,
+ },
+ Spec: apiextv1.CustomResourceDefinitionSpec{},
+ Status: crdBetaStatus,
+ }
+}
+
func intToInt32(i int) *int32 {
i32 := int32(i)
return &i32
diff --git a/pkg/kube/resource.go b/pkg/kube/resource.go
index ee8f83a25..d88b171f0 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/v3/pkg/kube"
+package kube // import "helm.sh/helm/v4/pkg/kube"
import "k8s.io/cli-runtime/pkg/resource"
@@ -26,7 +26,7 @@ func (r *ResourceList) Append(val *resource.Info) {
*r = append(*r, val)
}
-// Visit implements resource.Visitor.
+// Visit implements resource.Visitor. The visitor stops if fn returns an error.
func (r ResourceList) Visit(fn resource.VisitorFunc) error {
for _, i := range r {
if err := fn(i, nil); err != nil {
@@ -81,5 +81,5 @@ func (r ResourceList) Intersect(rs ResourceList) ResourceList {
// isMatchingInfo returns true if infos match on Name and GroupVersionKind.
func isMatchingInfo(a, b *resource.Info) bool {
- return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind.Kind == b.Mapping.GroupVersionKind.Kind
+ return a.Name == b.Name && a.Namespace == b.Namespace && a.Mapping.GroupVersionKind == b.Mapping.GroupVersionKind
}
diff --git a/pkg/kube/resource_policy.go b/pkg/kube/resource_policy.go
index 46b8680dd..fb1089785 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/v3/pkg/kube"
+package kube // import "helm.sh/helm/v4/pkg/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 3c906ceca..ccc613c1b 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/v3/pkg/kube"
+package kube // import "helm.sh/helm/v4/pkg/kube"
import (
"testing"
@@ -59,3 +59,42 @@ func TestResourceList(t *testing.T) {
t.Error("expected intersect to return bar")
}
}
+
+func TestIsMatchingInfo(t *testing.T) {
+ gvk := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "pod"}
+ resourceInfo := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}}
+
+ gvkDiffGroup := schema.GroupVersionKind{Group: "diff", Version: "version1", Kind: "pod"}
+ resourceInfoDiffGroup := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffGroup}}
+ if isMatchingInfo(&resourceInfo, &resourceInfoDiffGroup) {
+ t.Error("expected resources not equal")
+ }
+
+ gvkDiffVersion := schema.GroupVersionKind{Group: "group1", Version: "diff", Kind: "pod"}
+ resourceInfoDiffVersion := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffVersion}}
+ if isMatchingInfo(&resourceInfo, &resourceInfoDiffVersion) {
+ t.Error("expected resources not equal")
+ }
+
+ gvkDiffKind := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "deployment"}
+ resourceInfoDiffKind := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkDiffKind}}
+ if isMatchingInfo(&resourceInfo, &resourceInfoDiffKind) {
+ t.Error("expected resources not equal")
+ }
+
+ resourceInfoDiffName := resource.Info{Name: "diff", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}}
+ if isMatchingInfo(&resourceInfo, &resourceInfoDiffName) {
+ t.Error("expected resources not equal")
+ }
+
+ resourceInfoDiffNamespace := resource.Info{Name: "name1", Namespace: "diff", Mapping: &meta.RESTMapping{GroupVersionKind: gvk}}
+ if isMatchingInfo(&resourceInfo, &resourceInfoDiffNamespace) {
+ t.Error("expected resources not equal")
+ }
+
+ gvkEqual := schema.GroupVersionKind{Group: "group1", Version: "version1", Kind: "pod"}
+ resourceInfoEqual := resource.Info{Name: "name1", Namespace: "namespace1", Mapping: &meta.RESTMapping{GroupVersionKind: gvkEqual}}
+ if !isMatchingInfo(&resourceInfo, &resourceInfoEqual) {
+ t.Error("expected resources to be equal")
+ }
+}
diff --git a/pkg/cli/roundtripper.go b/pkg/kube/roundtripper.go
similarity index 83%
rename from pkg/cli/roundtripper.go
rename to pkg/kube/roundtripper.go
index 9cd4eacba..52cb5bad2 100644
--- a/pkg/cli/roundtripper.go
+++ b/pkg/kube/roundtripper.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package cli
+package kube
import (
"bytes"
@@ -24,19 +24,19 @@ import (
"strings"
)
-type retryingRoundTripper struct {
- wrapped http.RoundTripper
+type RetryingRoundTripper struct {
+ Wrapped http.RoundTripper
}
-func (rt *retryingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+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) {
+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)
+ resp, rtErr := rt.Wrapped.RoundTrip(req)
if rtErr != nil {
return resp, rtErr
}
@@ -49,7 +49,7 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
- return resp, rtErr
+ return resp, err
}
var ke kubernetesError
@@ -58,10 +58,10 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp
r.Seek(0, io.SeekStart)
resp.Body = io.NopCloser(r)
if err != nil {
- return resp, rtErr
+ return resp, err
}
if ke.Code < 500 {
- return resp, rtErr
+ return resp, nil
}
// Matches messages like "etcdserver: leader changed"
if strings.HasSuffix(ke.Message, "etcdserver: leader changed") {
@@ -71,7 +71,7 @@ func (rt *retryingRoundTripper) roundTrip(req *http.Request, retry int, prevResp
if strings.HasSuffix(ke.Message, "raft proposal dropped") {
return rt.roundTrip(req, retry-1, resp)
}
- return resp, rtErr
+ return resp, nil
}
type kubernetesError struct {
diff --git a/pkg/kube/roundtripper_test.go b/pkg/kube/roundtripper_test.go
new file mode 100644
index 000000000..96602c1f4
--- /dev/null
+++ b/pkg/kube/roundtripper_test.go
@@ -0,0 +1,161 @@
+/*
+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 kube
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type fakeRoundTripper struct {
+ resp *http.Response
+ err error
+ calls int
+}
+
+func (f *fakeRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
+ f.calls++
+ return f.resp, f.err
+}
+
+func newRespWithBody(statusCode int, contentType, body string) *http.Response {
+ return &http.Response{
+ StatusCode: statusCode,
+ Header: http.Header{"Content-Type": []string{contentType}},
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+}
+
+func TestRetryingRoundTripper_RoundTrip(t *testing.T) {
+ marshalErr := func(code int, msg string) string {
+ b, _ := json.Marshal(kubernetesError{
+ Code: code,
+ Message: msg,
+ })
+ return string(b)
+ }
+
+ tests := []struct {
+ name string
+ resp *http.Response
+ err error
+ expectedCalls int
+ expectedErr string
+ expectedCode int
+ }{
+ {
+ name: "no retry, status < 500 returns response",
+ resp: newRespWithBody(200, "application/json", `{"message":"ok","code":200}`),
+ err: nil,
+ expectedCalls: 1,
+ expectedCode: 200,
+ },
+ {
+ name: "error from wrapped RoundTripper propagates",
+ resp: nil,
+ err: errors.New("wrapped error"),
+ expectedCalls: 1,
+ expectedErr: "wrapped error",
+ },
+ {
+ name: "no retry, content-type not application/json",
+ resp: newRespWithBody(500, "text/plain", "server error"),
+ err: nil,
+ expectedCalls: 1,
+ expectedCode: 500,
+ },
+ {
+ name: "error reading body returns error",
+ resp: &http.Response{
+ StatusCode: http.StatusInternalServerError,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: &errReader{},
+ },
+ err: nil,
+ expectedCalls: 1,
+ expectedErr: "read error",
+ },
+ {
+ name: "error decoding JSON returns error",
+ resp: newRespWithBody(500, "application/json", `invalid-json`),
+ err: nil,
+ expectedCalls: 1,
+ expectedErr: "invalid character",
+ },
+ {
+ name: "retry on etcdserver leader changed message",
+ resp: newRespWithBody(500, "application/json", marshalErr(500, "some error etcdserver: leader changed")),
+ err: nil,
+ expectedCalls: 2,
+ expectedCode: 500,
+ },
+ {
+ name: "retry on raft proposal dropped message",
+ resp: newRespWithBody(500, "application/json", marshalErr(500, "rpc error: code = Unknown desc = raft proposal dropped")),
+ err: nil,
+ expectedCalls: 2,
+ expectedCode: 500,
+ },
+ {
+ name: "no retry on other error message",
+ resp: newRespWithBody(500, "application/json", marshalErr(500, "other server error")),
+ err: nil,
+ expectedCalls: 1,
+ expectedCode: 500,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fakeRT := &fakeRoundTripper{
+ resp: tt.resp,
+ err: tt.err,
+ }
+ rt := RetryingRoundTripper{
+ Wrapped: fakeRT,
+ }
+ req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil)
+ resp, err := rt.RoundTrip(req)
+
+ if tt.expectedErr != "" {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedErr)
+ return
+ }
+ assert.NoError(t, err)
+
+ assert.Equal(t, tt.expectedCode, resp.StatusCode)
+ assert.Equal(t, tt.expectedCalls, fakeRT.calls)
+ })
+ }
+}
+
+type errReader struct{}
+
+func (e *errReader) Read(_ []byte) (int, error) {
+ return 0, errors.New("read error")
+}
+
+func (e *errReader) Close() error {
+ return nil
+}
diff --git a/pkg/kube/statuswait.go b/pkg/kube/statuswait.go
new file mode 100644
index 000000000..2d7cfe971
--- /dev/null
+++ b/pkg/kube/statuswait.go
@@ -0,0 +1,235 @@
+/*
+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 kube // import "helm.sh/helm/v3/pkg/kube"
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sort"
+ "time"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/aggregator"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/collector"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/watcher"
+ "github.com/fluxcd/cli-utils/pkg/object"
+ appsv1 "k8s.io/api/apps/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/client-go/dynamic"
+
+ helmStatusReaders "helm.sh/helm/v4/internal/statusreaders"
+)
+
+type statusWaiter struct {
+ client dynamic.Interface
+ restMapper meta.RESTMapper
+}
+
+func alwaysReady(_ *unstructured.Unstructured) (*status.Result, error) {
+ return &status.Result{
+ Status: status.CurrentStatus,
+ Message: "Resource is current",
+ }, nil
+}
+
+func (w *statusWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
+ sw := watcher.NewDefaultStatusWatcher(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
+ genericSR := statusreaders.NewGenericStatusReader(w.restMapper, alwaysReady)
+
+ sr := &statusreaders.DelegatingStatusReader{
+ StatusReaders: []engine.StatusReader{
+ jobSR,
+ podSR,
+ genericSR,
+ },
+ }
+ sw.StatusReader = sr
+ return w.wait(ctx, resourceList, sw)
+}
+
+func (w *statusWaiter) Wait(resourceList ResourceList, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(context.TODO(), timeout)
+ defer cancel()
+ slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
+ sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
+ return w.wait(ctx, resourceList, sw)
+}
+
+func (w *statusWaiter) WaitWithJobs(resourceList ResourceList, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(context.TODO(), timeout)
+ defer cancel()
+ slog.Debug("waiting for resources", "count", len(resourceList), "timeout", timeout)
+ sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
+ newCustomJobStatusReader := helmStatusReaders.NewCustomJobStatusReader(w.restMapper)
+ customSR := statusreaders.NewStatusReader(w.restMapper, newCustomJobStatusReader)
+ sw.StatusReader = customSR
+ return w.wait(ctx, resourceList, sw)
+}
+
+func (w *statusWaiter) WaitForDelete(resourceList ResourceList, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(context.TODO(), timeout)
+ defer cancel()
+ slog.Debug("waiting for resources to be deleted", "count", len(resourceList), "timeout", timeout)
+ sw := watcher.NewDefaultStatusWatcher(w.client, w.restMapper)
+ return w.waitForDelete(ctx, resourceList, sw)
+}
+
+func (w *statusWaiter) waitForDelete(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
+ cancelCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ resources := []object.ObjMetadata{}
+ for _, resource := range resourceList {
+ obj, err := object.RuntimeToObjMeta(resource.Object)
+ if err != nil {
+ return err
+ }
+ resources = append(resources, obj)
+ }
+ eventCh := sw.Watch(cancelCtx, resources, watcher.Options{})
+ statusCollector := collector.NewResourceStatusCollector(resources)
+ done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.NotFoundStatus))
+ <-done
+
+ if statusCollector.Error != nil {
+ return statusCollector.Error
+ }
+
+ // Only check parent context error, otherwise we would error when desired status is achieved.
+ if ctx.Err() != nil {
+ errs := []error{}
+ for _, id := range resources {
+ rs := statusCollector.ResourceStatuses[id]
+ if rs.Status == status.NotFoundStatus {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("resource still exists, name: %s, kind: %s, status: %s", rs.Identifier.Name, rs.Identifier.GroupKind.Kind, rs.Status))
+ }
+ errs = append(errs, ctx.Err())
+ return errors.Join(errs...)
+ }
+ return nil
+}
+
+func (w *statusWaiter) wait(ctx context.Context, resourceList ResourceList, sw watcher.StatusWatcher) error {
+ cancelCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ resources := []object.ObjMetadata{}
+ for _, resource := range resourceList {
+ switch value := AsVersioned(resource).(type) {
+ case *appsv1.Deployment:
+ if value.Spec.Paused {
+ continue
+ }
+ }
+ obj, err := object.RuntimeToObjMeta(resource.Object)
+ if err != nil {
+ return err
+ }
+ resources = append(resources, obj)
+ }
+
+ eventCh := sw.Watch(cancelCtx, resources, watcher.Options{})
+ statusCollector := collector.NewResourceStatusCollector(resources)
+ done := statusCollector.ListenWithObserver(eventCh, statusObserver(cancel, status.CurrentStatus))
+ <-done
+
+ if statusCollector.Error != nil {
+ return statusCollector.Error
+ }
+
+ // Only check parent context error, otherwise we would error when desired status is achieved.
+ if ctx.Err() != nil {
+ errs := []error{}
+ for _, id := range resources {
+ rs := statusCollector.ResourceStatuses[id]
+ if rs.Status == status.CurrentStatus {
+ continue
+ }
+ errs = append(errs, fmt.Errorf("resource not ready, name: %s, kind: %s, status: %s", rs.Identifier.Name, rs.Identifier.GroupKind.Kind, rs.Status))
+ }
+ errs = append(errs, ctx.Err())
+ return errors.Join(errs...)
+ }
+ return nil
+}
+
+func statusObserver(cancel context.CancelFunc, desired status.Status) collector.ObserverFunc {
+ return func(statusCollector *collector.ResourceStatusCollector, _ event.Event) {
+ var rss []*event.ResourceStatus
+ var nonDesiredResources []*event.ResourceStatus
+ for _, rs := range statusCollector.ResourceStatuses {
+ if rs == nil {
+ continue
+ }
+ // If a resource is already deleted before waiting has started, it will show as unknown
+ // this check ensures we don't wait forever for a resource that is already deleted
+ if rs.Status == status.UnknownStatus && desired == status.NotFoundStatus {
+ continue
+ }
+ rss = append(rss, rs)
+ if rs.Status != desired {
+ nonDesiredResources = append(nonDesiredResources, rs)
+ }
+ }
+
+ if aggregator.AggregateStatus(rss, desired) == desired {
+ cancel()
+ return
+ }
+
+ if len(nonDesiredResources) > 0 {
+ // Log a single resource so the user knows what they're waiting for without an overwhelming amount of output
+ sort.Slice(nonDesiredResources, func(i, j int) bool {
+ return nonDesiredResources[i].Identifier.Name < nonDesiredResources[j].Identifier.Name
+ })
+ first := nonDesiredResources[0]
+ slog.Debug("waiting for resource", "name", first.Identifier.Name, "kind", first.Identifier.GroupKind.Kind, "expectedStatus", desired, "actualStatus", first.Status)
+ }
+ }
+}
+
+type hookOnlyWaiter struct {
+ sw *statusWaiter
+}
+
+func (w *hookOnlyWaiter) WatchUntilReady(resourceList ResourceList, timeout time.Duration) error {
+ return w.sw.WatchUntilReady(resourceList, timeout)
+}
+
+func (w *hookOnlyWaiter) Wait(_ ResourceList, _ time.Duration) error {
+ return nil
+}
+
+func (w *hookOnlyWaiter) WaitWithJobs(_ ResourceList, _ time.Duration) error {
+ return nil
+}
+
+func (w *hookOnlyWaiter) WaitForDelete(_ ResourceList, _ time.Duration) error {
+ return nil
+}
diff --git a/pkg/kube/statuswait_test.go b/pkg/kube/statuswait_test.go
new file mode 100644
index 000000000..4b06da896
--- /dev/null
+++ b/pkg/kube/statuswait_test.go
@@ -0,0 +1,450 @@
+/*
+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 kube // import "helm.sh/helm/v3/pkg/kube"
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/fluxcd/cli-utils/pkg/testutil"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ appsv1 "k8s.io/api/apps/v1"
+ batchv1 "k8s.io/api/batch/v1"
+ v1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/util/yaml"
+ dynamicfake "k8s.io/client-go/dynamic/fake"
+ "k8s.io/kubectl/pkg/scheme"
+)
+
+var podCurrentManifest = `
+apiVersion: v1
+kind: Pod
+metadata:
+ name: current-pod
+ namespace: ns
+status:
+ conditions:
+ - type: Ready
+ status: "True"
+ phase: Running
+`
+
+var podNoStatusManifest = `
+apiVersion: v1
+kind: Pod
+metadata:
+ name: in-progress-pod
+ namespace: ns
+`
+
+var jobNoStatusManifest = `
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: test
+ namespace: qual
+ generation: 1
+`
+
+var jobReadyManifest = `
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: ready-not-complete
+ namespace: default
+ generation: 1
+status:
+ startTime: 2025-02-06T16:34:20-05:00
+ active: 1
+ ready: 1
+`
+
+var jobCompleteManifest = `
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: test
+ namespace: qual
+ generation: 1
+status:
+ succeeded: 1
+ active: 0
+ conditions:
+ - type: Complete
+ status: "True"
+`
+
+var podCompleteManifest = `
+apiVersion: v1
+kind: Pod
+metadata:
+ name: good-pod
+ namespace: ns
+status:
+ phase: Succeeded
+`
+
+var pausedDeploymentManifest = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: paused
+ namespace: ns-1
+ generation: 1
+spec:
+ paused: true
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:1.19.6
+ ports:
+ - containerPort: 80
+`
+
+var notReadyDeploymentManifest = `
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: not-ready
+ namespace: ns-1
+ generation: 1
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:1.19.6
+ ports:
+ - containerPort: 80
+`
+
+func getGVR(t *testing.T, mapper meta.RESTMapper, obj *unstructured.Unstructured) schema.GroupVersionResource {
+ t.Helper()
+ gvk := obj.GroupVersionKind()
+ mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
+ require.NoError(t, err)
+ return mapping.Resource
+}
+
+func getRuntimeObjFromManifests(t *testing.T, manifests []string) []runtime.Object {
+ t.Helper()
+ objects := []runtime.Object{}
+ for _, manifest := range manifests {
+ m := make(map[string]interface{})
+ err := yaml.Unmarshal([]byte(manifest), &m)
+ assert.NoError(t, err)
+ resource := &unstructured.Unstructured{Object: m}
+ objects = append(objects, resource)
+ }
+ return objects
+}
+
+func getResourceListFromRuntimeObjs(t *testing.T, c *Client, objs []runtime.Object) ResourceList {
+ t.Helper()
+ resourceList := ResourceList{}
+ for _, obj := range objs {
+ list, err := c.Build(objBody(obj), false)
+ assert.NoError(t, err)
+ resourceList = append(resourceList, list...)
+ }
+ return resourceList
+}
+
+func TestStatusWaitForDelete(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ manifestsToCreate []string
+ manifestsToDelete []string
+ expectErrs []error
+ }{
+ {
+ name: "wait for pod to be deleted",
+ manifestsToCreate: []string{podCurrentManifest},
+ manifestsToDelete: []string{podCurrentManifest},
+ expectErrs: nil,
+ },
+ {
+ name: "error when not all objects are deleted",
+ manifestsToCreate: []string{jobCompleteManifest, podCurrentManifest},
+ manifestsToDelete: []string{jobCompleteManifest},
+ expectErrs: []error{errors.New("resource still exists, name: current-pod, kind: Pod, status: Current"), errors.New("context deadline exceeded")},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, 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"),
+ batchv1.SchemeGroupVersion.WithKind("Job"),
+ )
+ statusWaiter := statusWaiter{
+ restMapper: fakeMapper,
+ client: fakeClient,
+ }
+ objsToCreate := getRuntimeObjFromManifests(t, tt.manifestsToCreate)
+ for _, objToCreate := range objsToCreate {
+ u := objToCreate.(*unstructured.Unstructured)
+ gvr := getGVR(t, fakeMapper, u)
+ err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace())
+ assert.NoError(t, err)
+ }
+ objsToDelete := getRuntimeObjFromManifests(t, tt.manifestsToDelete)
+ for _, objToDelete := range objsToDelete {
+ u := objToDelete.(*unstructured.Unstructured)
+ gvr := getGVR(t, fakeMapper, u)
+ go func() {
+ time.Sleep(timeUntilPodDelete)
+ err := fakeClient.Tracker().Delete(gvr, u.GetNamespace(), u.GetName())
+ assert.NoError(t, err)
+ }()
+ }
+ resourceList := getResourceListFromRuntimeObjs(t, c, objsToCreate)
+ err := statusWaiter.WaitForDelete(resourceList, timeout)
+ if tt.expectErrs != nil {
+ assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error())
+ return
+ }
+ assert.NoError(t, err)
+ })
+ }
+}
+
+func TestStatusWaitForDeleteNonExistentObject(t *testing.T) {
+ t.Parallel()
+ c := newTestClient(t)
+ timeout := time.Second
+ fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
+ fakeMapper := testutil.NewFakeRESTMapper(
+ v1.SchemeGroupVersion.WithKind("Pod"),
+ )
+ statusWaiter := statusWaiter{
+ restMapper: fakeMapper,
+ client: fakeClient,
+ }
+ // Don't create the object to test that the wait for delete works when the object doesn't exist
+ objManifest := getRuntimeObjFromManifests(t, []string{podCurrentManifest})
+ resourceList := getResourceListFromRuntimeObjs(t, c, objManifest)
+ err := statusWaiter.WaitForDelete(resourceList, timeout)
+ assert.NoError(t, err)
+}
+
+func TestStatusWait(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ objManifests []string
+ expectErrs []error
+ waitForJobs bool
+ }{
+ {
+ name: "Job is not complete",
+ objManifests: []string{jobNoStatusManifest},
+ expectErrs: []error{errors.New("resource not ready, name: test, kind: Job, status: InProgress"), errors.New("context deadline exceeded")},
+ waitForJobs: true,
+ },
+ {
+ name: "Job is ready but not complete",
+ objManifests: []string{jobReadyManifest},
+ expectErrs: nil,
+ waitForJobs: false,
+ },
+ {
+ name: "Pod is ready",
+ objManifests: []string{podCurrentManifest},
+ expectErrs: nil,
+ },
+ {
+ name: "one of the pods never becomes ready",
+ objManifests: []string{podNoStatusManifest, podCurrentManifest},
+ expectErrs: []error{errors.New("resource not ready, name: in-progress-pod, kind: Pod, status: InProgress"), errors.New("context deadline exceeded")},
+ },
+ {
+ name: "paused deployment passes",
+ objManifests: []string{pausedDeploymentManifest},
+ expectErrs: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ c := newTestClient(t)
+ fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
+ fakeMapper := testutil.NewFakeRESTMapper(
+ v1.SchemeGroupVersion.WithKind("Pod"),
+ appsv1.SchemeGroupVersion.WithKind("Deployment"),
+ batchv1.SchemeGroupVersion.WithKind("Job"),
+ )
+ statusWaiter := statusWaiter{
+ client: fakeClient,
+ restMapper: fakeMapper,
+ }
+ objs := getRuntimeObjFromManifests(t, tt.objManifests)
+ for _, obj := range objs {
+ u := obj.(*unstructured.Unstructured)
+ gvr := getGVR(t, fakeMapper, u)
+ err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace())
+ assert.NoError(t, err)
+ }
+ resourceList := getResourceListFromRuntimeObjs(t, c, objs)
+ err := statusWaiter.Wait(resourceList, time.Second*3)
+ if tt.expectErrs != nil {
+ assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error())
+ return
+ }
+ assert.NoError(t, err)
+ })
+ }
+}
+
+func TestWaitForJobComplete(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ objManifests []string
+ expectErrs []error
+ }{
+ {
+ name: "Job is complete",
+ objManifests: []string{jobCompleteManifest},
+ },
+ {
+ name: "Job is not ready",
+ objManifests: []string{jobNoStatusManifest},
+ expectErrs: []error{errors.New("resource not ready, name: test, kind: Job, status: InProgress"), errors.New("context deadline exceeded")},
+ },
+ {
+ name: "Job is ready but not complete",
+ objManifests: []string{jobReadyManifest},
+ expectErrs: []error{errors.New("resource not ready, name: ready-not-complete, kind: Job, status: InProgress"), errors.New("context deadline exceeded")},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ c := newTestClient(t)
+ fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
+ fakeMapper := testutil.NewFakeRESTMapper(
+ batchv1.SchemeGroupVersion.WithKind("Job"),
+ )
+ statusWaiter := statusWaiter{
+ client: fakeClient,
+ restMapper: fakeMapper,
+ }
+ objs := getRuntimeObjFromManifests(t, tt.objManifests)
+ for _, obj := range objs {
+ u := obj.(*unstructured.Unstructured)
+ gvr := getGVR(t, fakeMapper, u)
+ err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace())
+ assert.NoError(t, err)
+ }
+ resourceList := getResourceListFromRuntimeObjs(t, c, objs)
+ err := statusWaiter.WaitWithJobs(resourceList, time.Second*3)
+ if tt.expectErrs != nil {
+ assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error())
+ return
+ }
+ assert.NoError(t, err)
+ })
+ }
+}
+
+func TestWatchForReady(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ objManifests []string
+ expectErrs []error
+ }{
+ {
+ name: "succeeds if pod and job are complete",
+ objManifests: []string{jobCompleteManifest, podCompleteManifest},
+ },
+ {
+ name: "succeeds when a resource that's not a pod or job is not ready",
+ objManifests: []string{notReadyDeploymentManifest},
+ },
+ {
+ name: "Fails if job is not complete",
+ objManifests: []string{jobReadyManifest},
+ expectErrs: []error{errors.New("resource not ready, name: ready-not-complete, kind: Job, status: InProgress"), errors.New("context deadline exceeded")},
+ },
+ {
+ name: "Fails if pod is not complete",
+ objManifests: []string{podCurrentManifest},
+ expectErrs: []error{errors.New("resource not ready, name: current-pod, kind: Pod, status: InProgress"), errors.New("context deadline exceeded")},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ c := newTestClient(t)
+ fakeClient := dynamicfake.NewSimpleDynamicClient(scheme.Scheme)
+ fakeMapper := testutil.NewFakeRESTMapper(
+ v1.SchemeGroupVersion.WithKind("Pod"),
+ appsv1.SchemeGroupVersion.WithKind("Deployment"),
+ batchv1.SchemeGroupVersion.WithKind("Job"),
+ )
+ statusWaiter := statusWaiter{
+ client: fakeClient,
+ restMapper: fakeMapper,
+ }
+ objs := getRuntimeObjFromManifests(t, tt.objManifests)
+ for _, obj := range objs {
+ u := obj.(*unstructured.Unstructured)
+ gvr := getGVR(t, fakeMapper, u)
+ err := fakeClient.Tracker().Create(gvr, u, u.GetNamespace())
+ assert.NoError(t, err)
+ }
+ resourceList := getResourceListFromRuntimeObjs(t, c, objs)
+ err := statusWaiter.WatchUntilReady(resourceList, time.Second*3)
+ if tt.expectErrs != nil {
+ assert.EqualError(t, err, errors.Join(tt.expectErrs...).Error())
+ return
+ }
+ assert.NoError(t, err)
+ })
+ }
+}
diff --git a/pkg/kube/wait.go b/pkg/kube/wait.go
index ecdd38940..9bfa1ef6d 100644
--- a/pkg/kube/wait.go
+++ b/pkg/kube/wait.go
@@ -14,14 +14,15 @@ 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 "helm.sh/helm/v4/pkg/kube"
import (
"context"
"fmt"
+ "log/slog"
+ "net/http"
"time"
- "github.com/pkg/errors"
appsv1 "k8s.io/api/apps/v1"
appsv1beta1 "k8s.io/api/apps/v1beta1"
appsv1beta2 "k8s.io/api/apps/v1beta2"
@@ -30,30 +31,65 @@ import (
extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/watch"
+ "k8s.io/cli-runtime/pkg/resource"
+ "k8s.io/client-go/kubernetes"
+ cachetools "k8s.io/client-go/tools/cache"
+ watchtools "k8s.io/client-go/tools/watch"
"k8s.io/apimachinery/pkg/util/wait"
)
-type waiter struct {
- c ReadyChecker
- timeout time.Duration
- log func(string, ...interface{})
+// legacyWaiter is the legacy implementation of the Waiter interface. This logic was used by default in Helm 3
+// Helm 4 now uses the StatusWaiter implementation instead
+type legacyWaiter struct {
+ c ReadyChecker
+ kubeClient *kubernetes.Clientset
+}
+
+func (hw *legacyWaiter) Wait(resources ResourceList, timeout time.Duration) error {
+ hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true))
+ return hw.waitForResources(resources, timeout)
+}
+
+func (hw *legacyWaiter) WaitWithJobs(resources ResourceList, timeout time.Duration) error {
+ hw.c = NewReadyChecker(hw.kubeClient, PausedAsReady(true), CheckJobs(true))
+ return hw.waitForResources(resources, timeout)
}
// waitForResources polls to get the current status of all pods, PVCs, Services and
// Jobs(optional) until all are ready or a timeout is reached
-func (w *waiter) waitForResources(created ResourceList) error {
- w.log("beginning wait for %d resources with timeout of %v", len(created), w.timeout)
+func (hw *legacyWaiter) waitForResources(created ResourceList, timeout time.Duration) error {
+ slog.Debug("beginning wait for resources", "count", len(created), "timeout", timeout)
- ctx, cancel := context.WithTimeout(context.Background(), w.timeout)
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
+ numberOfErrors := make([]int, len(created))
+ for i := range numberOfErrors {
+ numberOfErrors[i] = 0
+ }
+
return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) {
- for _, v := range created {
- ready, err := w.c.IsReady(ctx, v)
- if !ready || err != nil {
+ waitRetries := 30
+ for i, v := range created {
+ ready, err := hw.c.IsReady(ctx, v)
+
+ if waitRetries > 0 && hw.isRetryableError(err, v) {
+ numberOfErrors[i]++
+ if numberOfErrors[i] > waitRetries {
+ slog.Debug("max number of retries reached", "resource", v.Name, "retries", numberOfErrors[i])
+ return false, err
+ }
+ slog.Debug("retrying resource readiness", "resource", v.Name, "currentRetries", numberOfErrors[i]-1, "maxRetries", waitRetries)
+ return false, nil
+ }
+ numberOfErrors[i] = 0
+ if !ready {
return false, err
}
}
@@ -61,14 +97,34 @@ func (w *waiter) waitForResources(created ResourceList) error {
})
}
-// waitForDeletedResources polls to check if all the resources are deleted or a timeout is reached
-func (w *waiter) waitForDeletedResources(deleted ResourceList) error {
- w.log("beginning wait for %d resources to be deleted with timeout of %v", len(deleted), w.timeout)
+func (hw *legacyWaiter) isRetryableError(err error, resource *resource.Info) bool {
+ if err == nil {
+ return false
+ }
+ slog.Debug("error received when checking resource status", "resource", resource.Name, slog.Any("error", err))
+ if ev, ok := err.(*apierrors.StatusError); ok {
+ statusCode := ev.Status().Code
+ retryable := hw.isRetryableHTTPStatusCode(statusCode)
+ slog.Debug("status code received", "resource", resource.Name, "statusCode", statusCode, "retryable", retryable)
+ return retryable
+ }
+ slog.Debug("retryable error assumed", "resource", resource.Name)
+ return true
+}
- ctx, cancel := context.WithTimeout(context.Background(), w.timeout)
+func (hw *legacyWaiter) isRetryableHTTPStatusCode(httpStatusCode int32) bool {
+ return httpStatusCode == 0 || httpStatusCode == http.StatusTooManyRequests || (httpStatusCode >= 500 && httpStatusCode != http.StatusNotImplemented)
+}
+
+// WaitForDelete polls to check if all the resources are deleted or a timeout is reached
+func (hw *legacyWaiter) WaitForDelete(deleted ResourceList, timeout time.Duration) error {
+ slog.Debug("beginning wait for resources to be deleted", "count", len(deleted), "timeout", timeout)
+
+ startTime := time.Now()
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
- return wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) {
+ err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(_ context.Context) (bool, error) {
for _, v := range deleted {
err := v.Get()
if err == nil || !apierrors.IsNotFound(err) {
@@ -77,6 +133,15 @@ func (w *waiter) waitForDeletedResources(deleted ResourceList) error {
}
return true, nil
})
+
+ elapsed := time.Since(startTime).Round(time.Second)
+ if err != nil {
+ slog.Debug("wait for resources failed", "elapsed", elapsed, slog.Any("error", err))
+ } else {
+ slog.Debug("wait for resources succeeded", "elapsed", elapsed)
+ }
+
+ return err
}
// SelectorsForObject returns the pod label selector for a given object
@@ -115,7 +180,7 @@ func SelectorsForObject(object runtime.Object) (selector labels.Selector, err er
case *batchv1.Job:
selector, err = metav1.LabelSelectorAsSelector(t.Spec.Selector)
case *corev1.Service:
- if t.Spec.Selector == nil || len(t.Spec.Selector) == 0 {
+ if len(t.Spec.Selector) == 0 {
return nil, fmt.Errorf("invalid service '%s': Service is defined without a selector", t.Name)
}
selector = labels.SelectorFromSet(t.Spec.Selector)
@@ -124,5 +189,141 @@ func SelectorsForObject(object runtime.Object) (selector labels.Selector, err er
return nil, fmt.Errorf("selector for %T not implemented", object)
}
- return selector, errors.Wrap(err, "invalid label selector")
+ if err != nil {
+ return selector, fmt.Errorf("invalid label selector: %w", err)
+ }
+
+ return selector, nil
+}
+
+func (hw *legacyWaiter) watchTimeout(t time.Duration) func(*resource.Info) error {
+ return func(info *resource.Info) error {
+ return hw.watchUntilReady(t, info)
+ }
+}
+
+// WatchUntilReady watches the resources given and waits until it is ready.
+//
+// This method is mainly for hook implementations. It watches for a resource to
+// hit a particular milestone. The milestone depends on the Kind.
+//
+// For most kinds, it checks to see if the resource is marked as Added or Modified
+// by the Kubernetes event stream. For some kinds, it does more:
+//
+// - Jobs: A job is marked "Ready" when it has successfully completed. This is
+// ascertained by watching the Status fields in a job's output.
+// - Pods: A pod is marked "Ready" when it has successfully completed. This is
+// ascertained by watching the status.phase field in a pod's output.
+//
+// Handling for other kinds will be added as necessary.
+func (hw *legacyWaiter) WatchUntilReady(resources ResourceList, timeout time.Duration) error {
+ // For jobs, there's also the option to do poll c.Jobs(namespace).Get():
+ // https://github.com/adamreese/kubernetes/blob/master/test/e2e/job.go#L291-L300
+ return perform(resources, hw.watchTimeout(timeout))
+}
+
+func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.Info) error {
+ kind := info.Mapping.GroupVersionKind.Kind
+ switch kind {
+ case "Job", "Pod":
+ default:
+ return nil
+ }
+
+ slog.Debug("watching for resource changes", "kind", kind, "resource", info.Name, "timeout", timeout)
+
+ // 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))
+ if err != nil {
+ return err
+ }
+ lw := cachetools.NewListWatchFromClient(info.Client, info.Mapping.Resource.Resource, info.Namespace, selector)
+
+ // What we watch for depends on the Kind.
+ // - For a Job, we watch for completion.
+ // - For all else, we watch until Ready.
+ // In the future, we might want to add some special logic for types
+ // like Ingress, Volume, etc.
+
+ ctx, cancel := watchtools.ContextWithOptionalTimeout(context.Background(), timeout)
+ defer cancel()
+ _, err = watchtools.UntilWithSync(ctx, lw, &unstructured.Unstructured{}, nil, func(e watch.Event) (bool, error) {
+ // Make sure the incoming object is versioned as we use unstructured
+ // objects when we build manifests
+ obj := convertWithMapper(e.Object, info.Mapping)
+ switch e.Type {
+ case watch.Added, watch.Modified:
+ // For things like a secret or a config map, this is the best indicator
+ // we get. We care mostly about jobs, where what we want to see is
+ // the status go into a good state. For other types, like ReplicaSet
+ // we don't really do anything to support these as hooks.
+ slog.Debug("add/modify event received", "resource", info.Name, "eventType", e.Type)
+
+ switch kind {
+ case "Job":
+ return hw.waitForJob(obj, info.Name)
+ case "Pod":
+ return hw.waitForPodSuccess(obj, info.Name)
+ }
+ return true, nil
+ case watch.Deleted:
+ slog.Debug("deleted event received", "resource", info.Name)
+ return true, nil
+ case watch.Error:
+ // Handle error and return with an error.
+ slog.Error("error event received", "resource", info.Name)
+ return true, fmt.Errorf("failed to deploy %s", info.Name)
+ default:
+ return false, nil
+ }
+ })
+ return err
+}
+
+// waitForJob is a helper that waits for a job to complete.
+//
+// This operates on an event returned from a watcher.
+func (hw *legacyWaiter) waitForJob(obj runtime.Object, name string) (bool, error) {
+ o, ok := obj.(*batchv1.Job)
+ if !ok {
+ return true, fmt.Errorf("expected %s to be a *batch.Job, got %T", name, obj)
+ }
+
+ for _, c := range o.Status.Conditions {
+ if c.Type == batchv1.JobComplete && c.Status == "True" {
+ return true, nil
+ } else if c.Type == batchv1.JobFailed && c.Status == "True" {
+ slog.Error("job failed", "job", name, "reason", c.Reason)
+ return true, fmt.Errorf("job %s failed: %s", name, c.Reason)
+ }
+ }
+
+ slog.Debug("job status update", "job", name, "active", o.Status.Active, "failed", o.Status.Failed, "succeeded", o.Status.Succeeded)
+ return false, nil
+}
+
+// waitForPodSuccess is a helper that waits for a pod to complete.
+//
+// This operates on an event returned from a watcher.
+func (hw *legacyWaiter) waitForPodSuccess(obj runtime.Object, name string) (bool, error) {
+ o, ok := obj.(*corev1.Pod)
+ if !ok {
+ return true, fmt.Errorf("expected %s to be a *v1.Pod, got %T", name, obj)
+ }
+
+ switch o.Status.Phase {
+ case corev1.PodSucceeded:
+ slog.Debug("pod succeeded", "pod", o.Name)
+ return true, nil
+ case corev1.PodFailed:
+ slog.Error("pod failed", "pod", o.Name)
+ return true, fmt.Errorf("pod %s failed", o.Name)
+ case corev1.PodPending:
+ slog.Debug("pod pending", "pod", o.Name)
+ case corev1.PodRunning:
+ slog.Debug("pod running", "pod", o.Name)
+ }
+
+ return false, nil
}
diff --git a/pkg/kube/wait_test.go b/pkg/kube/wait_test.go
new file mode 100644
index 000000000..d96f2c486
--- /dev/null
+++ b/pkg/kube/wait_test.go
@@ -0,0 +1,467 @@
+/*
+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 kube
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ appsv1 "k8s.io/api/apps/v1"
+ appsv1beta1 "k8s.io/api/apps/v1beta1"
+ appsv1beta2 "k8s.io/api/apps/v1beta2"
+ batchv1 "k8s.io/api/batch/v1"
+ corev1 "k8s.io/api/core/v1"
+ extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/labels"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/cli-runtime/pkg/resource"
+)
+
+func TestSelectorsForObject(t *testing.T) {
+ tests := []struct {
+ name string
+ object interface{}
+ expectError bool
+ errorContains string
+ expectedLabels map[string]string
+ }{
+ {
+ name: "appsv1 ReplicaSet",
+ object: &appsv1.ReplicaSet{
+ Spec: appsv1.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "test"},
+ },
+ },
+ },
+ expectError: false,
+ expectedLabels: map[string]string{"app": "test"},
+ },
+ {
+ name: "extensionsv1beta1 ReplicaSet",
+ object: &extensionsv1beta1.ReplicaSet{
+ Spec: extensionsv1beta1.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "ext-rs"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "ext-rs"},
+ },
+ {
+ name: "appsv1beta2 ReplicaSet",
+ object: &appsv1beta2.ReplicaSet{
+ Spec: appsv1beta2.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "beta2-rs"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "beta2-rs"},
+ },
+ {
+ name: "corev1 ReplicationController",
+ object: &corev1.ReplicationController{
+ Spec: corev1.ReplicationControllerSpec{
+ Selector: map[string]string{"rc": "test"},
+ },
+ },
+ expectError: false,
+ expectedLabels: map[string]string{"rc": "test"},
+ },
+ {
+ name: "appsv1 StatefulSet",
+ object: &appsv1.StatefulSet{
+ Spec: appsv1.StatefulSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-v1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "statefulset-v1"},
+ },
+ {
+ name: "appsv1beta1 StatefulSet",
+ object: &appsv1beta1.StatefulSet{
+ Spec: appsv1beta1.StatefulSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-beta1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "statefulset-beta1"},
+ },
+ {
+ name: "appsv1beta2 StatefulSet",
+ object: &appsv1beta2.StatefulSet{
+ Spec: appsv1beta2.StatefulSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "statefulset-beta2"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "statefulset-beta2"},
+ },
+ {
+ name: "extensionsv1beta1 DaemonSet",
+ object: &extensionsv1beta1.DaemonSet{
+ Spec: extensionsv1beta1.DaemonSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-ext-beta1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "daemonset-ext-beta1"},
+ },
+ {
+ name: "appsv1 DaemonSet",
+ object: &appsv1.DaemonSet{
+ Spec: appsv1.DaemonSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-v1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "daemonset-v1"},
+ },
+ {
+ name: "appsv1beta2 DaemonSet",
+ object: &appsv1beta2.DaemonSet{
+ Spec: appsv1beta2.DaemonSetSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "daemonset-beta2"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "daemonset-beta2"},
+ },
+ {
+ name: "extensionsv1beta1 Deployment",
+ object: &extensionsv1beta1.Deployment{
+ Spec: extensionsv1beta1.DeploymentSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-ext-beta1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "deployment-ext-beta1"},
+ },
+ {
+ name: "appsv1 Deployment",
+ object: &appsv1.Deployment{
+ Spec: appsv1.DeploymentSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-v1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "deployment-v1"},
+ },
+ {
+ name: "appsv1beta1 Deployment",
+ object: &appsv1beta1.Deployment{
+ Spec: appsv1beta1.DeploymentSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-beta1"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "deployment-beta1"},
+ },
+ {
+ name: "appsv1beta2 Deployment",
+ object: &appsv1beta2.Deployment{
+ Spec: appsv1beta2.DeploymentSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "deployment-beta2"}},
+ },
+ },
+ expectedLabels: map[string]string{"app": "deployment-beta2"},
+ },
+ {
+ name: "batchv1 Job",
+ object: &batchv1.Job{
+ Spec: batchv1.JobSpec{
+ Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"job": "batch-job"}},
+ },
+ },
+ expectedLabels: map[string]string{"job": "batch-job"},
+ },
+ {
+ name: "corev1 Service with selector",
+ object: &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "svc"},
+ Spec: corev1.ServiceSpec{
+ Selector: map[string]string{"svc": "yes"},
+ },
+ },
+ expectError: false,
+ expectedLabels: map[string]string{"svc": "yes"},
+ },
+ {
+ name: "corev1 Service without selector",
+ object: &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{Name: "svc"},
+ Spec: corev1.ServiceSpec{Selector: map[string]string{}},
+ },
+ expectError: true,
+ errorContains: "invalid service 'svc': Service is defined without a selector",
+ },
+ {
+ name: "invalid label selector",
+ object: &appsv1.ReplicaSet{
+ Spec: appsv1.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchExpressions: []metav1.LabelSelectorRequirement{
+ {
+ Key: "foo",
+ Operator: "InvalidOperator",
+ Values: []string{"bar"},
+ },
+ },
+ },
+ },
+ },
+ expectError: true,
+ errorContains: "invalid label selector:",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ selector, err := SelectorsForObject(tt.object.(runtime.Object))
+ if tt.expectError {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errorContains)
+ } else {
+ assert.NoError(t, err)
+ expected := labels.Set(tt.expectedLabels)
+ assert.True(t, selector.Matches(expected), "expected selector to match")
+ }
+ })
+ }
+}
+
+func TestLegacyWaiter_waitForPodSuccess(t *testing.T) {
+ lw := &legacyWaiter{}
+
+ tests := []struct {
+ name string
+ obj runtime.Object
+ wantDone bool
+ wantErr bool
+ errMessage string
+ }{
+ {
+ name: "pod succeeded",
+ obj: &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod1"},
+ Status: corev1.PodStatus{Phase: corev1.PodSucceeded},
+ },
+ wantDone: true,
+ wantErr: false,
+ },
+ {
+ name: "pod failed",
+ obj: &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod2"},
+ Status: corev1.PodStatus{Phase: corev1.PodFailed},
+ },
+ wantDone: true,
+ wantErr: true,
+ errMessage: "pod pod2 failed",
+ },
+ {
+ name: "pod pending",
+ obj: &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod3"},
+ Status: corev1.PodStatus{Phase: corev1.PodPending},
+ },
+ wantDone: false,
+ wantErr: false,
+ },
+ {
+ name: "pod running",
+ obj: &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{Name: "pod4"},
+ Status: corev1.PodStatus{Phase: corev1.PodRunning},
+ },
+ wantDone: false,
+ wantErr: false,
+ },
+ {
+ name: "wrong object type",
+ obj: &metav1.Status{},
+ wantDone: true,
+ wantErr: true,
+ errMessage: "expected foo to be a *v1.Pod, got *v1.Status",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ done, err := lw.waitForPodSuccess(tt.obj, "foo")
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("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())
+ }
+ } else if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if done != tt.wantDone {
+ t.Errorf("got done=%v, want %v", done, tt.wantDone)
+ }
+ })
+ }
+}
+
+func TestLegacyWaiter_waitForJob(t *testing.T) {
+ lw := &legacyWaiter{}
+
+ tests := []struct {
+ name string
+ obj runtime.Object
+ wantDone bool
+ wantErr bool
+ errMessage string
+ }{
+ {
+ name: "job complete",
+ obj: &batchv1.Job{
+ Status: batchv1.JobStatus{
+ Conditions: []batchv1.JobCondition{
+ {
+ Type: batchv1.JobComplete,
+ Status: "True",
+ },
+ },
+ },
+ },
+ wantDone: true,
+ wantErr: false,
+ },
+ {
+ name: "job failed",
+ obj: &batchv1.Job{
+ Status: batchv1.JobStatus{
+ Conditions: []batchv1.JobCondition{
+ {
+ Type: batchv1.JobFailed,
+ Status: "True",
+ Reason: "FailedReason",
+ },
+ },
+ },
+ },
+ wantDone: true,
+ wantErr: true,
+ errMessage: "job test-job failed: FailedReason",
+ },
+ {
+ name: "job in progress",
+ obj: &batchv1.Job{
+ Status: batchv1.JobStatus{
+ Active: 1,
+ Failed: 0,
+ Succeeded: 0,
+ Conditions: []batchv1.JobCondition{
+ {
+ Type: batchv1.JobComplete,
+ Status: "False",
+ },
+ {
+ Type: batchv1.JobFailed,
+ Status: "False",
+ },
+ },
+ },
+ },
+ wantDone: false,
+ wantErr: false,
+ },
+ {
+ name: "wrong object type",
+ obj: &metav1.Status{},
+ wantDone: true,
+ wantErr: true,
+ errMessage: "expected test-job to be a *batch.Job, got *v1.Status",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ done, err := lw.waitForJob(tt.obj, "test-job")
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("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())
+ }
+ } else if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+
+ if done != tt.wantDone {
+ t.Errorf("got done=%v, want %v", done, tt.wantDone)
+ }
+ })
+ }
+}
+
+func TestLegacyWaiter_isRetryableError(t *testing.T) {
+ lw := &legacyWaiter{}
+
+ info := &resource.Info{
+ Name: "test-resource",
+ }
+
+ tests := []struct {
+ name string
+ err error
+ wantRetry bool
+ description string
+ }{
+ {
+ name: "nil error",
+ err: nil,
+ wantRetry: false,
+ },
+ {
+ name: "status error - 0 code",
+ err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: 0}},
+ wantRetry: true,
+ },
+ {
+ name: "status error - 429 (TooManyRequests)",
+ err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusTooManyRequests}},
+ wantRetry: true,
+ },
+ {
+ name: "status error - 503",
+ err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusServiceUnavailable}},
+ wantRetry: true,
+ },
+ {
+ name: "status error - 501 (NotImplemented)",
+ err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusNotImplemented}},
+ wantRetry: false,
+ },
+ {
+ name: "status error - 400 (Bad Request)",
+ err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusBadRequest}},
+ wantRetry: false,
+ },
+ {
+ name: "non-status error",
+ err: fmt.Errorf("some generic error"),
+ wantRetry: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := lw.isRetryableError(tt.err, info)
+ if got != tt.wantRetry {
+ t.Errorf("isRetryableError() = %v, want %v", got, tt.wantRetry)
+ }
+ })
+ }
+}
diff --git a/pkg/lint/lint.go b/pkg/lint/lint.go
index 67e76bd3d..64b2a6057 100644
--- a/pkg/lint/lint.go
+++ b/pkg/lint/lint.go
@@ -14,24 +14,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package lint // import "helm.sh/helm/v3/pkg/lint"
+package lint // import "helm.sh/helm/v4/pkg/lint"
import (
"path/filepath"
- "helm.sh/helm/v3/pkg/lint/rules"
- "helm.sh/helm/v3/pkg/lint/support"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/rules"
+ "helm.sh/helm/v4/pkg/lint/support"
)
-// All runs all of the available linters on the given base directory.
-func All(basedir string, values map[string]interface{}, namespace string, strict bool) support.Linter {
- // Using abs path to get directory context
- chartDir, _ := filepath.Abs(basedir)
-
- linter := support.Linter{ChartDir: chartDir}
- rules.Chartfile(&linter)
- rules.ValuesWithOverrides(&linter, values)
- rules.Templates(&linter, values, namespace, strict)
- rules.Dependencies(&linter)
- return linter
+type linterOptions struct {
+ KubeVersion *chartutil.KubeVersion
+ SkipSchemaValidation bool
+}
+
+type LinterOption func(lo *linterOptions)
+
+func WithKubeVersion(kubeVersion *chartutil.KubeVersion) LinterOption {
+ return func(lo *linterOptions) {
+ lo.KubeVersion = kubeVersion
+ }
+}
+
+func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption {
+ return func(lo *linterOptions) {
+ lo.SkipSchemaValidation = skipSchemaValidation
+ }
+}
+
+func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter {
+
+ chartDir, _ := filepath.Abs(baseDir)
+
+ lo := linterOptions{}
+ for _, option := range options {
+ option(&lo)
+ }
+
+ result := support.Linter{
+ ChartDir: chartDir,
+ }
+
+ rules.Chartfile(&result)
+ rules.ValuesWithOverrides(&result, values)
+ rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation)
+ rules.Dependencies(&result)
+ rules.Crds(&result)
+
+ return result
}
diff --git a/pkg/lint/lint_test.go b/pkg/lint/lint_test.go
index 5516ec668..5b590c010 100644
--- a/pkg/lint/lint_test.go
+++ b/pkg/lint/lint_test.go
@@ -21,36 +21,44 @@ import (
"testing"
"time"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ "github.com/stretchr/testify/assert"
+
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
var values map[string]interface{}
const namespace = "testNamespace"
-const strict = false
const badChartDir = "rules/testdata/badchartfile"
const badValuesFileDir = "rules/testdata/badvaluesfile"
const badYamlFileDir = "rules/testdata/albatross"
+const badCrdFileDir = "rules/testdata/badcrdfile"
const goodChartDir = "rules/testdata/goodone"
const subChartValuesDir = "rules/testdata/withsubchart"
const malformedTemplate = "rules/testdata/malformed-template"
+const invalidChartFileDir = "rules/testdata/invalidchartfile"
func TestBadChart(t *testing.T) {
- m := All(badChartDir, values, namespace, strict).Messages
+ m := RunAll(badChartDir, values, namespace).Messages
if len(m) != 8 {
t.Errorf("Number of errors %v", len(m))
t.Errorf("All didn't fail with expected errors, got %#v", m)
}
- // There should be one INFO, and 2 ERROR messages, check for them
- var i, e, e2, e3, e4, e5, e6 bool
+ // There should be one INFO, one WARNING, and 2 ERROR messages, check for them
+ var i, w, e, e2, e3, e4, e5, e6 bool
for _, msg := range m {
if msg.Severity == support.InfoSev {
if strings.Contains(msg.Err.Error(), "icon is recommended") {
i = true
}
}
+ if msg.Severity == support.WarningSev {
+ if strings.Contains(msg.Err.Error(), "does not exist") {
+ w = true
+ }
+ }
if msg.Severity == support.ErrorSev {
if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") {
e = true
@@ -76,13 +84,13 @@ func TestBadChart(t *testing.T) {
}
}
}
- if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 {
+ if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w {
t.Errorf("Didn't find all the expected errors, got %#v", m)
}
}
func TestInvalidYaml(t *testing.T) {
- m := All(badYamlFileDir, values, namespace, strict).Messages
+ m := RunAll(badYamlFileDir, values, namespace).Messages
if len(m) != 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m)
}
@@ -91,8 +99,18 @@ func TestInvalidYaml(t *testing.T) {
}
}
+func TestInvalidChartYaml(t *testing.T) {
+ m := RunAll(invalidChartFileDir, values, namespace).Messages
+ if len(m) != 2 {
+ 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")
+ }
+}
+
func TestBadValues(t *testing.T) {
- m := All(badValuesFileDir, values, namespace, strict).Messages
+ m := RunAll(badValuesFileDir, values, namespace).Messages
if len(m) < 1 {
t.Fatalf("All didn't fail with expected errors, got %#v", m)
}
@@ -101,8 +119,15 @@ func TestBadValues(t *testing.T) {
}
}
+func TestBadCrdFile(t *testing.T) {
+ m := RunAll(badCrdFileDir, values, namespace).Messages
+ assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m)
+ assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'")
+ assert.ErrorContains(t, m[1].Err, "object kind is not 'CustomResourceDefinition'")
+}
+
func TestGoodChart(t *testing.T) {
- m := All(goodChartDir, values, namespace, strict).Messages
+ m := RunAll(goodChartDir, values, namespace).Messages
if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have")
for i, msg := range m {
@@ -126,7 +151,7 @@ func TestHelmCreateChart(t *testing.T) {
// Note: we test with strict=true here, even though others have
// strict = false.
- m := All(createdChart, values, namespace, true).Messages
+ m := RunAll(createdChart, values, namespace, WithSkipSchemaValidation(true)).Messages
if ll := len(m); ll != 1 {
t.Errorf("All should have had exactly 1 error. Got %d", ll)
for i, msg := range m {
@@ -137,10 +162,57 @@ func TestHelmCreateChart(t *testing.T) {
}
}
+// TestHelmCreateChart_CheckDeprecatedWarnings checks if any default template created by `helm create` throws
+// deprecated warnings in the linter check against the current Kubernetes version (provided using ldflags).
+//
+// See https://github.com/helm/helm/issues/11495
+//
+// Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent
+// of the `--set` flag.
+//
+// Note: This test requires the following ldflags to be set per the current Kubernetes version to avoid false-positive
+// results.
+// 1. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor=
+// 2. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor=
+// or directly use '$(LDFLAGS)' in Makefile.
+//
+// When run without ldflags, the test passes giving a false-positive result. This is because the variables
+// `k8sVersionMajor` and `k8sVersionMinor` by default are set to an older version of Kubernetes, with which, there
+// might not be the deprecation warning.
+func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) {
+ createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir())
+ if err != nil {
+ t.Error(err)
+ return
+ }
+
+ // Add values to enable hpa, and ingress which are disabled by default.
+ // This is the equivalent of:
+ // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true'
+ updatedValues := map[string]interface{}{
+ "autoscaling": map[string]interface{}{
+ "enabled": true,
+ },
+ "ingress": map[string]interface{}{
+ "enabled": true,
+ },
+ }
+
+ linterRunDetails := RunAll(createdChart, updatedValues, namespace, WithSkipSchemaValidation(true))
+ for _, msg := range linterRunDetails.Messages {
+ if strings.HasPrefix(msg.Error(), "[WARNING]") &&
+ strings.Contains(msg.Error(), "deprecated") {
+ // When there is a deprecation warning for an object created
+ // by `helm create` for the current Kubernetes version, fail.
+ t.Errorf("Unexpected deprecation warning for %q: %s", msg.Path, msg.Error())
+ }
+ }
+}
+
// lint ignores import-values
// See https://github.com/helm/helm/issues/9658
func TestSubChartValuesChart(t *testing.T) {
- m := All(subChartValuesDir, values, namespace, strict).Messages
+ m := RunAll(subChartValuesDir, values, namespace).Messages
if len(m) != 0 {
t.Error("All returned linter messages when it shouldn't have")
for i, msg := range m {
@@ -156,7 +228,7 @@ func TestMalformedTemplate(t *testing.T) {
ch := make(chan int, 1)
var m []support.Message
go func() {
- m = All(malformedTemplate, values, namespace, strict).Messages
+ m = RunAll(malformedTemplate, values, namespace).Messages
ch <- 1
}()
select {
diff --git a/pkg/lint/rules/chartfile.go b/pkg/lint/rules/chartfile.go
index 70532ad4f..103c28374 100644
--- a/pkg/lint/rules/chartfile.go
+++ b/pkg/lint/rules/chartfile.go
@@ -14,21 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package rules // import "helm.sh/helm/v3/pkg/lint/rules"
+package rules // import "helm.sh/helm/v4/pkg/lint/rules"
import (
+ "errors"
"fmt"
"os"
"path/filepath"
"github.com/Masterminds/semver/v3"
"github.com/asaskevich/govalidator"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
// Chartfile runs a set of linter rules related to Chart.yaml file
@@ -46,6 +46,9 @@ func Chartfile(linter *support.Linter) {
return
}
+ _, err = chartutil.StrictLoadChartfile(chartPath)
+ linter.RunLinterRule(support.WarningSev, chartFileName, validateChartYamlStrictFormat(err))
+
// type check for Chart.yaml . ignoring error as any parse
// errors would already be caught in the above load function
chartFileForTypeCheck, _ := loadChartFileForTypeCheck(chartPath)
@@ -81,7 +84,7 @@ func isStringValue(data map[string]interface{}, key string) error {
}
valueType := fmt.Sprintf("%T", value)
if valueType != "string" {
- return errors.Errorf("%s should be of type string but it's of type %s", key, valueType)
+ return fmt.Errorf("%s should be of type string but it's of type %s", key, valueType)
}
return nil
}
@@ -97,7 +100,14 @@ func validateChartYamlNotDirectory(chartPath string) error {
func validateChartYamlFormat(chartFileError error) error {
if chartFileError != nil {
- return errors.Errorf("unable to parse YAML\n\t%s", chartFileError.Error())
+ return fmt.Errorf("unable to parse YAML\n\t%w", chartFileError)
+ }
+ return nil
+}
+
+func validateChartYamlStrictFormat(chartFileError error) error {
+ if chartFileError != nil {
+ return fmt.Errorf("failed to strictly parse chart metadata file\n\t%w", chartFileError)
}
return nil
}
@@ -106,6 +116,10 @@ func validateChartName(cf *chart.Metadata) error {
if cf.Name == "" {
return errors.New("name is required")
}
+ name := filepath.Base(cf.Name)
+ if name != cf.Name {
+ return fmt.Errorf("chart name %q is invalid", cf.Name)
+ }
return nil
}
@@ -127,9 +141,8 @@ func validateChartVersion(cf *chart.Metadata) error {
}
version, err := semver.NewVersion(cf.Version)
-
if err != nil {
- return errors.Errorf("version '%s' is not a valid SemVer", cf.Version)
+ return fmt.Errorf("version '%s' is not a valid SemVer", cf.Version)
}
c, err := semver.NewConstraint(">0.0.0-0")
@@ -139,7 +152,7 @@ func validateChartVersion(cf *chart.Metadata) error {
valid, msg := c.Validate(version)
if !valid && len(msg) > 0 {
- return errors.Errorf("version %v", msg[0])
+ return fmt.Errorf("version %v", msg[0])
}
return nil
@@ -147,12 +160,15 @@ func validateChartVersion(cf *chart.Metadata) error {
func validateChartMaintainer(cf *chart.Metadata) error {
for _, maintainer := range cf.Maintainers {
+ if maintainer == nil {
+ return errors.New("a maintainer entry is empty")
+ }
if maintainer.Name == "" {
return errors.New("each maintainer requires a name")
} else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) {
- return errors.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name)
+ return fmt.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name)
} else if maintainer.URL != "" && !govalidator.IsURL(maintainer.URL) {
- return errors.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name)
+ return fmt.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name)
}
}
return nil
@@ -161,7 +177,7 @@ func validateChartMaintainer(cf *chart.Metadata) error {
func validateChartSources(cf *chart.Metadata) error {
for _, source := range cf.Sources {
if source == "" || !govalidator.IsRequestURL(source) {
- return errors.Errorf("invalid source URL '%s'", source)
+ return fmt.Errorf("invalid source URL '%s'", source)
}
}
return nil
@@ -176,7 +192,7 @@ func validateChartIconPresence(cf *chart.Metadata) error {
func validateChartIconURL(cf *chart.Metadata) error {
if cf.Icon != "" && !govalidator.IsRequestURL(cf.Icon) {
- return errors.Errorf("invalid icon URL '%s'", cf.Icon)
+ return fmt.Errorf("invalid icon URL '%s'", cf.Icon)
}
return nil
}
diff --git a/pkg/lint/rules/chartfile_test.go b/pkg/lint/rules/chartfile_test.go
index 087cda047..1719a2011 100644
--- a/pkg/lint/rules/chartfile_test.go
+++ b/pkg/lint/rules/chartfile_test.go
@@ -17,29 +17,31 @@ limitations under the License.
package rules
import (
+ "errors"
"os"
"path/filepath"
"strings"
"testing"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
const (
+ badChartNameDir = "testdata/badchartname"
badChartDir = "testdata/badchartfile"
anotherBadChartDir = "testdata/anotherbadchartfile"
)
var (
+ badChartNamePath = filepath.Join(badChartNameDir, "Chart.yaml")
badChartFilePath = filepath.Join(badChartDir, "Chart.yaml")
nonExistingChartFilePath = filepath.Join(os.TempDir(), "Chart.yaml")
)
var badChart, _ = chartutil.LoadChartfile(badChartFilePath)
+var badChartName, _ = chartutil.LoadChartfile(badChartNamePath)
// Validation functions Test
func TestValidateChartYamlNotDirectory(t *testing.T) {
@@ -69,6 +71,11 @@ func TestValidateChartName(t *testing.T) {
if err == nil {
t.Errorf("validateChartName to return a linter error, got no error")
}
+
+ err = validateChartName(badChartName)
+ if err == nil {
+ t.Error("expected validateChartName to return a linter error for an invalid name, got no error")
+ }
}
func TestValidateChartVersion(t *testing.T) {
@@ -135,6 +142,16 @@ func TestValidateChartMaintainer(t *testing.T) {
t.Errorf("validateChartMaintainer(%s, %s) to return no error, got %s", test.Name, test.Email, err.Error())
}
}
+
+ // Testing for an empty maintainer
+ badChart.Maintainers = []*chart.Maintainer{nil}
+ err := validateChartMaintainer(badChart)
+ if err == nil {
+ t.Errorf("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())
+ }
}
func TestValidateChartSources(t *testing.T) {
@@ -158,10 +175,30 @@ func TestValidateChartSources(t *testing.T) {
}
func TestValidateChartIconPresence(t *testing.T) {
- err := validateChartIconPresence(badChart)
- if err == nil {
- t.Errorf("validateChartIconPresence to return a linter error, got no error")
- }
+ t.Run("Icon absent", func(t *testing.T) {
+ testChart := &chart.Metadata{
+ Icon: "",
+ }
+
+ err := validateChartIconPresence(testChart)
+
+ if err == nil {
+ t.Errorf("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())
+ }
+ })
+ t.Run("Icon present", func(t *testing.T) {
+ testChart := &chart.Metadata{
+ Icon: "http://example.org/icon.png",
+ }
+
+ err := validateChartIconPresence(testChart)
+
+ if err != nil {
+ t.Errorf("Unexpected error: %q", err.Error())
+ }
+ })
}
func TestValidateChartIconURL(t *testing.T) {
diff --git a/pkg/lint/rules/crds.go b/pkg/lint/rules/crds.go
new file mode 100644
index 000000000..1b8a73139
--- /dev/null
+++ b/pkg/lint/rules/crds.go
@@ -0,0 +1,113 @@
+/*
+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 rules
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/util/yaml"
+
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/lint/support"
+)
+
+// Crds lints the CRDs in the Linter.
+func Crds(linter *support.Linter) {
+ fpath := "crds/"
+ crdsPath := filepath.Join(linter.ChartDir, fpath)
+
+ // crds directory is optional
+ if _, err := os.Stat(crdsPath); errors.Is(err, fs.ErrNotExist) {
+ return
+ }
+
+ crdsDirValid := linter.RunLinterRule(support.ErrorSev, fpath, validateCrdsDir(crdsPath))
+ if !crdsDirValid {
+ return
+ }
+
+ // Load chart and parse CRDs
+ chart, err := loader.Load(linter.ChartDir)
+
+ chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err)
+
+ if !chartLoaded {
+ return
+ }
+
+ /* Iterate over all the CRDs to check:
+ 1. It is a YAML file and not a template
+ 2. The API version is apiextensions.k8s.io
+ 3. The kind is CustomResourceDefinition
+ */
+ for _, crd := range chart.CRDObjects() {
+ fileName := crd.Name
+ fpath = fileName
+
+ decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(crd.File.Data), 4096)
+ for {
+ var yamlStruct *k8sYamlStruct
+
+ err := decoder.Decode(&yamlStruct)
+ if err == io.EOF {
+ break
+ }
+
+ // If YAML parsing fails here, it will always fail in the next block as well, so we should return here.
+ // This also confirms the YAML is not a template, since templates can't be decoded into a K8sYamlStruct.
+ if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) {
+ return
+ }
+
+ linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct))
+ linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct))
+ }
+ }
+}
+
+// Validation functions
+func validateCrdsDir(crdsPath string) error {
+ fi, err := os.Stat(crdsPath)
+ if err != nil {
+ return err
+ }
+ if !fi.IsDir() {
+ return errors.New("not a directory")
+ }
+ return nil
+}
+
+func validateCrdAPIVersion(obj *k8sYamlStruct) error {
+ if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") {
+ return fmt.Errorf("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 nil
+}
diff --git a/pkg/lint/rules/crds_test.go b/pkg/lint/rules/crds_test.go
new file mode 100644
index 000000000..d497b29ba
--- /dev/null
+++ b/pkg/lint/rules/crds_test.go
@@ -0,0 +1,36 @@
+/*
+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 rules
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "helm.sh/helm/v4/pkg/lint/support"
+)
+
+const invalidCrdsDir = "./testdata/invalidcrdsdir"
+
+func TestInvalidCrdsDir(t *testing.T) {
+ linter := support.Linter{ChartDir: invalidCrdsDir}
+ Crds(&linter)
+ res := linter.Messages
+
+ assert.Len(t, res, 1)
+ assert.ErrorContains(t, res[0].Err, "not a directory")
+}
diff --git a/pkg/lint/rules/dependencies.go b/pkg/lint/rules/dependencies.go
index abecd1feb..16c9d6435 100644
--- a/pkg/lint/rules/dependencies.go
+++ b/pkg/lint/rules/dependencies.go
@@ -14,17 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package rules // import "helm.sh/helm/v3/pkg/lint/rules"
+package rules // import "helm.sh/helm/v4/pkg/lint/rules"
import (
"fmt"
"strings"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/lint/support"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/lint/support"
)
// Dependencies runs lints against a chart's dependencies
@@ -37,12 +35,13 @@ func Dependencies(linter *support.Linter) {
}
linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependencyInMetadata(c))
+ linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependenciesUnique(c))
linter.RunLinterRule(support.WarningSev, linter.ChartDir, validateDependencyInChartsDir(c))
}
func validateChartFormat(chartError error) error {
if chartError != nil {
- return errors.Errorf("unable to load chart\n\t%s", chartError)
+ return fmt.Errorf("unable to load chart\n\t%w", chartError)
}
return nil
}
@@ -80,3 +79,23 @@ func validateDependencyInMetadata(c *chart.Chart) (err error) {
}
return err
}
+
+func validateDependenciesUnique(c *chart.Chart) (err error) {
+ dependencies := map[string]*chart.Dependency{}
+ shadowing := []string{}
+
+ for _, dep := range c.Metadata.Dependencies {
+ key := dep.Name
+ if dep.Alias != "" {
+ key = dep.Alias
+ }
+ if dependencies[key] != nil {
+ shadowing = append(shadowing, key)
+ }
+ dependencies[key] = dep
+ }
+ if len(shadowing) > 0 {
+ err = fmt.Errorf("multiple dependencies with name or alias: %s", strings.Join(shadowing, ","))
+ }
+ return err
+}
diff --git a/pkg/lint/rules/dependencies_test.go b/pkg/lint/rules/dependencies_test.go
index 24c5faf7f..1369b2372 100644
--- a/pkg/lint/rules/dependencies_test.go
+++ b/pkg/lint/rules/dependencies_test.go
@@ -19,9 +19,9 @@ import (
"path/filepath"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
func chartWithBadDependencies() chart.Chart {
@@ -76,6 +76,67 @@ func TestValidateDependencyInMetadata(t *testing.T) {
}
}
+func TestValidateDependenciesUnique(t *testing.T) {
+ tests := []struct {
+ chart chart.Chart
+ }{
+ {chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "badchart",
+ Version: "0.1.0",
+ APIVersion: "v2",
+ Dependencies: []*chart.Dependency{
+ {
+ Name: "foo",
+ },
+ {
+ Name: "foo",
+ },
+ },
+ },
+ }},
+ {chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "badchart",
+ Version: "0.1.0",
+ APIVersion: "v2",
+ Dependencies: []*chart.Dependency{
+ {
+ Name: "foo",
+ Alias: "bar",
+ },
+ {
+ Name: "bar",
+ },
+ },
+ },
+ }},
+ {chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "badchart",
+ Version: "0.1.0",
+ APIVersion: "v2",
+ Dependencies: []*chart.Dependency{
+ {
+ Name: "foo",
+ Alias: "baz",
+ },
+ {
+ Name: "bar",
+ Alias: "baz",
+ },
+ },
+ },
+ }},
+ }
+
+ for _, tt := range tests {
+ if err := validateDependenciesUnique(&tt.chart); err == nil {
+ t.Errorf("chart should have been flagged for dependency shadowing")
+ }
+ }
+}
+
func TestDependencies(t *testing.T) {
tmp := t.TempDir()
diff --git a/pkg/lint/rules/deprecations.go b/pkg/lint/rules/deprecations.go
index ce19b91d5..c6d635a5e 100644
--- a/pkg/lint/rules/deprecations.go
+++ b/pkg/lint/rules/deprecations.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package rules // import "helm.sh/helm/v3/pkg/lint/rules"
+package rules // import "helm.sh/helm/v4/pkg/lint/rules"
import (
"fmt"
@@ -24,6 +24,8 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/endpoints/deprecation"
kscheme "k8s.io/client-go/kubernetes/scheme"
+
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
var (
@@ -45,7 +47,7 @@ func (e deprecatedAPIError) Error() string {
return msg
}
-func validateNoDeprecations(resource *K8sYamlStruct) error {
+func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *chartutil.KubeVersion) error {
// if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation
if resource.APIVersion == "" {
return nil
@@ -54,6 +56,14 @@ func validateNoDeprecations(resource *K8sYamlStruct) error {
return nil
}
+ majorVersion := k8sVersionMajor
+ minorVersion := k8sVersionMinor
+
+ if kubeVersion != nil {
+ majorVersion = kubeVersion.Major
+ minorVersion = kubeVersion.Minor
+ }
+
runtimeObject, err := resourceToRuntimeObject(resource)
if err != nil {
// do not error for non-kubernetes resources
@@ -62,16 +72,17 @@ func validateNoDeprecations(resource *K8sYamlStruct) error {
}
return err
}
- maj, err := strconv.Atoi(k8sVersionMajor)
+
+ major, err := strconv.Atoi(majorVersion)
if err != nil {
return err
}
- min, err := strconv.Atoi(k8sVersionMinor)
+ minor, err := strconv.Atoi(minorVersion)
if err != nil {
return err
}
- if !deprecation.IsDeprecated(runtimeObject, maj, min) {
+ if !deprecation.IsDeprecated(runtimeObject, major, minor) {
return nil
}
gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind)
@@ -81,7 +92,7 @@ func validateNoDeprecations(resource *K8sYamlStruct) error {
}
}
-func resourceToRuntimeObject(resource *K8sYamlStruct) (runtime.Object, error) {
+func resourceToRuntimeObject(resource *k8sYamlStruct) (runtime.Object, error) {
scheme := runtime.NewScheme()
kscheme.AddToScheme(scheme)
diff --git a/pkg/lint/rules/deprecations_test.go b/pkg/lint/rules/deprecations_test.go
index 96e072d14..6add843ce 100644
--- a/pkg/lint/rules/deprecations_test.go
+++ b/pkg/lint/rules/deprecations_test.go
@@ -14,16 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package rules // import "helm.sh/helm/v3/pkg/lint/rules"
+package rules // import "helm.sh/helm/v4/pkg/lint/rules"
import "testing"
func TestValidateNoDeprecations(t *testing.T) {
- deprecated := &K8sYamlStruct{
+ deprecated := &k8sYamlStruct{
APIVersion: "extensions/v1beta1",
Kind: "Deployment",
}
- err := validateNoDeprecations(deprecated)
+ err := validateNoDeprecations(deprecated, nil)
if err == nil {
t.Fatal("Expected deprecated extension to be flagged")
}
@@ -32,10 +32,10 @@ func TestValidateNoDeprecations(t *testing.T) {
t.Fatalf("Expected error message to be non-blank: %v", err)
}
- if err := validateNoDeprecations(&K8sYamlStruct{
+ if err := validateNoDeprecations(&k8sYamlStruct{
APIVersion: "v1",
Kind: "Pod",
- }); err != nil {
+ }, nil); err != nil {
t.Errorf("Expected a v1 Pod to not be deprecated")
}
}
diff --git a/pkg/lint/rules/template.go b/pkg/lint/rules/template.go
index 000f7ebcf..b36153ec6 100644
--- a/pkg/lint/rules/template.go
+++ b/pkg/lint/rules/template.go
@@ -19,40 +19,49 @@ package rules
import (
"bufio"
"bytes"
+ "errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
- "regexp"
+ "slices"
"strings"
- "github.com/pkg/errors"
"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"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/engine"
- "helm.sh/helm/v3/pkg/lint/support"
-)
-
-var (
- crdHookSearch = regexp.MustCompile(`"?helm\.sh/hook"?:\s+crd-install`)
- releaseTimeSearch = regexp.MustCompile(`\.Release\.Time`)
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/engine"
+ "helm.sh/helm/v4/pkg/lint/support"
)
// Templates lints the templates in the Linter.
-func Templates(linter *support.Linter, values map[string]interface{}, namespace string, strict bool) {
+func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) {
+ TemplatesWithKubeVersion(linter, values, namespace, nil)
+}
+
+// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version.
+func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) {
+ TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false)
+}
+
+// TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not.
+func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion, skipSchemaValidation bool) {
fpath := "templates/"
templatesPath := filepath.Join(linter.ChartDir, fpath)
- templatesDirExist := linter.RunLinterRule(support.WarningSev, fpath, validateTemplatesDir(templatesPath))
-
// Templates directory is optional for now
- if !templatesDirExist {
+ templatesDirExists := linter.RunLinterRule(support.WarningSev, fpath, templatesDirExists(templatesPath))
+ if !templatesDirExists {
+ return
+ }
+
+ validTemplatesDir := linter.RunLinterRule(support.ErrorSev, fpath, validateTemplatesDir(templatesPath))
+ if !validTemplatesDir {
return
}
@@ -70,9 +79,14 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
Namespace: namespace,
}
+ caps := chartutil.DefaultCapabilities.Copy()
+ if kubeVersion != nil {
+ caps.KubeVersion = *kubeVersion
+ }
+
// lint ignores import-values
// See https://github.com/helm/helm/issues/9658
- if err := chartutil.ProcessDependenciesWithMerge(chart, values); err != nil {
+ if err := chartutil.ProcessDependencies(chart, values); err != nil {
return
}
@@ -80,7 +94,8 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
if err != nil {
return
}
- valuesToRender, err := chartutil.ToRenderValues(chart, cvals, options, nil)
+
+ valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation)
if err != nil {
linter.RunLinterRule(support.ErrorSev, fpath, err)
return
@@ -103,14 +118,10 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
- Metadata.Namespace is not set
*/
for _, template := range chart.Templates {
- fileName, data := template.Name, template.Data
+ fileName := template.Name
fpath = fileName
linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName))
- // These are v3 specific checks to make sure and warn people if their
- // chart is not compatible with v3
- linter.RunLinterRule(support.WarningSev, fpath, validateNoCRDHooks(data))
- linter.RunLinterRule(support.ErrorSev, fpath, validateNoReleaseTime(data))
// We only apply the following lint rules to yaml files
if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" {
@@ -132,9 +143,9 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
// Lint all resources if the file contains multiple documents separated by ---
for {
- // Even though K8sYamlStruct only defines a few fields, an error in any other
+ // Even though k8sYamlStruct only defines a few fields, an error in any other
// key will be raised as well
- var yamlStruct *K8sYamlStruct
+ var yamlStruct *k8sYamlStruct
err := decoder.Decode(&yamlStruct)
if err == io.EOF {
@@ -150,7 +161,7 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace
// NOTE: set to warnings to allow users to support out-of-date kubernetes
// Refs https://github.com/helm/helm/issues/8596
linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct))
- linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct))
+ linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion))
linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent))
linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent))
@@ -187,11 +198,21 @@ func validateTopIndentLevel(content string) error {
}
// Validation functions
+func templatesDirExists(templatesPath string) error {
+ _, err := os.Stat(templatesPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return errors.New("directory does not exist")
+ }
+ return nil
+}
+
func validateTemplatesDir(templatesPath string) error {
- if fi, err := os.Stat(templatesPath); err == nil {
- if !fi.IsDir() {
- return errors.New("not a directory")
- }
+ fi, err := os.Stat(templatesPath)
+ if err != nil {
+ return err
+ }
+ if !fi.IsDir() {
+ return errors.New("not a directory")
}
return nil
}
@@ -200,30 +221,31 @@ func validateAllowedExtension(fileName string) error {
ext := filepath.Ext(fileName)
validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"}
- for _, b := range validExtensions {
- if b == ext {
- return nil
- }
+ if slices.Contains(validExtensions, ext) {
+ return nil
}
- return errors.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext)
+ return fmt.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext)
}
func validateYamlContent(err error) error {
- return errors.Wrap(err, "unable to parse YAML")
+ if err != nil {
+ return fmt.Errorf("unable to parse YAML: %w", err)
+ }
+ return nil
}
// validateMetadataName uses the correct validation function for the object
// Kind, or if not set, defaults to the standard definition of a subdomain in
// DNS (RFC 1123), used by most resources.
-func validateMetadataName(obj *K8sYamlStruct) error {
+func validateMetadataName(obj *k8sYamlStruct) error {
fn := validateMetadataNameFunc(obj)
allErrs := field.ErrorList{}
for _, msg := range fn(obj.Metadata.Name, false) {
allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg))
}
if len(allErrs) > 0 {
- return errors.Wrapf(allErrs.ToAggregate(), "object name does not conform to Kubernetes naming requirements: %q", obj.Metadata.Name)
+ return fmt.Errorf("object name does not conform to Kubernetes naming requirements: %q: %w", obj.Metadata.Name, allErrs.ToAggregate())
}
return nil
}
@@ -241,7 +263,7 @@ func validateMetadataName(obj *K8sYamlStruct) error {
// If no mapping is defined, returns NameIsDNSSubdomain. This is used by object
// kinds that don't have special requirements, so is the most likely to work if
// new kinds are added.
-func validateMetadataNameFunc(obj *K8sYamlStruct) validation.ValidateNameFunc {
+func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc {
switch strings.ToLower(obj.Kind) {
case "pod", "node", "secret", "endpoints", "resourcequota", // core
"controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps
@@ -264,10 +286,10 @@ func validateMetadataNameFunc(obj *K8sYamlStruct) validation.ValidateNameFunc {
case "certificatesigningrequest":
// No validation.
// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140
- return func(name string, prefix bool) []string { return nil }
+ return func(_ string, _ bool) []string { return nil }
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, prefix bool) []string {
+ return func(name string, _ bool) []string {
return apipath.IsValidPathSegmentName(name)
}
default:
@@ -275,33 +297,20 @@ func validateMetadataNameFunc(obj *K8sYamlStruct) validation.ValidateNameFunc {
}
}
-func validateNoCRDHooks(manifest []byte) error {
- if crdHookSearch.Match(manifest) {
- return errors.New("manifest is a crd-install hook. This hook is no longer supported in v3 and all CRDs should also exist the crds/ directory at the top level of the chart")
- }
- return nil
-}
-
-func validateNoReleaseTime(manifest []byte) error {
- if releaseTimeSearch.Match(manifest) {
- return errors.New(".Release.Time has been removed in v3, please replace with the `now` function in your templates")
- }
- return nil
-}
-
// validateMatchSelector ensures that template specs have a selector declared.
// See https://github.com/helm/helm/issues/1990
-func validateMatchSelector(yamlStruct *K8sYamlStruct, manifest string) error {
+func validateMatchSelector(yamlStruct *k8sYamlStruct, manifest string) error {
switch yamlStruct.Kind {
case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet":
// verify that matchLabels or matchExpressions is present
- if !(strings.Contains(manifest, "matchLabels") || strings.Contains(manifest, "matchExpressions")) {
+ if !strings.Contains(manifest, "matchLabels") && !strings.Contains(manifest, "matchExpressions") {
return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name)
}
}
return nil
}
-func validateListAnnotations(yamlStruct *K8sYamlStruct, manifest string) error {
+
+func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error {
if yamlStruct.Kind == "List" {
m := struct {
Items []struct {
@@ -317,18 +326,15 @@ func validateListAnnotations(yamlStruct *K8sYamlStruct, manifest string) error {
for _, i := range m.Items {
if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok {
- return errors.New("Annotation 'helm.sh/resource-policy' within List objects are ignored")
+ return errors.New("annotation 'helm.sh/resource-policy' within List objects are ignored")
}
}
}
return nil
}
-// K8sYamlStruct stubs a Kubernetes YAML file.
-//
-// DEPRECATED: In Helm 4, this will be made a private type, as it is for use only within
-// the rules package.
-type K8sYamlStruct struct {
+// k8sYamlStruct stubs a Kubernetes YAML file.
+type k8sYamlStruct struct {
APIVersion string `json:"apiVersion"`
Kind string
Metadata k8sYamlMetadata
diff --git a/pkg/lint/rules/template_test.go b/pkg/lint/rules/template_test.go
index 80f9b28ed..787bd6e4b 100644
--- a/pkg/lint/rules/template_test.go
+++ b/pkg/lint/rules/template_test.go
@@ -23,9 +23,9 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
const templateTestBasedir = "./testdata/albatross"
@@ -85,26 +85,6 @@ func TestTemplateIntegrationHappyPath(t *testing.T) {
}
}
-func TestV3Fail(t *testing.T) {
- linter := support.Linter{ChartDir: "./testdata/v3-fail"}
- Templates(&linter, values, namespace, strict)
- res := linter.Messages
-
- if len(res) != 3 {
- t.Fatalf("Expected 3 errors, got %d, %v", len(res), res)
- }
-
- if !strings.Contains(res[0].Err.Error(), ".Release.Time has been removed in v3") {
- t.Errorf("Unexpected error: %s", res[0].Err)
- }
- if !strings.Contains(res[1].Err.Error(), "manifest is a crd-install hook") {
- t.Errorf("Unexpected error: %s", res[1].Err)
- }
- if !strings.Contains(res[2].Err.Error(), "manifest is a crd-install hook") {
- t.Errorf("Unexpected error: %s", res[2].Err)
- }
-}
-
func TestMultiTemplateFail(t *testing.T) {
linter := support.Linter{ChartDir: "./testdata/multi-template-fail"}
Templates(&linter, values, namespace, strict)
@@ -121,76 +101,76 @@ func TestMultiTemplateFail(t *testing.T) {
func TestValidateMetadataName(t *testing.T) {
tests := []struct {
- obj *K8sYamlStruct
+ obj *k8sYamlStruct
wantErr bool
}{
// Most kinds use IsDNS1123Subdomain.
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, true},
- {&K8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
- {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
- {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
- {&K8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, true},
+ {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
+ {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
+ {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
+ {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true},
// Service uses IsDNS1035Label.
- {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true},
- {&K8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true},
+ {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true},
+ {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true},
// Namespace uses IsDNS1123Label.
- {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true},
- {&K8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false},
+ {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true},
+ {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false},
// CertificateSigningRequest has no validation.
- {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false},
- {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, false},
+ {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false},
+ {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, false},
// RBAC uses path validation.
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false},
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true},
- {&K8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true},
- {&K8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true},
- {&K8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
- {&K8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true},
+ {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true},
+ {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true},
+ {&k8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
+ {&k8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false},
// Unknown Kind
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, true},
- {&K8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^$%*@^*@^"}}, true},
+ {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
// No kind
- {&K8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false},
- {&K8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
+ {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false},
+ {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s/%s", tt.obj.Kind, tt.obj.Metadata.Name), func(t *testing.T) {
@@ -293,7 +273,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) {
}
func TestValidateMatchSelector(t *testing.T) {
- md := &K8sYamlStruct{
+ md := &k8sYamlStruct{
APIVersion: "apps/v1",
Kind: "Deployment",
Metadata: k8sYamlMetadata{
@@ -421,7 +401,7 @@ func TestEmptyWithCommentsManifests(t *testing.T) {
}
}
func TestValidateListAnnotations(t *testing.T) {
- md := &K8sYamlStruct{
+ md := &k8sYamlStruct{
APIVersion: "v1",
Kind: "List",
Metadata: k8sYamlMetadata{
diff --git a/pkg/lint/rules/testdata/badchartname/Chart.yaml b/pkg/lint/rules/testdata/badchartname/Chart.yaml
new file mode 100644
index 000000000..64f8fb8bf
--- /dev/null
+++ b/pkg/lint/rules/testdata/badchartname/Chart.yaml
@@ -0,0 +1,5 @@
+apiVersion: v2
+description: A Helm chart for Kubernetes
+version: 0.1.0
+name: "../badchartname"
+type: application
diff --git a/pkg/lint/rules/testdata/badchartname/values.yaml b/pkg/lint/rules/testdata/badchartname/values.yaml
new file mode 100644
index 000000000..9f367033b
--- /dev/null
+++ b/pkg/lint/rules/testdata/badchartname/values.yaml
@@ -0,0 +1 @@
+# Default values for badchartfile.
diff --git a/pkg/lint/rules/testdata/badcrdfile/Chart.yaml b/pkg/lint/rules/testdata/badcrdfile/Chart.yaml
new file mode 100644
index 000000000..08c4b61ac
--- /dev/null
+++ b/pkg/lint/rules/testdata/badcrdfile/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+description: A Helm chart for Kubernetes
+version: 0.1.0
+name: badcrdfile
+type: application
+icon: http://riverrun.io
diff --git a/pkg/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml b/pkg/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml
new file mode 100644
index 000000000..468916053
--- /dev/null
+++ b/pkg/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml
@@ -0,0 +1,2 @@
+apiVersion: bad.k8s.io/v1beta1
+kind: CustomResourceDefinition
diff --git a/pkg/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml b/pkg/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml
new file mode 100644
index 000000000..523b97f85
--- /dev/null
+++ b/pkg/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml
@@ -0,0 +1,2 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: NotACustomResourceDefinition
diff --git a/pkg/lint/rules/testdata/badcrdfile/templates/.gitkeep b/pkg/lint/rules/testdata/badcrdfile/templates/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/lint/rules/testdata/badcrdfile/values.yaml b/pkg/lint/rules/testdata/badcrdfile/values.yaml
new file mode 100644
index 000000000..2fffc7715
--- /dev/null
+++ b/pkg/lint/rules/testdata/badcrdfile/values.yaml
@@ -0,0 +1 @@
+# Default values for badcrdfile.
diff --git a/pkg/lint/rules/testdata/goodone/crds/test-crd.yaml b/pkg/lint/rules/testdata/goodone/crds/test-crd.yaml
new file mode 100644
index 000000000..1d7350f1d
--- /dev/null
+++ b/pkg/lint/rules/testdata/goodone/crds/test-crd.yaml
@@ -0,0 +1,19 @@
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+ name: tests.test.io
+spec:
+ group: test.io
+ names:
+ kind: Test
+ listKind: TestList
+ plural: tests
+ singular: test
+ scope: Namespaced
+ versions:
+ - name : v1alpha2
+ served: true
+ storage: true
+ - name : v1alpha1
+ served: true
+ storage: false
diff --git a/pkg/lint/rules/testdata/invalidchartfile/Chart.yaml b/pkg/lint/rules/testdata/invalidchartfile/Chart.yaml
new file mode 100644
index 000000000..0fd58d1d4
--- /dev/null
+++ b/pkg/lint/rules/testdata/invalidchartfile/Chart.yaml
@@ -0,0 +1,6 @@
+name: some-chart
+apiVersion: v2
+apiVersion: v1
+description: A Helm chart for Kubernetes
+version: 1.3.0
+icon: http://example.com
diff --git a/pkg/lint/rules/testdata/invalidchartfile/values.yaml b/pkg/lint/rules/testdata/invalidchartfile/values.yaml
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/Chart.yaml b/pkg/lint/rules/testdata/invalidcrdsdir/Chart.yaml
new file mode 100644
index 000000000..18e30f70f
--- /dev/null
+++ b/pkg/lint/rules/testdata/invalidcrdsdir/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+description: A Helm chart for Kubernetes
+version: 0.1.0
+name: invalidcrdsdir
+type: application
+icon: http://riverrun.io
diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/crds b/pkg/lint/rules/testdata/invalidcrdsdir/crds
new file mode 100644
index 000000000..e69de29bb
diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/values.yaml b/pkg/lint/rules/testdata/invalidcrdsdir/values.yaml
new file mode 100644
index 000000000..6b1611a64
--- /dev/null
+++ b/pkg/lint/rules/testdata/invalidcrdsdir/values.yaml
@@ -0,0 +1 @@
+# Default values for invalidcrdsdir.
diff --git a/pkg/lint/rules/values.go b/pkg/lint/rules/values.go
index 538d8381b..019e74fa7 100644
--- a/pkg/lint/rules/values.go
+++ b/pkg/lint/rules/values.go
@@ -17,29 +17,21 @@ limitations under the License.
package rules
import (
+ "fmt"
"os"
"path/filepath"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/lint/support"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ "helm.sh/helm/v4/pkg/lint/support"
)
-// Values lints a chart's values.yaml file.
-//
-// This function is deprecated and will be removed in Helm 4.
-func Values(linter *support.Linter) {
- ValuesWithOverrides(linter, map[string]interface{}{})
-}
-
// ValuesWithOverrides tests the values.yaml file.
//
// If a schema is present in the chart, values are tested against that. Otherwise,
// they are only tested for well-formedness.
//
// If additional values are supplied, they are coalesced into the values in values.yaml.
-func ValuesWithOverrides(linter *support.Linter, values map[string]interface{}) {
+func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) {
file := "values.yaml"
vf := filepath.Join(linter.ChartDir, file)
fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf))
@@ -48,13 +40,13 @@ func ValuesWithOverrides(linter *support.Linter, values map[string]interface{})
return
}
- linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, values))
+ linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides))
}
func validateValuesFileExistence(valuesPath string) error {
_, err := os.Stat(valuesPath)
if err != nil {
- return errors.Errorf("file does not exist")
+ return fmt.Errorf("file does not exist")
}
return nil
}
@@ -62,7 +54,7 @@ func validateValuesFileExistence(valuesPath string) error {
func validateValuesFile(valuesPath string, overrides map[string]interface{}) error {
values, err := chartutil.ReadValuesFile(valuesPath)
if err != nil {
- return errors.Wrap(err, "unable to parse YAML")
+ return fmt.Errorf("unable to parse YAML: %w", err)
}
// Helm 3.0.0 carried over the values linting from Helm 2.x, which only tests the top
diff --git a/pkg/lint/rules/values_test.go b/pkg/lint/rules/values_test.go
index faa29d48a..348695785 100644
--- a/pkg/lint/rules/values_test.go
+++ b/pkg/lint/rules/values_test.go
@@ -23,7 +23,7 @@ import (
"github.com/stretchr/testify/assert"
- "helm.sh/helm/v3/internal/test/ensure"
+ "helm.sh/helm/v4/internal/test/ensure"
)
var nonExistingValuesFilePath = filepath.Join("/fake/dir", "values.yaml")
@@ -96,7 +96,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) {
t.Fatal("expected values file to fail parsing")
}
- assert.Contains(t, err.Error(), "Expected: string, given: integer", "integer should be caught by schema")
+ assert.Contains(t, err.Error(), "- at '/username': got number, want string")
}
func TestValidateValuesFileSchemaOverrides(t *testing.T) {
@@ -129,7 +129,7 @@ func TestValidateValuesFile(t *testing.T) {
name: "value not overridden",
yaml: "username: admin\npassword:",
overrides: map[string]interface{}{"username": "anotherUser"},
- errorMessage: "Expected: string, given: null",
+ errorMessage: "- at '/password': got null, want string",
},
{
name: "value overridden",
diff --git a/pkg/lint/support/doc.go b/pkg/lint/support/doc.go
index bffefe8ff..b007804dc 100644
--- a/pkg/lint/support/doc.go
+++ b/pkg/lint/support/doc.go
@@ -20,4 +20,4 @@ Package support contains tools for linting charts.
Linting is the process of testing charts for errors or warnings regarding
formatting, compilation, or standards compliance.
*/
-package support // import "helm.sh/helm/v3/pkg/lint/support"
+package support // import "helm.sh/helm/v4/pkg/lint/support"
diff --git a/pkg/lint/support/message_test.go b/pkg/lint/support/message_test.go
index 9e12a638b..ce5b5e42e 100644
--- a/pkg/lint/support/message_test.go
+++ b/pkg/lint/support/message_test.go
@@ -17,12 +17,10 @@ limitations under the License.
package support
import (
+ "errors"
"testing"
-
- "github.com/pkg/errors"
)
-var linter = Linter{}
var errLint = errors.New("lint failed")
func TestRunLinterRule(t *testing.T) {
@@ -46,6 +44,7 @@ func TestRunLinterRule(t *testing.T) {
{-1, errLint, 4, false, ErrorSev},
}
+ linter := Linter{}
for _, test := range tests {
isValid := linter.RunLinterRule(test.Severity, "chart", test.LintError)
if len(linter.Messages) != test.ExpectedMessages {
diff --git a/pkg/plugin/installer/local_installer.go b/pkg/plugin/installer/local_installer.go
deleted file mode 100644
index 759df38be..000000000
--- a/pkg/plugin/installer/local_installer.go
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
-
-import (
- "os"
- "path/filepath"
-
- "github.com/pkg/errors"
-)
-
-// ErrPluginNotAFolder indicates that the plugin path is not a folder.
-var ErrPluginNotAFolder = errors.New("expected plugin to be a folder")
-
-// LocalInstaller installs plugins from the filesystem.
-type LocalInstaller struct {
- base
-}
-
-// NewLocalInstaller creates a new LocalInstaller.
-func NewLocalInstaller(source string) (*LocalInstaller, error) {
- src, err := filepath.Abs(source)
- if err != nil {
- return nil, errors.Wrap(err, "unable to get absolute path to plugin")
- }
- i := &LocalInstaller{
- base: newBase(src),
- }
- return i, nil
-}
-
-// Install creates a symlink to the plugin directory.
-//
-// Implements Installer.
-func (i *LocalInstaller) Install() error {
- stat, err := os.Stat(i.Source)
- if err != nil {
- return err
- }
- if !stat.IsDir() {
- return ErrPluginNotAFolder
- }
-
- if !isPlugin(i.Source) {
- return ErrMissingMetadata
- }
- debug("symlinking %s to %s", i.Source, i.Path())
- return os.Symlink(i.Source, i.Path())
-}
-
-// Update updates a local repository
-func (i *LocalInstaller) Update() error {
- debug("local repository is auto-updated")
- return nil
-}
diff --git a/pkg/plugin/installer/local_installer_test.go b/pkg/plugin/installer/local_installer_test.go
deleted file mode 100644
index 51408f128..000000000
--- a/pkg/plugin/installer/local_installer_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package installer // import "helm.sh/helm/v3/pkg/plugin/installer"
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "helm.sh/helm/v3/pkg/helmpath"
-)
-
-var _ Installer = new(LocalInstaller)
-
-func TestLocalInstaller(t *testing.T) {
- // Make a temp dir
- tdir := t.TempDir()
- if err := os.WriteFile(filepath.Join(tdir, "plugin.yaml"), []byte{}, 0644); err != nil {
- t.Fatal(err)
- }
-
- source := "../testdata/plugdir/good/echo"
- i, err := NewForSource(source, "")
- if err != nil {
- t.Fatalf("unexpected error: %s", err)
- }
-
- if err := Install(i); err != nil {
- t.Fatal(err)
- }
-
- if i.Path() != helmpath.DataPath("plugins", "echo") {
- t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path())
- }
- defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm
-}
-
-func TestLocalInstallerNotAFolder(t *testing.T) {
- source := "../testdata/plugdir/good/echo/plugin.yaml"
- i, err := NewForSource(source, "")
- if err != nil {
- t.Fatalf("unexpected error: %s", err)
- }
-
- err = Install(i)
- if err == nil {
- t.Fatal("expected error")
- }
- if err != ErrPluginNotAFolder {
- t.Fatalf("expected error to equal: %q", err)
- }
-}
diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go
deleted file mode 100644
index fb3bc5215..000000000
--- a/pkg/plugin/plugin.go
+++ /dev/null
@@ -1,284 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package plugin // import "helm.sh/helm/v3/pkg/plugin"
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "regexp"
- "runtime"
- "strings"
- "unicode"
-
- "github.com/pkg/errors"
- "sigs.k8s.io/yaml"
-
- "helm.sh/helm/v3/pkg/cli"
-)
-
-const PluginFileName = "plugin.yaml"
-
-// Downloaders represents the plugins capability if it can retrieve
-// charts from special sources
-type Downloaders struct {
- // Protocols are the list of schemes from the charts URL.
- Protocols []string `json:"protocols"`
- // Command is the executable path with which the plugin performs
- // the actual download for the corresponding Protocols
- Command string `json:"command"`
-}
-
-// PlatformCommand represents a command for a particular operating system and architecture
-type PlatformCommand struct {
- OperatingSystem string `json:"os"`
- Architecture string `json:"arch"`
- Command string `json:"command"`
-}
-
-// Metadata describes a plugin.
-//
-// This is the plugin equivalent of a chart.Metadata.
-type Metadata struct {
- // Name is the name of the plugin
- Name string `json:"name"`
-
- // Version is a SemVer 2 version of the plugin.
- Version string `json:"version"`
-
- // Usage is the single-line usage text shown in help
- Usage string `json:"usage"`
-
- // Description is a long description shown in places like `helm help`
- Description string `json:"description"`
-
- // Command is the command, as a single string.
- //
- // The command will be passed through environment expansion, so env vars can
- // be present in this command. Unless IgnoreFlags is set, this will
- // also merge the flags passed from Helm.
- //
- // Note that command is not executed in a shell. To do so, we suggest
- // pointing the command to a shell script.
- //
- // The following rules will apply to processing commands:
- // - If platformCommand is present, it will be searched first
- // - If both OS and Arch match the current platform, search will stop and the command will be executed
- // - If OS matches and there is no more specific match, the command will be executed
- // - If no OS/Arch match is found, the default command will be executed
- // - If no command is present and no matches are found in platformCommand, Helm will exit with an error
- PlatformCommand []PlatformCommand `json:"platformCommand"`
- Command string `json:"command"`
-
- // IgnoreFlags ignores any flags passed in from Helm
- //
- // For example, if the plugin is invoked as `helm --debug myplugin`, if this
- // is false, `--debug` will be appended to `--command`. If this is true,
- // the `--debug` flag will be discarded.
- IgnoreFlags bool `json:"ignoreFlags"`
-
- // Hooks are commands that will run on events.
- Hooks Hooks
-
- // Downloaders field is used if the plugin supply downloader mechanism
- // for special protocols.
- Downloaders []Downloaders `json:"downloaders"`
-
- // UseTunnelDeprecated indicates that this command needs a tunnel.
- // Setting this will cause a number of side effects, such as the
- // automatic setting of HELM_HOST.
- // DEPRECATED and unused, but retained for backwards compatibility with Helm 2 plugins. Remove in Helm 4
- UseTunnelDeprecated bool `json:"useTunnel,omitempty"`
-}
-
-// Plugin represents a plugin.
-type Plugin struct {
- // Metadata is a parsed representation of a plugin.yaml
- Metadata *Metadata
- // Dir is the string path to the directory that holds the plugin.
- Dir string
-}
-
-// The following rules will apply to processing the Plugin.PlatformCommand.Command:
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, return nil
-func getPlatformCommand(cmds []PlatformCommand) []string {
- var command []string
- eq := strings.EqualFold
- for _, c := range cmds {
- if eq(c.OperatingSystem, runtime.GOOS) {
- command = strings.Split(c.Command, " ")
- }
- if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) {
- return strings.Split(c.Command, " ")
- }
- }
- return command
-}
-
-// PrepareCommand takes a Plugin.PlatformCommand.Command, a Plugin.Command and will applying the following processing:
-// - If platformCommand is present, it will be searched first
-// - If both OS and Arch match the current platform, search will stop and the command will be prepared for execution
-// - If OS matches and there is no more specific match, the command will be prepared for execution
-// - If no OS/Arch match is found, the default command will be prepared for execution
-// - If no command is present and no matches are found in platformCommand, will exit with an error
-//
-// It merges extraArgs into any arguments supplied in the plugin. It
-// returns the name of the command and an args array.
-//
-// The result is suitable to pass to exec.Command.
-func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) {
- var parts []string
- platCmdLen := len(p.Metadata.PlatformCommand)
- if platCmdLen > 0 {
- parts = getPlatformCommand(p.Metadata.PlatformCommand)
- }
- if platCmdLen == 0 || parts == nil {
- parts = strings.Split(p.Metadata.Command, " ")
- }
- if len(parts) == 0 || parts[0] == "" {
- return "", nil, fmt.Errorf("no plugin command is applicable")
- }
-
- main := os.ExpandEnv(parts[0])
- baseArgs := []string{}
- if len(parts) > 1 {
- for _, cmdpart := range parts[1:] {
- cmdexp := os.ExpandEnv(cmdpart)
- baseArgs = append(baseArgs, cmdexp)
- }
- }
- if !p.Metadata.IgnoreFlags {
- baseArgs = append(baseArgs, extraArgs...)
- }
- return main, baseArgs, nil
-}
-
-// validPluginName is a regular expression that validates plugin names.
-//
-// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, _ and -.
-var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$")
-
-// validatePluginData validates a plugin's YAML data.
-func validatePluginData(plug *Plugin, filepath string) error {
- if !validPluginName.MatchString(plug.Metadata.Name) {
- return fmt.Errorf("invalid plugin name at %q", filepath)
- }
- plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage)
-
- // We could also validate SemVer, executable, and other fields should we so choose.
- return nil
-}
-
-// sanitizeString normalize spaces and removes non-printable characters.
-func sanitizeString(str string) string {
- return strings.Map(func(r rune) rune {
- if unicode.IsSpace(r) {
- return ' '
- }
- if unicode.IsPrint(r) {
- return r
- }
- return -1
- }, str)
-}
-
-func detectDuplicates(plugs []*Plugin) error {
- names := map[string]string{}
-
- for _, plug := range plugs {
- if oldpath, ok := names[plug.Metadata.Name]; ok {
- return fmt.Errorf(
- "two plugins claim the name %q at %q and %q",
- plug.Metadata.Name,
- oldpath,
- plug.Dir,
- )
- }
- names[plug.Metadata.Name] = plug.Dir
- }
-
- return nil
-}
-
-// LoadDir loads a plugin from the given directory.
-func LoadDir(dirname string) (*Plugin, error) {
- pluginfile := filepath.Join(dirname, PluginFileName)
- data, err := os.ReadFile(pluginfile)
- if err != nil {
- return nil, errors.Wrapf(err, "failed to read plugin at %q", pluginfile)
- }
-
- plug := &Plugin{Dir: dirname}
- if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil {
- return nil, errors.Wrapf(err, "failed to load plugin at %q", pluginfile)
- }
- return plug, validatePluginData(plug, pluginfile)
-}
-
-// LoadAll loads all plugins found beneath the base directory.
-//
-// This scans only one directory level.
-func LoadAll(basedir string) ([]*Plugin, error) {
- plugins := []*Plugin{}
- // We want basedir/*/plugin.yaml
- scanpath := filepath.Join(basedir, "*", PluginFileName)
- matches, err := filepath.Glob(scanpath)
- if err != nil {
- return plugins, errors.Wrapf(err, "failed to find plugins in %q", scanpath)
- }
-
- if matches == nil {
- return plugins, nil
- }
-
- for _, yaml := range matches {
- dir := filepath.Dir(yaml)
- p, err := LoadDir(dir)
- if err != nil {
- return plugins, err
- }
- plugins = append(plugins, p)
- }
- return plugins, detectDuplicates(plugins)
-}
-
-// FindPlugins returns a list of YAML files that describe plugins.
-func FindPlugins(plugdirs string) ([]*Plugin, error) {
- found := []*Plugin{}
- // Let's get all UNIXy and allow path separators
- for _, p := range filepath.SplitList(plugdirs) {
- matches, err := LoadAll(p)
- if err != nil {
- return matches, err
- }
- found = append(found, matches...)
- }
- return found, nil
-}
-
-// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because
-// the plugin subsystem itself needs access to the environment variables
-// created here.
-func SetupPluginEnv(settings *cli.EnvSettings, name, base string) {
- env := settings.EnvVars()
- env["HELM_PLUGIN_NAME"] = name
- env["HELM_PLUGIN_DIR"] = base
- for key, val := range env {
- os.Setenv(key, val)
- }
-}
diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go
deleted file mode 100644
index abbc90f7d..000000000
--- a/pkg/plugin/plugin_test.go
+++ /dev/null
@@ -1,398 +0,0 @@
-/*
-Copyright The Helm Authors.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package plugin // import "helm.sh/helm/v3/pkg/plugin"
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "reflect"
- "runtime"
- "testing"
-
- "helm.sh/helm/v3/pkg/cli"
-)
-
-func checkCommand(p *Plugin, extraArgs []string, osStrCmp string, t *testing.T) {
- cmd, args, err := p.PrepareCommand(extraArgs)
- if err != nil {
- t.Fatal(err)
- }
- if cmd != "echo" {
- t.Fatalf("Expected echo, got %q", cmd)
- }
-
- if l := len(args); l != 5 {
- t.Fatalf("expected 5 args, got %d", l)
- }
-
- expect := []string{"-n", osStrCmp, "--debug", "--foo", "bar"}
- for i := 0; i < len(args); i++ {
- if expect[i] != args[i] {
- t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
- }
- }
-
- // Test with IgnoreFlags. This should omit --debug, --foo, bar
- p.Metadata.IgnoreFlags = true
- cmd, args, err = p.PrepareCommand(extraArgs)
- if err != nil {
- t.Fatal(err)
- }
- if cmd != "echo" {
- t.Fatalf("Expected echo, got %q", cmd)
- }
- if l := len(args); l != 2 {
- t.Fatalf("expected 2 args, got %d", l)
- }
- expect = []string{"-n", osStrCmp}
- for i := 0; i < len(args); i++ {
- if expect[i] != args[i] {
- t.Errorf("Expected arg=%q, got %q", expect[i], args[i])
- }
- }
-}
-
-func TestPrepareCommand(t *testing.T) {
- p := &Plugin{
- Dir: "/tmp", // Unused
- Metadata: &Metadata{
- Name: "test",
- Command: "echo -n foo",
- },
- }
- argv := []string{"--debug", "--foo", "bar"}
-
- checkCommand(p, argv, "foo", t)
-}
-
-func TestPlatformPrepareCommand(t *testing.T) {
- p := &Plugin{
- Dir: "/tmp", // Unused
- Metadata: &Metadata{
- Name: "test",
- Command: "echo -n os-arch",
- PlatformCommand: []PlatformCommand{
- {OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
- {OperatingSystem: "linux", Architecture: "amd64", Command: "echo -n linux-amd64"},
- {OperatingSystem: "linux", Architecture: "arm64", Command: "echo -n linux-arm64"},
- {OperatingSystem: "linux", Architecture: "ppc64le", Command: "echo -n linux-ppc64le"},
- {OperatingSystem: "linux", Architecture: "s390x", Command: "echo -n linux-s390x"},
- {OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
- },
- },
- }
- var osStrCmp string
- os := runtime.GOOS
- arch := runtime.GOARCH
- if os == "linux" && arch == "386" {
- osStrCmp = "linux-386"
- } else if os == "linux" && arch == "amd64" {
- osStrCmp = "linux-amd64"
- } else if os == "linux" && arch == "arm64" {
- osStrCmp = "linux-arm64"
- } else if os == "linux" && arch == "ppc64le" {
- osStrCmp = "linux-ppc64le"
- } else if os == "linux" && arch == "s390x" {
- osStrCmp = "linux-s390x"
- } else if os == "windows" && arch == "amd64" {
- osStrCmp = "win-64"
- } else {
- osStrCmp = "os-arch"
- }
-
- argv := []string{"--debug", "--foo", "bar"}
- checkCommand(p, argv, osStrCmp, t)
-}
-
-func TestPartialPlatformPrepareCommand(t *testing.T) {
- p := &Plugin{
- Dir: "/tmp", // Unused
- Metadata: &Metadata{
- Name: "test",
- Command: "echo -n os-arch",
- PlatformCommand: []PlatformCommand{
- {OperatingSystem: "linux", Architecture: "386", Command: "echo -n linux-386"},
- {OperatingSystem: "windows", Architecture: "amd64", Command: "echo -n win-64"},
- },
- },
- }
- var osStrCmp string
- os := runtime.GOOS
- arch := runtime.GOARCH
- if os == "linux" {
- osStrCmp = "linux-386"
- } else if os == "windows" && arch == "amd64" {
- osStrCmp = "win-64"
- } else {
- osStrCmp = "os-arch"
- }
-
- argv := []string{"--debug", "--foo", "bar"}
- checkCommand(p, argv, osStrCmp, t)
-}
-
-func TestNoPrepareCommand(t *testing.T) {
- p := &Plugin{
- Dir: "/tmp", // Unused
- Metadata: &Metadata{
- Name: "test",
- },
- }
- argv := []string{"--debug", "--foo", "bar"}
-
- _, _, err := p.PrepareCommand(argv)
- if err == nil {
- t.Fatalf("Expected error to be returned")
- }
-}
-
-func TestNoMatchPrepareCommand(t *testing.T) {
- p := &Plugin{
- Dir: "/tmp", // Unused
- Metadata: &Metadata{
- Name: "test",
- PlatformCommand: []PlatformCommand{
- {OperatingSystem: "no-os", Architecture: "amd64", Command: "echo -n linux-386"},
- },
- },
- }
- argv := []string{"--debug", "--foo", "bar"}
-
- if _, _, err := p.PrepareCommand(argv); err == nil {
- t.Fatalf("Expected error to be returned")
- }
-}
-
-func TestLoadDir(t *testing.T) {
- dirname := "testdata/plugdir/good/hello"
- plug, err := LoadDir(dirname)
- if err != nil {
- t.Fatalf("error loading Hello plugin: %s", err)
- }
-
- if plug.Dir != dirname {
- t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir)
- }
-
- expect := &Metadata{
- Name: "hello",
- Version: "0.1.0",
- Usage: "usage",
- Description: "description",
- Command: "$HELM_PLUGIN_DIR/hello.sh",
- IgnoreFlags: true,
- Hooks: map[string]string{
- Install: "echo installing...",
- },
- }
-
- if !reflect.DeepEqual(expect, plug.Metadata) {
- t.Fatalf("Expected plugin metadata %v, got %v", expect, plug.Metadata)
- }
-}
-
-func TestLoadDirDuplicateEntries(t *testing.T) {
- dirname := "testdata/plugdir/bad/duplicate-entries"
- if _, err := LoadDir(dirname); err == nil {
- t.Errorf("successfully loaded plugin with duplicate entries when it should've failed")
- }
-}
-
-func TestDownloader(t *testing.T) {
- dirname := "testdata/plugdir/good/downloader"
- plug, err := LoadDir(dirname)
- if err != nil {
- t.Fatalf("error loading Hello plugin: %s", err)
- }
-
- if plug.Dir != dirname {
- t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir)
- }
-
- expect := &Metadata{
- Name: "downloader",
- Version: "1.2.3",
- Usage: "usage",
- Description: "download something",
- Command: "echo Hello",
- Downloaders: []Downloaders{
- {
- Protocols: []string{"myprotocol", "myprotocols"},
- Command: "echo Download",
- },
- },
- }
-
- if !reflect.DeepEqual(expect, plug.Metadata) {
- t.Fatalf("Expected metadata %v, got %v", expect, plug.Metadata)
- }
-}
-
-func TestLoadAll(t *testing.T) {
-
- // Verify that empty dir loads:
- if plugs, err := LoadAll("testdata"); err != nil {
- t.Fatalf("error loading dir with no plugins: %s", err)
- } else if len(plugs) > 0 {
- t.Fatalf("expected empty dir to have 0 plugins")
- }
-
- basedir := "testdata/plugdir/good"
- plugs, err := LoadAll(basedir)
- if err != nil {
- t.Fatalf("Could not load %q: %s", basedir, err)
- }
-
- if l := len(plugs); l != 3 {
- t.Fatalf("expected 3 plugins, found %d", l)
- }
-
- if plugs[0].Metadata.Name != "downloader" {
- t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name)
- }
- if plugs[1].Metadata.Name != "echo" {
- t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name)
- }
- if plugs[2].Metadata.Name != "hello" {
- t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name)
- }
-}
-
-func TestFindPlugins(t *testing.T) {
- cases := []struct {
- name string
- plugdirs string
- expected int
- }{
- {
- name: "plugdirs is empty",
- plugdirs: "",
- expected: 0,
- },
- {
- 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: 3,
- },
- }
- for _, c := range cases {
- t.Run(t.Name(), func(t *testing.T) {
- plugin, _ := FindPlugins(c.plugdirs)
- if len(plugin) != c.expected {
- t.Errorf("expected: %v, got: %v", c.expected, len(plugin))
- }
- })
- }
-}
-
-func TestSetupEnv(t *testing.T) {
- name := "pequod"
- base := filepath.Join("testdata/helmhome/helm/plugins", name)
-
- s := cli.New()
- s.PluginsDirectory = "testdata/helmhome/helm/plugins"
-
- SetupPluginEnv(s, name, base)
- for _, tt := range []struct {
- name, expect string
- }{
- {"HELM_PLUGIN_NAME", name},
- {"HELM_PLUGIN_DIR", base},
- } {
- if got := os.Getenv(tt.name); got != tt.expect {
- t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
- }
- }
-}
-
-func TestSetupEnvWithSpace(t *testing.T) {
- name := "sureshdsk"
- base := filepath.Join("testdata/helm home/helm/plugins", name)
-
- s := cli.New()
- s.PluginsDirectory = "testdata/helm home/helm/plugins"
-
- SetupPluginEnv(s, name, base)
- for _, tt := range []struct {
- name, expect string
- }{
- {"HELM_PLUGIN_NAME", name},
- {"HELM_PLUGIN_DIR", base},
- } {
- if got := os.Getenv(tt.name); got != tt.expect {
- t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got)
- }
- }
-}
-
-func TestValidatePluginData(t *testing.T) {
- for i, item := range []struct {
- pass bool
- plug *Plugin
- }{
- {true, mockPlugin("abcdefghijklmnopqrstuvwxyz0123456789_-ABC")},
- {true, mockPlugin("foo-bar-FOO-BAR_1234")},
- {false, mockPlugin("foo -bar")},
- {false, mockPlugin("$foo -bar")}, // Test leading chars
- {false, mockPlugin("foo -bar ")}, // Test trailing chars
- {false, mockPlugin("foo\nbar")}, // Test newline
- } {
- err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i))
- if item.pass && err != nil {
- t.Errorf("failed to validate case %d: %s", i, err)
- } else if !item.pass && err == nil {
- t.Errorf("expected case %d to fail", i)
- }
- }
-}
-
-func TestDetectDuplicates(t *testing.T) {
- plugs := []*Plugin{
- mockPlugin("foo"),
- mockPlugin("bar"),
- }
- if err := detectDuplicates(plugs); err != nil {
- t.Error("no duplicates in the first set")
- }
- plugs = append(plugs, mockPlugin("foo"))
- if err := detectDuplicates(plugs); err == nil {
- t.Error("duplicates in the second set")
- }
-}
-
-func mockPlugin(name string) *Plugin {
- return &Plugin{
- Metadata: &Metadata{
- Name: name,
- Version: "v0.1.2",
- Usage: "Mock plugin",
- Description: "Mock plugin for testing",
- Command: "echo mock plugin",
- },
- Dir: "no-such-dir",
- }
-}
diff --git a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml b/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
deleted file mode 100644
index b857b55ee..000000000
--- a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-name: "hello"
-version: "0.1.0"
-usage: "usage"
-description: |-
- description
-command: "$HELM_PLUGIN_DIR/hello.sh"
-ignoreFlags: true
-hooks:
- install: "echo installing..."
diff --git a/pkg/postrender/exec.go b/pkg/postrender/exec.go
index 167e737d6..16d9c09ce 100644
--- a/pkg/postrender/exec.go
+++ b/pkg/postrender/exec.go
@@ -18,11 +18,10 @@ package postrender
import (
"bytes"
+ "fmt"
"io"
"os/exec"
"path/filepath"
-
- "github.com/pkg/errors"
)
type execRender struct {
@@ -61,7 +60,13 @@ func (p *execRender) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error)
}()
err = cmd.Run()
if err != nil {
- return nil, errors.Wrapf(err, "error while running command %s. error output:\n%s", p.binaryPath, stderr.String())
+ return nil, fmt.Errorf("error while running command %s. error output:\n%s: %w", p.binaryPath, stderr.String(), err)
+ }
+
+ // If the binary returned almost nothing, it's likely that it didn't
+ // successfully render anything
+ if len(bytes.TrimSpace(postRendered.Bytes())) == 0 {
+ return nil, fmt.Errorf("post-renderer %q produced empty output", p.binaryPath)
}
return postRendered, nil
@@ -89,7 +94,7 @@ func getFullPath(binaryPath string) (string, error) {
// // The plugins variable can actually contain multiple paths, so loop through those
// for _, p := range filepath.SplitList(pluginDir) {
// _, err := os.Stat(filepath.Join(p, binaryPath))
- // if err != nil && !os.IsNotExist(err) {
+ // if err != nil && !errors.Is(err, fs.ErrNotExist) {
// return "", err
// } else if err == nil {
// binaryPath = filepath.Join(p, binaryPath)
@@ -102,7 +107,7 @@ func getFullPath(binaryPath string) (string, error) {
// the path and is executable
checkedPath, err := exec.LookPath(binaryPath)
if err != nil {
- return "", errors.Wrapf(err, "unable to find binary at %s", binaryPath)
+ return "", fmt.Errorf("unable to find binary at %s: %w", binaryPath, err)
}
return filepath.Abs(checkedPath)
diff --git a/pkg/postrender/exec_test.go b/pkg/postrender/exec_test.go
index 19a6ec6c4..a10ad2cc4 100644
--- a/pkg/postrender/exec_test.go
+++ b/pkg/postrender/exec_test.go
@@ -60,11 +60,7 @@ func TestGetFullPath(t *testing.T) {
t.Run("binary in PATH resolves correctly", func(t *testing.T) {
testpath := setupTestingScript(t)
- realPath := os.Getenv("PATH")
- os.Setenv("PATH", filepath.Dir(testpath))
- defer func() {
- os.Setenv("PATH", realPath)
- }()
+ t.Setenv("PATH", filepath.Dir(testpath))
fullPath, err := getFullPath(filepath.Base(testpath))
is.NoError(err)
@@ -121,6 +117,21 @@ func TestExecRun(t *testing.T) {
is.Contains(output.String(), "BARTEST")
}
+func TestExecRunWithNoOutput(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ // the actual Run test uses a basic sed example, so skip this test on windows
+ t.Skip("skipping on windows")
+ }
+ is := assert.New(t)
+ testpath := setupTestingScript(t)
+
+ renderer, err := NewExec(testpath)
+ require.NoError(t, err)
+
+ _, err = renderer.Run(bytes.NewBufferString(""))
+ is.Error(err)
+}
+
func TestNewExecWithOneArgsRun(t *testing.T) {
if runtime.GOOS == "windows" {
// the actual Run test uses a basic sed example, so skip this test on windows
@@ -168,7 +179,7 @@ func setupTestingScript(t *testing.T) (filepath string) {
t.Fatalf("unable to write tempfile for testing: %s", err)
}
- err = f.Chmod(0755)
+ err = f.Chmod(0o755)
if err != nil {
t.Fatalf("unable to make tempfile executable for testing: %s", err)
}
diff --git a/pkg/provenance/doc.go b/pkg/provenance/doc.go
index 0c7ae0618..883c0e724 100644
--- a/pkg/provenance/doc.go
+++ b/pkg/provenance/doc.go
@@ -35,4 +35,4 @@ and using `gpg --verify`, `keybase pgp verify`, or similar:
gpg: Signature made Mon Jul 25 17:23:44 2016 MDT using RSA key ID 1FC18762
gpg: Good signature from "Helm Testing (This key should only be used for testing. DO NOT TRUST.) " [ultimate]
*/
-package provenance // import "helm.sh/helm/v3/pkg/provenance"
+package provenance // import "helm.sh/helm/v4/pkg/provenance"
diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go
index 7f89ef3f5..504bc6aa1 100644
--- a/pkg/provenance/sign.go
+++ b/pkg/provenance/sign.go
@@ -19,19 +19,20 @@ import (
"bytes"
"crypto"
"encoding/hex"
+ "errors"
+ "fmt"
"io"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
"golang.org/x/crypto/openpgp" //nolint
"golang.org/x/crypto/openpgp/clearsign" //nolint
"golang.org/x/crypto/openpgp/packet" //nolint
"sigs.k8s.io/yaml"
- hapi "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
+ hapi "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
)
var defaultPGPConfig = packet.Config{
@@ -143,7 +144,7 @@ func NewFromKeyring(keyringfile, id string) (*Signatory, error) {
}
}
if vague {
- return s, errors.Errorf("more than one key contain the id %q", id)
+ return s, fmt.Errorf("more than one key contain the id %q", id)
}
s.Entity = candidate
@@ -236,12 +237,12 @@ func (s *Signatory) ClearSign(chartpath string) (string, error) {
// In other words, if we call Close here, there's a risk that there's an attempt to use the
// private key to sign garbage data (since we know that io.Copy failed, `w` won't contain
// anything useful).
- return "", errors.Wrap(err, "failed to write to clearsign encoder")
+ return "", fmt.Errorf("failed to write to clearsign encoder: %w", err)
}
err = w.Close()
if err != nil {
- return "", errors.Wrap(err, "failed to either sign or armor message block")
+ return "", fmt.Errorf("failed to either sign or armor message block: %w", err)
}
return out.String(), nil
@@ -254,14 +255,14 @@ func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) {
if fi, err := os.Stat(fname); err != nil {
return ver, err
} else if fi.IsDir() {
- return ver, errors.Errorf("%s cannot be a directory", fname)
+ return ver, fmt.Errorf("%s cannot be a directory", fname)
}
}
// First verify the signature
sig, err := s.decodeSignature(sigpath)
if err != nil {
- return ver, errors.Wrap(err, "failed to decode signature")
+ return ver, fmt.Errorf("failed to decode signature: %w", err)
}
by, err := s.verifySignature(sig)
@@ -283,9 +284,9 @@ func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) {
sum = "sha256:" + sum
basename := filepath.Base(chartpath)
if sha, ok := sums.Files[basename]; !ok {
- return ver, errors.Errorf("provenance does not contain a SHA for a file named %q", basename)
+ return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", basename)
} else if sha != sum {
- return ver, errors.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum)
+ return ver, fmt.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum)
}
ver.FileHash = sum
ver.FileName = basename
diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go
index 17f727ea7..9a60fd19c 100644
--- a/pkg/provenance/sign_test.go
+++ b/pkg/provenance/sign_test.go
@@ -34,7 +34,7 @@ const (
// phrase. Use `gpg --export-secret-keys helm-test` to export the secret.
testKeyfile = "testdata/helm-test-key.secret"
- // testPasswordKeyFile is a keyfile with a password.
+ // testPasswordKeyfile is a keyfile with a password.
testPasswordKeyfile = "testdata/helm-password-key.secret"
// testPubfile is the public key file.
@@ -196,7 +196,7 @@ func TestDecryptKey(t *testing.T) {
}
// We give this a simple callback that returns the password.
- if err := k.DecryptKey(func(s string) ([]byte, error) {
+ if err := k.DecryptKey(func(_ string) ([]byte, error) {
return []byte("secret"), nil
}); err != nil {
t.Fatal(err)
@@ -208,7 +208,7 @@ func TestDecryptKey(t *testing.T) {
t.Fatal(err)
}
// Now we give it a bogus password.
- if err := k.DecryptKey(func(s string) ([]byte, error) {
+ if err := k.DecryptKey(func(_ string) ([]byte, error) {
return []byte("secrets_and_lies"), nil
}); err == nil {
t.Fatal("Expected an error when giving a bogus passphrase")
@@ -276,7 +276,7 @@ func TestDecodeSignature(t *testing.T) {
t.Fatal(err)
}
- f, err := os.CreateTemp("", "helm-test-sig-")
+ f, err := os.CreateTemp(t.TempDir(), "helm-test-sig-")
if err != nil {
t.Fatal(err)
}
diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go
index 94154d389..699d27caf 100644
--- a/pkg/pusher/ocipusher.go
+++ b/pkg/pusher/ocipusher.go
@@ -16,7 +16,9 @@ limitations under the License.
package pusher
import (
+ "errors"
"fmt"
+ "io/fs"
"net"
"net/http"
"os"
@@ -24,11 +26,10 @@ import (
"strings"
"time"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/internal/tlsutil"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/time/ctime"
)
// OCIPusher is the default OCI backend handler
@@ -47,8 +48,8 @@ func (pusher *OCIPusher) Push(chartRef, href string, options ...Option) error {
func (pusher *OCIPusher) push(chartRef, href string) error {
stat, err := os.Stat(chartRef)
if err != nil {
- if os.IsNotExist(err) {
- return errors.Errorf("%s: no such file", chartRef)
+ if errors.Is(err, fs.ErrNotExist) {
+ return fmt.Errorf("%s: no such file", chartRef)
}
return err
}
@@ -89,6 +90,10 @@ func (pusher *OCIPusher) push(chartRef, href string) error {
path.Join(strings.TrimPrefix(href, fmt.Sprintf("%s://", 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)
+ chartArchiveFileCreatedTime := ctime.Modified(stat)
+ pushOpts = append(pushOpts, registry.PushOptCreationTime(chartArchiveFileCreatedTime.Format(time.RFC3339)))
+
_, err = client.Push(chartBytes, ref, pushOpts...)
return err
}
@@ -106,9 +111,13 @@ func NewOCIPusher(ops ...Option) (Pusher, error) {
func (pusher *OCIPusher) newRegistryClient() (*registry.Client, error) {
if (pusher.opts.certFile != "" && pusher.opts.keyFile != "") || pusher.opts.caFile != "" || pusher.opts.insecureSkipTLSverify {
- tlsConf, err := tlsutil.NewClientTLS(pusher.opts.certFile, pusher.opts.keyFile, pusher.opts.caFile, pusher.opts.insecureSkipTLSverify)
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(pusher.opts.insecureSkipTLSverify),
+ tlsutil.WithCertKeyPairFiles(pusher.opts.certFile, pusher.opts.keyFile),
+ tlsutil.WithCAFile(pusher.opts.caFile),
+ )
if err != nil {
- return nil, errors.Wrap(err, "can't create TLS config for client")
+ return nil, fmt.Errorf("can't create TLS config for client: %w", err)
}
registryClient, err := registry.NewClient(
diff --git a/pkg/pusher/ocipusher_test.go b/pkg/pusher/ocipusher_test.go
index 11842b4ae..24f52a7ad 100644
--- a/pkg/pusher/ocipusher_test.go
+++ b/pkg/pusher/ocipusher_test.go
@@ -1,3 +1,5 @@
+//go:build !windows
+
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
@@ -16,10 +18,13 @@ limitations under the License.
package pusher
import (
+ "io"
+ "os"
"path/filepath"
+ "strings"
"testing"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/registry"
)
func TestNewOCIPusher(t *testing.T) {
@@ -94,3 +99,330 @@ func TestNewOCIPusher(t *testing.T) {
t.Errorf("Expected NewOCIPusher to contain %p as RegistryClient, got %p", registryClient, op.opts.registryClient)
}
}
+
+func TestOCIPusher_Push_ErrorHandling(t *testing.T) {
+ tests := []struct {
+ name string
+ chartRef string
+ expectedError string
+ setupFunc func() string
+ }{
+ {
+ name: "non-existent file",
+ chartRef: "/non/existent/file.tgz",
+ expectedError: "no such file",
+ },
+ {
+ name: "directory instead of file",
+ expectedError: "cannot push directory, must provide chart archive (.tgz)",
+ setupFunc: func() string {
+ tempDir := t.TempDir()
+ return tempDir
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pusher, err := NewOCIPusher()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ chartRef := tt.chartRef
+ if tt.setupFunc != nil {
+ chartRef = tt.setupFunc()
+ }
+
+ err = pusher.Push(chartRef, "oci://localhost:5000/test")
+ if err == nil {
+ t.Fatal("Expected error but got none")
+ }
+
+ if !strings.Contains(err.Error(), tt.expectedError) {
+ t.Errorf("Expected error containing %q, got %q", tt.expectedError, err.Error())
+ }
+ })
+ }
+}
+
+func TestOCIPusher_newRegistryClient(t *testing.T) {
+ cd := "../../testdata"
+ join := filepath.Join
+ ca, pub, priv := join(cd, "rootca.crt"), join(cd, "crt.pem"), join(cd, "key.pem")
+
+ tests := []struct {
+ name string
+ opts []Option
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "plain HTTP",
+ opts: []Option{WithPlainHTTP(true)},
+ },
+ {
+ name: "with TLS client config",
+ opts: []Option{
+ WithTLSClientConfig(pub, priv, ca),
+ },
+ },
+ {
+ name: "with insecure skip TLS verify",
+ opts: []Option{
+ WithInsecureSkipTLSVerify(true),
+ },
+ },
+ {
+ name: "with cert and key only",
+ opts: []Option{
+ WithTLSClientConfig(pub, priv, ""),
+ },
+ },
+ {
+ name: "with CA file only",
+ opts: []Option{
+ WithTLSClientConfig("", "", ca),
+ },
+ },
+ {
+ name: "default client without options",
+ opts: []Option{},
+ },
+ {
+ name: "invalid cert file",
+ opts: []Option{
+ WithTLSClientConfig("/non/existent/cert.pem", priv, ca),
+ },
+ expectError: true,
+ errorContains: "can't create TLS config",
+ },
+ {
+ name: "invalid key file",
+ opts: []Option{
+ WithTLSClientConfig(pub, "/non/existent/key.pem", ca),
+ },
+ expectError: true,
+ errorContains: "can't create TLS config",
+ },
+ {
+ name: "invalid CA file",
+ opts: []Option{
+ WithTLSClientConfig("", "", "/non/existent/ca.crt"),
+ },
+ expectError: true,
+ errorContains: "can't create TLS config",
+ },
+ {
+ name: "combined TLS options",
+ opts: []Option{
+ WithTLSClientConfig(pub, priv, ca),
+ WithInsecureSkipTLSVerify(true),
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pusher, err := NewOCIPusher(tt.opts...)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ op, ok := pusher.(*OCIPusher)
+ if !ok {
+ t.Fatal("Expected *OCIPusher")
+ }
+
+ client, err := op.newRegistryClient()
+ if tt.expectError {
+ if err == nil {
+ t.Fatal("Expected error but got none")
+ }
+ if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error())
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ if client == nil {
+ t.Fatal("Expected non-nil registry client")
+ }
+ }
+ })
+ }
+}
+
+func TestOCIPusher_Push_ChartOperations(t *testing.T) {
+ // Path to test charts
+ chartPath := "../../pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz"
+ chartWithProvPath := "../../pkg/cmd/testdata/testcharts/signtest-0.1.0.tgz"
+
+ tests := []struct {
+ name string
+ chartRef string
+ href string
+ options []Option
+ setupFunc func(t *testing.T) (string, func())
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "invalid chart file",
+ chartRef: "../../pkg/action/testdata/charts/corrupted-compressed-chart.tgz",
+ href: "oci://localhost:5000/test",
+ expectError: true,
+ errorContains: "does not appear to be a gzipped archive",
+ },
+ {
+ name: "chart read error",
+ setupFunc: func(t *testing.T) (string, func()) {
+ t.Helper()
+ // Create a valid chart file that we'll make unreadable
+ tempDir := t.TempDir()
+ tempChart := filepath.Join(tempDir, "temp-chart.tgz")
+
+ // Copy a valid chart
+ src, err := os.Open(chartPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer src.Close()
+
+ dst, err := os.Create(tempChart)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := io.Copy(dst, src); err != nil {
+ t.Fatal(err)
+ }
+ dst.Close()
+
+ // Make the file unreadable
+ if err := os.Chmod(tempChart, 0000); err != nil {
+ t.Fatal(err)
+ }
+
+ return tempChart, func() {
+ os.Chmod(tempChart, 0644) // Restore permissions for cleanup
+ }
+ },
+ href: "oci://localhost:5000/test",
+ expectError: true,
+ errorContains: "permission denied",
+ },
+ {
+ name: "push with provenance file - loading phase",
+ chartRef: chartWithProvPath,
+ href: "oci://registry.example.com/charts",
+ setupFunc: func(t *testing.T) (string, func()) {
+ t.Helper()
+ // Copy chart and create a .prov file for it
+ tempDir := t.TempDir()
+ tempChart := filepath.Join(tempDir, "signtest-0.1.0.tgz")
+ tempProv := filepath.Join(tempDir, "signtest-0.1.0.tgz.prov")
+
+ // Copy chart file
+ src, err := os.Open(chartWithProvPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer src.Close()
+
+ dst, err := os.Create(tempChart)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := io.Copy(dst, src); err != nil {
+ t.Fatal(err)
+ }
+ dst.Close()
+
+ // Create provenance file
+ if err := os.WriteFile(tempProv, []byte("test provenance data"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ return tempChart, func() {}
+ },
+ expectError: true, // Will fail at the registry push step
+ errorContains: "", // Error depends on registry client behavior
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ chartRef := tt.chartRef
+ var cleanup func()
+
+ if tt.setupFunc != nil {
+ chartRef, cleanup = tt.setupFunc(t)
+ if cleanup != nil {
+ defer cleanup()
+ }
+ }
+
+ // Skip test if chart file doesn't exist and we're not expecting an error
+ if _, err := os.Stat(chartRef); err != nil && !tt.expectError {
+ t.Skipf("Test chart %s not found, skipping test", chartRef)
+ }
+
+ pusher, err := NewOCIPusher(tt.options...)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = pusher.Push(chartRef, tt.href)
+
+ if tt.expectError {
+ if err == nil {
+ t.Fatal("Expected error but got none")
+ }
+ if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("Expected error containing %q, got %q", tt.errorContains, err.Error())
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestOCIPusher_Push_MultipleOptions(t *testing.T) {
+ chartPath := "../../pkg/cmd/testdata/testcharts/compressedchart-0.1.0.tgz"
+
+ // Skip test if chart file doesn't exist
+ if _, err := os.Stat(chartPath); err != nil {
+ t.Skipf("Test chart %s not found, skipping test", chartPath)
+ }
+
+ pusher, err := NewOCIPusher()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Test that multiple options are applied correctly
+ err = pusher.Push(chartPath, "oci://localhost:5000/test",
+ WithPlainHTTP(true),
+ WithInsecureSkipTLSVerify(true),
+ )
+
+ // We expect an error since we're not actually pushing to a registry
+ if err == nil {
+ t.Fatal("Expected error when pushing without a valid registry")
+ }
+
+ // Verify options were applied
+ op := pusher.(*OCIPusher)
+ if !op.opts.plainHTTP {
+ t.Error("Expected plainHTTP option to be applied")
+ }
+ if !op.opts.insecureSkipTLSverify {
+ t.Error("Expected insecureSkipTLSverify option to be applied")
+ }
+}
diff --git a/pkg/pusher/pusher.go b/pkg/pusher/pusher.go
index c99d97b35..e3c767be9 100644
--- a/pkg/pusher/pusher.go
+++ b/pkg/pusher/pusher.go
@@ -17,10 +17,11 @@ limitations under the License.
package pusher
import (
- "github.com/pkg/errors"
+ "fmt"
+ "slices"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/registry"
)
// options are generic parameters to be provided to the pusher during instantiation.
@@ -86,12 +87,7 @@ type Provider struct {
// Provides returns true if the given scheme is supported by this Provider.
func (p Provider) Provides(scheme string) bool {
- for _, i := range p.Schemes {
- if i == scheme {
- return true
- }
- }
- return false
+ return slices.Contains(p.Schemes, scheme)
}
// Providers is a collection of Provider objects.
@@ -106,7 +102,7 @@ func (p Providers) ByScheme(scheme string) (Pusher, error) {
return pp.New()
}
}
- return nil, errors.Errorf("scheme %q not supported", scheme)
+ return nil, fmt.Errorf("scheme %q not supported", scheme)
}
var ociProvider = Provider{
@@ -116,7 +112,7 @@ var ociProvider = Provider{
// All finds all of the registered pushers as a list of Provider instances.
// Currently, just the built-in pushers are collected.
-func All(settings *cli.EnvSettings) Providers {
+func All(_ *cli.EnvSettings) Providers {
result := Providers{ociProvider}
return result
}
diff --git a/pkg/pusher/pusher_test.go b/pkg/pusher/pusher_test.go
index d43e6c9ec..71fab8694 100644
--- a/pkg/pusher/pusher_test.go
+++ b/pkg/pusher/pusher_test.go
@@ -18,8 +18,8 @@ package pusher
import (
"testing"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/registry"
)
func TestProvider(t *testing.T) {
diff --git a/pkg/registry/client.go b/pkg/registry/client.go
index 829f6b494..7ba26ac5c 100644
--- a/pkg/registry/client.go
+++ b/pkg/registry/client.go
@@ -14,32 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package registry // import "helm.sh/helm/v3/pkg/registry"
+package registry // import "helm.sh/helm/v4/pkg/registry"
import (
"context"
+ "crypto/tls"
+ "crypto/x509"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
+ "net/url"
+ "os"
"sort"
"strings"
"github.com/Masterminds/semver/v3"
- "github.com/containerd/containerd/remotes"
+ "github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
- "github.com/pkg/errors"
- "oras.land/oras-go/pkg/auth"
- dockerauth "oras.land/oras-go/pkg/auth/docker"
- "oras.land/oras-go/pkg/content"
- "oras.land/oras-go/pkg/oras"
- "oras.land/oras-go/pkg/registry"
- registryremote "oras.land/oras-go/pkg/registry/remote"
- registryauth "oras.land/oras-go/pkg/registry/remote/auth"
-
- "helm.sh/helm/v3/internal/version"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/helmpath"
+ "oras.land/oras-go/v2"
+ "oras.land/oras-go/v2/content/memory"
+ "oras.land/oras-go/v2/registry"
+ "oras.land/oras-go/v2/registry/remote"
+ "oras.land/oras-go/v2/registry/remote/auth"
+ "oras.land/oras-go/v2/registry/remote/credentials"
+ "oras.land/oras-go/v2/registry/remote/retry"
+
+ "helm.sh/helm/v4/internal/version"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/helmpath"
)
// See https://github.com/helm/helm/issues/10166
@@ -50,18 +54,28 @@ an underscore (_) in chart version tags when pushing to a registry and back to
a plus (+) when pulling from a registry.`
type (
+ // RemoteClient shadows the ORAS remote.Client interface
+ // (hiding the ORAS type from Helm client visibility)
+ // https://pkg.go.dev/oras.land/oras-go/pkg/registry/remote#Client
+ RemoteClient interface {
+ Do(req *http.Request) (*http.Response, error)
+ }
+
// Client works with OCI-compliant registries
Client struct {
debug bool
enableCache bool
// path to repository config file e.g. ~/.docker/config.json
credentialsFile string
+ username string
+ password string
out io.Writer
- authorizer auth.Client
- registryAuthorizer *registryauth.Client
- resolver func(ref registry.Reference) (remotes.Resolver, error)
+ authorizer *auth.Client
+ registryAuthorizer RemoteClient
+ credentialsStore credentials.Store
httpClient *http.Client
plainHTTP bool
+ err error // pass any errors from the ClientOption functions
}
// ClientOption allows specifying various settings configurable by the user for overriding the defaults
@@ -76,93 +90,66 @@ func NewClient(options ...ClientOption) (*Client, error) {
}
for _, option := range options {
option(client)
+ if client.err != nil {
+ return nil, client.err
+ }
}
if client.credentialsFile == "" {
client.credentialsFile = helmpath.ConfigPath(CredentialsFileBasename)
}
- if client.authorizer == nil {
- authClient, err := dockerauth.NewClientWithDockerFallback(client.credentialsFile)
- if err != nil {
- return nil, err
+ if client.httpClient == nil {
+ client.httpClient = &http.Client{
+ Transport: NewTransport(client.debug),
}
- client.authorizer = authClient
}
- resolverFn := client.resolver // copy for avoiding recursive call
- client.resolver = func(ref registry.Reference) (remotes.Resolver, error) {
- if resolverFn != nil {
- // validate if the resolverFn returns a valid resolver
- if resolver, err := resolverFn(ref); resolver != nil && err == nil {
- return resolver, nil
- }
+ storeOptions := credentials.StoreOptions{
+ AllowPlaintextPut: true,
+ DetectDefaultNativeStore: true,
+ }
+ store, err := credentials.NewStore(client.credentialsFile, storeOptions)
+ if err != nil {
+ return nil, err
+ }
+ dockerStore, err := credentials.NewStoreFromDocker(storeOptions)
+ if err != nil {
+ // should only fail if user home directory can't be determined
+ client.credentialsStore = store
+ } else {
+ // use Helm credentials with fallback to Docker
+ client.credentialsStore = credentials.NewStoreWithFallbacks(store, dockerStore)
+ }
+
+ if client.authorizer == nil {
+ authorizer := auth.Client{
+ Client: client.httpClient,
}
+ authorizer.SetUserAgent(version.GetUserAgent())
- headers := http.Header{}
- headers.Set("User-Agent", version.GetUserAgent())
- dockerClient, ok := client.authorizer.(*dockerauth.Client)
- if ok {
- username, password, err := dockerClient.Credential(ref.Registry)
- if err != nil {
- return nil, fmt.Errorf("unable to retrieve credentials: %w", err)
+ if client.username != "" && client.password != "" {
+ authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) {
+ return auth.Credential{Username: client.username, Password: client.password}, nil
}
- authHeader(username, password, &headers)
+ } else {
+ authorizer.Credential = credentials.Credential(client.credentialsStore)
}
- opts := []auth.ResolverOption{auth.WithResolverHeaders(headers)}
- if client.httpClient != nil {
- opts = append(opts, auth.WithResolverClient(client.httpClient))
- }
- if client.plainHTTP {
- opts = append(opts, auth.WithResolverPlainHTTP())
- }
- resolver, err := client.authorizer.ResolverWithOpts(opts...)
- if err != nil {
- return nil, err
+ if client.enableCache {
+ authorizer.Cache = auth.NewCache()
}
- return resolver, nil
- }
- // allocate a cache if option is set
- var cache registryauth.Cache
- if client.enableCache {
- cache = registryauth.DefaultCache
+ authorizer.ForceAttemptOAuth2 = true
+ client.authorizer = &authorizer
}
- if client.registryAuthorizer == nil {
- client.registryAuthorizer = ®istryauth.Client{
- Client: client.httpClient,
- Header: http.Header{
- "User-Agent": {version.GetUserAgent()},
- },
- Cache: cache,
- Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) {
- dockerClient, ok := client.authorizer.(*dockerauth.Client)
- if !ok {
- return registryauth.EmptyCredential, errors.New("unable to obtain docker client")
- }
- username, password, err := dockerClient.Credential(reg)
- if err != nil {
- return registryauth.EmptyCredential, errors.New("unable to retrieve credentials")
- }
-
- // A blank returned username and password value is a bearer token
- if username == "" && password != "" {
- return registryauth.Credential{
- RefreshToken: password,
- }, nil
- }
-
- return registryauth.Credential{
- Username: username,
- Password: password,
- }, nil
-
- },
- }
- }
return client, nil
}
+// Generic returns a GenericClient for low-level OCI operations
+func (c *Client) Generic() *GenericClient {
+ return NewGenericClient(c)
+}
+
// ClientOptDebug returns a function that sets the debug setting on client options set
func ClientOptDebug(debug bool) ClientOption {
return func(client *Client) {
@@ -177,6 +164,14 @@ func ClientOptEnableCache(enableCache bool) ClientOption {
}
}
+// ClientOptBasicAuth returns a function that sets the username and password setting on client options set
+func ClientOptBasicAuth(username, password string) ClientOption {
+ return func(client *Client) {
+ client.username = username
+ client.password = password
+ }
+}
+
// ClientOptWriter returns a function that sets the writer setting on client options set
func ClientOptWriter(out io.Writer) ClientOption {
return func(client *Client) {
@@ -184,6 +179,26 @@ func ClientOptWriter(out io.Writer) ClientOption {
}
}
+// ClientOptAuthorizer returns a function that sets the authorizer setting on a client options set. This
+// can be used to override the default authorization mechanism.
+//
+// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
+func ClientOptAuthorizer(authorizer auth.Client) ClientOption {
+ return func(client *Client) {
+ client.authorizer = &authorizer
+ }
+}
+
+// ClientOptRegistryAuthorizer returns a function that sets the registry authorizer setting on a client options set. This
+// can be used to override the default authorization mechanism.
+//
+// Depending on the use-case you may need to set both ClientOptAuthorizer and ClientOptRegistryAuthorizer.
+func ClientOptRegistryAuthorizer(registryAuthorizer RemoteClient) ClientOption {
+ return func(client *Client) {
+ client.registryAuthorizer = registryAuthorizer
+ }
+}
+
// ClientOptCredentialsFile returns a function that sets the credentialsFile setting on a client options set
func ClientOptCredentialsFile(credentialsFile string) ClientOption {
return func(client *Client) {
@@ -204,74 +219,153 @@ func ClientOptPlainHTTP() ClientOption {
}
}
-// ClientOptResolver returns a function that sets the resolver setting on a client options set
-func ClientOptResolver(resolver remotes.Resolver) ClientOption {
- return func(client *Client) {
- client.resolver = func(ref registry.Reference) (remotes.Resolver, error) {
- return resolver, nil
- }
- }
-}
-
type (
// LoginOption allows specifying various settings on login
LoginOption func(*loginOperation)
loginOperation struct {
- username string
- password string
- insecure bool
- certFile string
- keyFile string
- caFile string
+ host string
+ client *Client
}
)
// Login logs into a registry
func (c *Client) Login(host string, options ...LoginOption) error {
- operation := &loginOperation{}
for _, option := range options {
- option(operation)
+ option(&loginOperation{host, c})
}
- authorizerLoginOpts := []auth.LoginOption{
- auth.WithLoginContext(ctx(c.out, c.debug)),
- auth.WithLoginHostname(host),
- auth.WithLoginUsername(operation.username),
- auth.WithLoginSecret(operation.password),
- auth.WithLoginUserAgent(version.GetUserAgent()),
- auth.WithLoginTLS(operation.certFile, operation.keyFile, operation.caFile),
+
+ reg, err := remote.NewRegistry(host)
+ if err != nil {
+ return err
}
- if operation.insecure {
- authorizerLoginOpts = append(authorizerLoginOpts, auth.WithLoginInsecure())
+ reg.PlainHTTP = c.plainHTTP
+ cred := auth.Credential{Username: c.username, Password: c.password}
+ c.authorizer.ForceAttemptOAuth2 = true
+ reg.Client = c.authorizer
+
+ ctx := context.Background()
+ if err := reg.Ping(ctx); err != nil {
+ c.authorizer.ForceAttemptOAuth2 = false
+ if err := reg.Ping(ctx); err != nil {
+ return fmt.Errorf("authenticating to %q: %w", host, err)
+ }
}
- if err := c.authorizer.LoginWithOpts(authorizerLoginOpts...); err != nil {
+
+ key := credentials.ServerAddressFromRegistry(host)
+ key = credentials.ServerAddressFromHostname(key)
+ if err := c.credentialsStore.Put(ctx, key, cred); err != nil {
return err
}
+
fmt.Fprintln(c.out, "Login Succeeded")
return nil
}
// LoginOptBasicAuth returns a function that sets the username/password settings on login
func LoginOptBasicAuth(username string, password string) LoginOption {
- return func(operation *loginOperation) {
- operation.username = username
- operation.password = password
+ return func(o *loginOperation) {
+ o.client.username = username
+ o.client.password = password
+ o.client.authorizer.Credential = auth.StaticCredential(o.host, auth.Credential{Username: username, Password: password})
}
}
+// LoginOptPlainText returns a function that allows plaintext (HTTP) login
+func LoginOptPlainText(isPlainText bool) LoginOption {
+ return func(o *loginOperation) {
+ o.client.plainHTTP = isPlainText
+ }
+}
+
+func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, error) {
+ var transport *http.Transport
+
+ switch t := client.Client.Transport.(type) {
+ case *http.Transport:
+ transport = t
+ case *retry.Transport:
+ switch t := t.Base.(type) {
+ case *http.Transport:
+ transport = t
+ case *LoggingTransport:
+ switch t := t.RoundTripper.(type) {
+ case *http.Transport:
+ transport = t
+ }
+ }
+ }
+
+ if transport == nil {
+ // we don't know how to access the http.Transport, most likely the
+ // auth.Client.Client was provided by API user
+ return nil, fmt.Errorf("unable to access TLS client configuration, the provided HTTP Transport is not supported, given: %T", client.Client.Transport)
+ }
+
+ switch {
+ case setConfig != nil:
+ transport.TLSClientConfig = setConfig
+ case transport.TLSClientConfig == nil:
+ transport.TLSClientConfig = &tls.Config{}
+ }
+
+ return transport.TLSClientConfig, nil
+}
+
// LoginOptInsecure returns a function that sets the insecure setting on login
func LoginOptInsecure(insecure bool) LoginOption {
- return func(operation *loginOperation) {
- operation.insecure = insecure
+ return func(o *loginOperation) {
+ tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
+
+ if err != nil {
+ panic(err)
+ }
+
+ tlsConfig.InsecureSkipVerify = insecure
}
}
// LoginOptTLSClientConfig returns a function that sets the TLS settings on login.
func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption {
- return func(operation *loginOperation) {
- operation.certFile = certFile
- operation.keyFile = keyFile
- operation.caFile = caFile
+ return func(o *loginOperation) {
+ if (certFile == "" || keyFile == "") && caFile == "" {
+ return
+ }
+ tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
+ if err != nil {
+ panic(err)
+ }
+
+ if certFile != "" && keyFile != "" {
+ authCert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ panic(err)
+ }
+ tlsConfig.Certificates = []tls.Certificate{authCert}
+ }
+
+ if caFile != "" {
+ certPool := x509.NewCertPool()
+ ca, err := os.ReadFile(caFile)
+ if err != nil {
+ panic(err)
+ }
+ if !certPool.AppendCertsFromPEM(ca) {
+ panic(fmt.Errorf("unable to parse CA file: %q", caFile))
+ }
+ tlsConfig.RootCAs = certPool
+ }
+ }
+}
+
+// LoginOptTLSClientConfigFromConfig returns a function that sets the TLS settings on login
+// receiving the configuration in memory rather than from files.
+func LoginOptTLSClientConfigFromConfig(conf *tls.Config) LoginOption {
+ return func(o *loginOperation) {
+ _, err := ensureTLSConfig(o.client.authorizer, conf)
+ if err != nil {
+ panic(err)
+ }
}
}
@@ -288,7 +382,8 @@ func (c *Client) Logout(host string, opts ...LogoutOption) error {
for _, opt := range opts {
opt(operation)
}
- if err := c.authorizer.Logout(ctx(c.out, c.debug), host); err != nil {
+
+ if err := credentials.Logout(context.Background(), c.credentialsStore, host); err != nil {
return err
}
fmt.Fprintf(c.out, "Removing login credentials for %s\n", host)
@@ -326,68 +421,31 @@ type (
}
)
-// Pull downloads a chart from a registry
-func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
- parsedRef, err := parseReference(ref)
- if err != nil {
- return nil, err
- }
+// processChartPull handles chart-specific processing of a generic pull result
+func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) {
+ var err error
- operation := &pullOperation{
- withChart: true, // By default, always download the chart layer
- }
- for _, option := range options {
- option(operation)
- }
- if !operation.withChart && !operation.withProv {
- return nil, errors.New(
- "must specify at least one layer to pull (chart/prov)")
- }
- memoryStore := content.NewMemory()
- allowedMediaTypes := []string{
- ConfigMediaType,
- }
+ // Chart-specific validation
minNumDescriptors := 1 // 1 for the config
if operation.withChart {
minNumDescriptors++
- allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
- }
- if operation.withProv {
- if !operation.ignoreMissingProv {
- minNumDescriptors++
- }
- allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
}
-
- var descriptors, layers []ocispec.Descriptor
- remotesResolver, err := c.resolver(parsedRef)
- if err != nil {
- return nil, err
- }
- registryStore := content.Registry{Resolver: remotesResolver}
-
- manifest, err := oras.Copy(ctx(c.out, c.debug), registryStore, parsedRef.String(), memoryStore, "",
- oras.WithPullEmptyNameAllowed(),
- oras.WithAllowedMediaTypes(allowedMediaTypes),
- oras.WithLayerDescriptors(func(l []ocispec.Descriptor) {
- layers = l
- }))
- if err != nil {
- return nil, err
+ if operation.withProv && !operation.ignoreMissingProv {
+ minNumDescriptors++
}
- descriptors = append(descriptors, manifest)
- descriptors = append(descriptors, layers...)
-
- numDescriptors := len(descriptors)
+ numDescriptors := len(genericResult.Descriptors)
if numDescriptors < minNumDescriptors {
return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d",
minNumDescriptors, numDescriptors)
}
+
+ // Find chart-specific descriptors
var configDescriptor *ocispec.Descriptor
var chartDescriptor *ocispec.Descriptor
var provDescriptor *ocispec.Descriptor
- for _, descriptor := range descriptors {
+
+ for _, descriptor := range genericResult.Descriptors {
d := descriptor
switch d.MediaType {
case ConfigMediaType:
@@ -401,6 +459,8 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType)
}
}
+
+ // Chart-specific validation
if configDescriptor == nil {
return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType)
}
@@ -408,6 +468,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
ChartLayerMediaType)
}
+
var provMissing bool
if operation.withProv && provDescriptor == nil {
if operation.ignoreMissingProv {
@@ -417,10 +478,12 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
ProvLayerMediaType)
}
}
+
+ // Build chart-specific result
result := &PullResult{
Manifest: &DescriptorPullSummary{
- Digest: manifest.Digest.String(),
- Size: manifest.Size,
+ Digest: genericResult.Manifest.Digest.String(),
+ Size: genericResult.Manifest.Size,
},
Config: &DescriptorPullSummary{
Digest: configDescriptor.Digest.String(),
@@ -428,56 +491,42 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
},
Chart: &DescriptorPullSummaryWithMeta{},
Prov: &DescriptorPullSummary{},
- Ref: parsedRef.String(),
- }
- var getManifestErr error
- if _, manifestData, ok := memoryStore.Get(manifest); !ok {
- getManifestErr = errors.Errorf("Unable to retrieve blob with digest %s", manifest.Digest)
- } else {
- result.Manifest.Data = manifestData
+ Ref: genericResult.Ref,
}
- if getManifestErr != nil {
- return nil, getManifestErr
+
+ // Fetch data using generic client
+ genericClient := c.Generic()
+
+ result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err)
}
- var getConfigDescriptorErr error
- if _, configData, ok := memoryStore.Get(*configDescriptor); !ok {
- getConfigDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", configDescriptor.Digest)
- } else {
- result.Config.Data = configData
- var meta *chart.Metadata
- if err := json.Unmarshal(configData, &meta); err != nil {
- return nil, err
- }
- result.Chart.Meta = meta
+
+ result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err)
}
- if getConfigDescriptorErr != nil {
- return nil, getConfigDescriptorErr
+
+ if err := json.Unmarshal(result.Config.Data, &result.Chart.Meta); err != nil {
+ return nil, err
}
+
if operation.withChart {
- var getChartDescriptorErr error
- if _, chartData, ok := memoryStore.Get(*chartDescriptor); !ok {
- getChartDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", chartDescriptor.Digest)
- } else {
- result.Chart.Data = chartData
- result.Chart.Digest = chartDescriptor.Digest.String()
- result.Chart.Size = chartDescriptor.Size
- }
- if getChartDescriptorErr != nil {
- return nil, getChartDescriptorErr
+ result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err)
}
+ result.Chart.Digest = chartDescriptor.Digest.String()
+ result.Chart.Size = chartDescriptor.Size
}
+
if operation.withProv && !provMissing {
- var getProvDescriptorErr error
- if _, provData, ok := memoryStore.Get(*provDescriptor); !ok {
- getProvDescriptorErr = errors.Errorf("Unable to retrieve blob with digest %s", provDescriptor.Digest)
- } else {
- result.Prov.Data = provData
- result.Prov.Digest = provDescriptor.Digest.String()
- result.Prov.Size = provDescriptor.Size
- }
- if getProvDescriptorErr != nil {
- return nil, getProvDescriptorErr
+ result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err)
}
+ result.Prov.Digest = provDescriptor.Digest.String()
+ result.Prov.Size = provDescriptor.Size
}
fmt.Fprintf(c.out, "Pulled: %s\n", result.Ref)
@@ -491,6 +540,44 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
return result, nil
}
+// Pull downloads a chart from a registry
+func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
+ operation := &pullOperation{
+ withChart: true, // By default, always download the chart layer
+ }
+ for _, option := range options {
+ option(operation)
+ }
+ if !operation.withChart && !operation.withProv {
+ return nil, errors.New(
+ "must specify at least one layer to pull (chart/prov)")
+ }
+
+ // Build allowed media types for chart pull
+ allowedMediaTypes := []string{
+ ocispec.MediaTypeImageManifest,
+ ConfigMediaType,
+ }
+ if operation.withChart {
+ allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
+ }
+ if operation.withProv {
+ allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
+ }
+
+ // Use generic client for the pull operation
+ genericClient := c.Generic()
+ genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
+ AllowedMediaTypes: allowedMediaTypes,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Process the result with chart-specific logic
+ return c.processChartPull(genericResult, operation)
+}
+
// PullOptWithChart returns a function that sets the withChart setting on pull
func PullOptWithChart(withChart bool) PullOption {
return func(operation *pullOperation) {
@@ -536,15 +623,15 @@ type (
}
pushOperation struct {
- provData []byte
- strictMode bool
- test bool
+ provData []byte
+ strictMode bool
+ creationTime string
}
)
// Push uploads a chart to a registry.
func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResult, error) {
- parsedRef, err := parseReference(ref)
+ parsedRef, err := newReference(ref)
if err != nil {
return nil, err
}
@@ -565,8 +652,11 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
"strict mode enabled, ref basename and tag must match the chart name and version")
}
}
- memoryStore := content.NewMemory()
- chartDescriptor, err := memoryStore.Add("", ChartLayerMediaType, data)
+
+ ctx := context.Background()
+
+ memoryStore := memory.New()
+ chartDescriptor, err := oras.PushBytes(ctx, memoryStore, ChartLayerMediaType, data)
if err != nil {
return nil, err
}
@@ -576,42 +666,47 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
return nil, err
}
- configDescriptor, err := memoryStore.Add("", ConfigMediaType, configData)
+ configDescriptor, err := oras.PushBytes(ctx, memoryStore, ConfigMediaType, configData)
if err != nil {
return nil, err
}
- descriptors := []ocispec.Descriptor{chartDescriptor}
+ layers := []ocispec.Descriptor{chartDescriptor}
var provDescriptor ocispec.Descriptor
if operation.provData != nil {
- provDescriptor, err = memoryStore.Add("", ProvLayerMediaType, operation.provData)
+ provDescriptor, err = oras.PushBytes(ctx, memoryStore, ProvLayerMediaType, operation.provData)
if err != nil {
return nil, err
}
- descriptors = append(descriptors, provDescriptor)
+ layers = append(layers, provDescriptor)
}
- ociAnnotations := generateOCIAnnotations(meta, operation.test)
+ // sort layers for determinism, similar to how ORAS v1 does it
+ sort.Slice(layers, func(i, j int) bool {
+ return layers[i].Digest < layers[j].Digest
+ })
+
+ ociAnnotations := generateOCIAnnotations(meta, operation.creationTime)
- manifestData, manifest, err := content.GenerateManifest(&configDescriptor, ociAnnotations, descriptors...)
+ manifestDescriptor, err := c.tagManifest(ctx, memoryStore, configDescriptor,
+ layers, ociAnnotations, parsedRef)
if err != nil {
return nil, err
}
- if err := memoryStore.StoreManifest(parsedRef.String(), manifest, manifestData); err != nil {
- return nil, err
- }
- remotesResolver, err := c.resolver(parsedRef)
+ repository, err := remote.NewRepository(parsedRef.String())
if err != nil {
return nil, err
}
- registryStore := content.Registry{Resolver: remotesResolver}
- _, err = oras.Copy(ctx(c.out, c.debug), memoryStore, parsedRef.String(), registryStore, "",
- oras.WithNameValidation(nil))
+ repository.PlainHTTP = c.plainHTTP
+ repository.Client = c.authorizer
+
+ manifestDescriptor, err = oras.ExtendedCopy(ctx, memoryStore, parsedRef.String(), repository, parsedRef.String(), oras.DefaultExtendedCopyOptions)
if err != nil {
return nil, err
}
+
chartSummary := &descriptorPushSummaryWithMeta{
Meta: meta,
}
@@ -619,8 +714,8 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
chartSummary.Size = chartDescriptor.Size
result := &PushResult{
Manifest: &descriptorPushSummary{
- Digest: manifest.Digest.String(),
- Size: manifest.Size,
+ Digest: manifestDescriptor.Digest.String(),
+ Size: manifestDescriptor.Size,
},
Config: &descriptorPushSummary{
Digest: configDescriptor.Digest.String(),
@@ -638,7 +733,7 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
}
fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref)
fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
- if strings.Contains(parsedRef.Reference, "_") {
+ if strings.Contains(parsedRef.orasReference.Reference, "_") {
fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
}
@@ -660,10 +755,10 @@ func PushOptStrictMode(strictMode bool) PushOption {
}
}
-// PushOptTest returns a function that sets whether test setting on push
-func PushOptTest(test bool) PushOption {
+// PushOptCreationTime returns a function that sets the creation time
+func PushOptCreationTime(creationTime string) PushOption {
return func(operation *pushOperation) {
- operation.test = test
+ operation.creationTime = creationTime
}
}
@@ -674,27 +769,29 @@ func (c *Client) Tags(ref string) ([]string, error) {
return nil, err
}
- repository := registryremote.Repository{
- Reference: parsedReference,
- Client: c.registryAuthorizer,
- PlainHTTP: c.plainHTTP,
- }
-
- var registryTags []string
-
- registryTags, err = registry.Tags(ctx(c.out, c.debug), &repository)
+ ctx := context.Background()
+ repository, err := remote.NewRepository(parsedReference.String())
if err != nil {
return nil, err
}
+ repository.PlainHTTP = c.plainHTTP
+ repository.Client = c.authorizer
var tagVersions []*semver.Version
- for _, tag := range registryTags {
- // Change underscore (_) back to plus (+) for Helm
- // See https://github.com/helm/helm/issues/10166
- tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+"))
- if err == nil {
- tagVersions = append(tagVersions, tagVersion)
+ err = repository.Tags(ctx, "", func(tags []string) error {
+ for _, tag := range tags {
+ // Change underscore (_) back to plus (+) for Helm
+ // See https://github.com/helm/helm/issues/10166
+ tagVersion, err := semver.StrictNewVersion(strings.ReplaceAll(tag, "_", "+"))
+ if err == nil {
+ tagVersions = append(tagVersions, tagVersion)
+ }
}
+
+ return nil
+ })
+ if err != nil {
+ return nil, err
}
// Sort the collection
@@ -709,3 +806,111 @@ func (c *Client) Tags(ref string) ([]string, error) {
return tags, nil
}
+
+// Resolve a reference to a descriptor.
+func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) {
+ remoteRepository, err := remote.NewRepository(ref)
+ if err != nil {
+ return desc, err
+ }
+ remoteRepository.PlainHTTP = c.plainHTTP
+ remoteRepository.Client = c.authorizer
+
+ parsedReference, err := newReference(ref)
+ if err != nil {
+ return desc, err
+ }
+
+ ctx := context.Background()
+ parsedString := parsedReference.String()
+ return remoteRepository.Resolve(ctx, parsedString)
+}
+
+// ValidateReference for path and version
+func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *url.URL, error) {
+ var tag string
+
+ registryReference, err := newReference(u.Host + u.Path)
+ if err != nil {
+ return "", nil, err
+ }
+
+ if version == "" {
+ // Use OCI URI tag as default
+ version = registryReference.Tag
+ } else {
+ if registryReference.Tag != "" && registryReference.Tag != version {
+ return "", nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag)
+ }
+ }
+
+ if registryReference.Digest != "" {
+ if version == "" {
+ // Install by digest only
+ return "", u, nil
+ }
+ u.Path = fmt.Sprintf("%s@%s", registryReference.Repository, registryReference.Digest)
+
+ // Validate the tag if it was specified
+ path := registryReference.Registry + "/" + registryReference.Repository + ":" + version
+ desc, err := c.Resolve(path)
+ if err != nil {
+ // The resource does not have to be tagged when digest is specified
+ return "", u, nil
+ }
+ if desc.Digest.String() != registryReference.Digest {
+ return "", nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest)
+ }
+ return registryReference.Digest, u, nil
+ }
+
+ // Evaluate whether an explicit version has been provided. Otherwise, determine version to use
+ _, errSemVer := semver.NewVersion(version)
+ if errSemVer == nil {
+ tag = version
+ } else {
+ // Retrieve list of repository tags
+ tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme)))
+ if err != nil {
+ return "", nil, err
+ }
+ if len(tags) == 0 {
+ return "", nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref)
+ }
+
+ // Determine if version provided
+ // If empty, try to get the highest available tag
+ // If exact version, try to find it
+ // If semver constraint string, try to find a match
+ tag, err = GetTagMatchingVersionOrConstraint(tags, version)
+ if err != nil {
+ return "", nil, err
+ }
+ }
+
+ u.Path = fmt.Sprintf("%s:%s", registryReference.Repository, tag)
+ // desc, err := c.Resolve(u.Path)
+
+ return "", u, err
+}
+
+// tagManifest prepares and tags a manifest in memory storage
+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,
+ Layers: layers,
+ Annotations: ociAnnotations,
+ }
+
+ manifestData, err := json.Marshal(manifest)
+ if err != nil {
+ return ocispec.Descriptor{}, err
+ }
+
+ return oras.TagBytes(ctx, memoryStore, ocispec.MediaTypeImageManifest,
+ manifestData, parsedRef.String())
+}
diff --git a/pkg/registry/client_http_test.go b/pkg/registry/client_http_test.go
index 872d19fc9..043fd4205 100644
--- a/pkg/registry/client_http_test.go
+++ b/pkg/registry/client_http_test.go
@@ -17,12 +17,13 @@ limitations under the License.
package registry
import (
+ "errors"
"fmt"
"os"
"testing"
- "github.com/containerd/containerd/errdefs"
"github.com/stretchr/testify/suite"
+ "oras.land/oras-go/v2/content"
)
type HTTPRegistryClientTestSuite struct {
@@ -42,6 +43,18 @@ func (suite *HTTPRegistryClientTestSuite) TearDownSuite() {
os.RemoveAll(suite.WorkspaceDir)
}
+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")
+
+ err = suite.RegistryClient.Login(suite.DockerRegistryHost,
+ LoginOptBasicAuth(testUsername, testPassword),
+ LoginOptPlainText(true))
+ suite.Nil(err, "no error logging into registry with good credentials")
+}
+
func (suite *HTTPRegistryClientTestSuite) Test_1_Push() {
testPush(&suite.TestSuite)
}
@@ -60,7 +73,7 @@ func (suite *HTTPRegistryClientTestSuite) Test_4_ManInTheMiddle() {
// returns content that does not match the expected digest
_, err := suite.RegistryClient.Pull(ref)
suite.NotNil(err)
- suite.True(errdefs.IsFailedPrecondition(err))
+ suite.True(errors.Is(err, content.ErrMismatchedDigest))
}
func TestHTTPRegistryClientTestSuite(t *testing.T) {
diff --git a/pkg/registry/client_insecure_tls_test.go b/pkg/registry/client_insecure_tls_test.go
index 5ba79b2ea..accbf1670 100644
--- a/pkg/registry/client_insecure_tls_test.go
+++ b/pkg/registry/client_insecure_tls_test.go
@@ -66,7 +66,10 @@ func (suite *InsecureTLSRegistryClientTestSuite) Test_3_Tags() {
func (suite *InsecureTLSRegistryClientTestSuite) Test_4_Logout() {
err := suite.RegistryClient.Logout("this-host-aint-real:5000")
- suite.NotNil(err, "error logging out of registry that has no entry")
+ if err != nil {
+ // credential backend for mac generates an error
+ suite.NotNil(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")
diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go
new file mode 100644
index 000000000..2ffd691c2
--- /dev/null
+++ b/pkg/registry/client_test.go
@@ -0,0 +1,53 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "io"
+ "testing"
+
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/require"
+ "oras.land/oras-go/v2/content/memory"
+)
+
+// Inspired by oras test
+// https://github.com/oras-project/oras-go/blob/05a2b09cbf2eab1df691411884dc4df741ec56ab/content_test.go#L1802
+func TestTagManifestTransformsReferences(t *testing.T) {
+ memStore := memory.New()
+ client := &Client{out: io.Discard}
+ ctx := t.Context()
+
+ refWithPlus := "test-registry.io/charts/test:1.0.0+metadata"
+ expectedRef := "test-registry.io/charts/test:1.0.0_metadata" // + becomes _
+
+ configDesc := ocispec.Descriptor{MediaType: ConfigMediaType, Digest: "sha256:config", Size: 100}
+ layers := []ocispec.Descriptor{{MediaType: ChartLayerMediaType, Digest: "sha256:layer", Size: 200}}
+
+ parsedRef, err := newReference(refWithPlus)
+ require.NoError(t, err)
+
+ desc, err := client.tagManifest(ctx, memStore, configDesc, layers, nil, parsedRef)
+ require.NoError(t, err)
+
+ transformedDesc, err := memStore.Resolve(ctx, expectedRef)
+ require.NoError(t, err, "Should find the reference with _ instead of +")
+ require.Equal(t, desc.Digest, transformedDesc.Digest)
+
+ _, err = memStore.Resolve(ctx, refWithPlus)
+ require.Error(t, err, "Should NOT find the reference with the original +")
+}
diff --git a/pkg/registry/client_tls_test.go b/pkg/registry/client_tls_test.go
index 518cfced4..0897858b5 100644
--- a/pkg/registry/client_tls_test.go
+++ b/pkg/registry/client_tls_test.go
@@ -17,6 +17,8 @@ limitations under the License.
package registry
import (
+ "crypto/tls"
+ "crypto/x509"
"os"
"testing"
@@ -52,6 +54,30 @@ func (suite *TLSRegistryClientTestSuite) Test_0_Login() {
suite.Nil(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")
+
+ // Create a *tls.Config from tlsCert, tlsKey, and tlsCA.
+ cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
+ suite.Nil(err, "error loading x509 key pair")
+ rootCAs := x509.NewCertPool()
+ caCert, err := os.ReadFile(tlsCA)
+ suite.Nil(err, "error reading CA certificate")
+ rootCAs.AppendCertsFromPEM(caCert)
+ conf := &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ RootCAs: rootCAs,
+ }
+
+ err = suite.RegistryClient.Login(suite.DockerRegistryHost,
+ LoginOptBasicAuth(testUsername, testPassword),
+ LoginOptTLSClientConfigFromConfig(conf))
+ suite.Nil(err, "no error logging into registry with good credentials")
+}
+
func (suite *TLSRegistryClientTestSuite) Test_1_Push() {
testPush(&suite.TestSuite)
}
@@ -66,7 +92,10 @@ func (suite *TLSRegistryClientTestSuite) Test_3_Tags() {
func (suite *TLSRegistryClientTestSuite) Test_4_Logout() {
err := suite.RegistryClient.Logout("this-host-aint-real:5000")
- suite.NotNil(err, "error logging out of registry that has no entry")
+ if err != nil {
+ // credential backend for mac generates an error
+ suite.NotNil(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")
diff --git a/pkg/registry/constants.go b/pkg/registry/constants.go
index 570b6f0d3..c455cf314 100644
--- a/pkg/registry/constants.go
+++ b/pkg/registry/constants.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package registry // import "helm.sh/helm/v3/pkg/registry"
+package registry // import "helm.sh/helm/v4/pkg/registry"
const (
// OCIScheme is the URL scheme for OCI-based requests
diff --git a/pkg/registry/generic.go b/pkg/registry/generic.go
new file mode 100644
index 000000000..b82132338
--- /dev/null
+++ b/pkg/registry/generic.go
@@ -0,0 +1,162 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "sort"
+ "sync"
+
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+ "oras.land/oras-go/v2"
+ "oras.land/oras-go/v2/content"
+ "oras.land/oras-go/v2/content/memory"
+ "oras.land/oras-go/v2/registry/remote"
+ "oras.land/oras-go/v2/registry/remote/auth"
+ "oras.land/oras-go/v2/registry/remote/credentials"
+)
+
+// GenericClient provides low-level OCI operations without artifact-specific assumptions
+type GenericClient struct {
+ debug bool
+ enableCache bool
+ credentialsFile string
+ username string
+ password string
+ out io.Writer
+ authorizer *auth.Client
+ registryAuthorizer RemoteClient
+ credentialsStore credentials.Store
+ httpClient *http.Client
+ plainHTTP bool
+}
+
+// GenericPullOptions configures a generic pull operation
+type GenericPullOptions struct {
+ // MediaTypes to include in the pull (empty means all)
+ AllowedMediaTypes []string
+ // Skip descriptors with these media types
+ SkipMediaTypes []string
+ // Custom PreCopy function for filtering
+ PreCopy func(context.Context, ocispec.Descriptor) error
+}
+
+// GenericPullResult contains the result of a generic pull operation
+type GenericPullResult struct {
+ Manifest ocispec.Descriptor
+ Descriptors []ocispec.Descriptor
+ MemoryStore *memory.Store
+ Ref string
+}
+
+// NewGenericClient creates a new generic OCI client from an existing Client
+func NewGenericClient(client *Client) *GenericClient {
+ return &GenericClient{
+ debug: client.debug,
+ enableCache: client.enableCache,
+ credentialsFile: client.credentialsFile,
+ username: client.username,
+ password: client.password,
+ out: client.out,
+ authorizer: client.authorizer,
+ registryAuthorizer: client.registryAuthorizer,
+ credentialsStore: client.credentialsStore,
+ httpClient: client.httpClient,
+ plainHTTP: client.plainHTTP,
+ }
+}
+
+// PullGeneric performs a generic OCI pull without artifact-specific assumptions
+func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) {
+ parsedRef, err := newReference(ref)
+ if err != nil {
+ return nil, err
+ }
+
+ memoryStore := memory.New()
+ var descriptors []ocispec.Descriptor
+
+ // Set up repository with authentication and configuration
+ repository, err := remote.NewRepository(parsedRef.String())
+ if err != nil {
+ return nil, err
+ }
+ repository.PlainHTTP = c.plainHTTP
+ repository.Client = c.authorizer
+
+ ctx := context.Background()
+
+ // Prepare allowed media types for filtering
+ var allowedMediaTypes []string
+ if len(options.AllowedMediaTypes) > 0 {
+ allowedMediaTypes = make([]string, len(options.AllowedMediaTypes))
+ copy(allowedMediaTypes, options.AllowedMediaTypes)
+ sort.Strings(allowedMediaTypes)
+ }
+
+ var mu sync.Mutex
+ manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
+ CopyGraphOptions: oras.CopyGraphOptions{
+ PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error {
+ // Apply custom PreCopy function if provided
+ if options.PreCopy != nil {
+ if err := options.PreCopy(ctx, desc); err != nil {
+ return err
+ }
+ }
+
+ mediaType := desc.MediaType
+
+ // Skip media types if specified
+ for _, skipType := range options.SkipMediaTypes {
+ if mediaType == skipType {
+ return oras.SkipNode
+ }
+ }
+
+ // Filter by allowed media types if specified
+ if len(allowedMediaTypes) > 0 {
+ if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
+ return oras.SkipNode
+ }
+ }
+
+ mu.Lock()
+ descriptors = append(descriptors, desc)
+ mu.Unlock()
+ return nil
+ },
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &GenericPullResult{
+ Manifest: manifest,
+ Descriptors: descriptors,
+ MemoryStore: memoryStore,
+ Ref: parsedRef.String(),
+ }, nil
+}
+
+// GetDescriptorData retrieves the data for a specific descriptor
+func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) {
+ return content.FetchAll(context.Background(), store, desc)
+}
diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go
new file mode 100644
index 000000000..5d22a99ee
--- /dev/null
+++ b/pkg/registry/plugin.go
@@ -0,0 +1,201 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// Plugin-specific constants
+const (
+ // PluginArtifactType is the artifact type for Helm plugins
+ PluginArtifactType = "application/vnd.helm.plugin.v1+json"
+)
+
+// PluginPullOptions configures a plugin pull operation
+type PluginPullOptions struct {
+ // PluginName specifies the expected plugin name for layer validation
+ PluginName string
+}
+
+// PluginPullResult contains the result of a plugin pull operation
+type PluginPullResult struct {
+ Manifest ocispec.Descriptor
+ PluginData []byte
+ ProvenanceData []byte // Optional provenance data
+ Ref string
+ PluginName string
+}
+
+// PullPlugin downloads a plugin from an OCI registry using artifact type
+func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) {
+ operation := &pluginPullOperation{
+ pluginName: pluginName,
+ }
+ for _, option := range options {
+ option(operation)
+ }
+
+ // Use generic client for the pull operation with artifact type filtering
+ genericClient := c.Generic()
+ genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
+ // Allow manifests and all layer types - we'll validate artifact type after download
+ AllowedMediaTypes: []string{
+ ocispec.MediaTypeImageManifest,
+ "application/vnd.oci.image.layer.v1.tar",
+ "application/vnd.oci.image.layer.v1.tar+gzip",
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Process the result with plugin-specific logic
+ return c.processPluginPull(genericResult, operation.pluginName)
+}
+
+// processPluginPull handles plugin-specific processing of a generic pull result using artifact type
+func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) {
+ // First validate that this is actually a plugin artifact
+ manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve manifest: %w", err)
+ }
+
+ // Parse the manifest to check artifact type
+ var manifest ocispec.Manifest
+ if err := json.Unmarshal(manifestData, &manifest); err != nil {
+ return nil, fmt.Errorf("unable to parse manifest: %w", err)
+ }
+
+ // Validate artifact type (for OCI v1.1+ manifests)
+ if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType {
+ return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType)
+ }
+
+ // For backwards compatibility, also check config media type if no artifact type
+ if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType {
+ return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType)
+ }
+
+ // Find the required plugin tarball and optional provenance
+ expectedTarball := pluginName + ".tgz"
+ expectedProvenance := pluginName + ".tgz.prov"
+
+ var pluginDescriptor *ocispec.Descriptor
+ var provenanceDescriptor *ocispec.Descriptor
+
+ // Look for layers with the expected titles/annotations
+ for _, layer := range manifest.Layers {
+ d := layer
+ // Check for title annotation (preferred method)
+ if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists {
+ switch title {
+ case expectedTarball:
+ pluginDescriptor = &d
+ case expectedProvenance:
+ provenanceDescriptor = &d
+ }
+ }
+ }
+
+ // Plugin tarball is required
+ if pluginDescriptor == nil {
+ return nil, fmt.Errorf("required layer %s not found in manifest", expectedTarball)
+ }
+
+ // Build plugin-specific result
+ result := &PluginPullResult{
+ Manifest: genericResult.Manifest,
+ Ref: genericResult.Ref,
+ PluginName: pluginName,
+ }
+
+ // Fetch plugin data using generic client
+ genericClient := c.Generic()
+ result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err)
+ }
+
+ // Fetch provenance data if available
+ if provenanceDescriptor != nil {
+ result.ProvenanceData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor)
+ if err != nil {
+ return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err)
+ }
+ }
+
+ fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref)
+ fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
+ if result.ProvenanceData != nil {
+ fmt.Fprintf(c.out, "Provenance: %s\n", expectedProvenance)
+ }
+
+ if strings.Contains(result.Ref, "_") {
+ fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
+ fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
+ }
+
+ return result, nil
+}
+
+// Plugin pull operation types and options
+type (
+ pluginPullOperation struct {
+ pluginName string
+ }
+
+ // PluginPullOption allows customizing plugin pull operations
+ PluginPullOption func(*pluginPullOperation)
+)
+
+// PluginPullOptWithPluginName sets the plugin name for validation
+func PluginPullOptWithPluginName(name string) PluginPullOption {
+ return func(operation *pluginPullOperation) {
+ operation.pluginName = name
+ }
+}
+
+// GetPluginName extracts the plugin name from an OCI reference using proper reference parsing
+func GetPluginName(source string) (string, error) {
+ ref, err := newReference(source)
+ if err != nil {
+ return "", fmt.Errorf("invalid OCI reference: %w", err)
+ }
+
+ // Extract plugin name from the repository path
+ // 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")
+ }
+
+ // Get the last part of the repository path as the plugin name
+ parts := strings.Split(repository, "/")
+ pluginName := parts[len(parts)-1]
+
+ if pluginName == "" {
+ return "", fmt.Errorf("invalid OCI reference: cannot determine plugin name from repository %s", repository)
+ }
+
+ return pluginName, nil
+}
diff --git a/pkg/registry/plugin_test.go b/pkg/registry/plugin_test.go
new file mode 100644
index 000000000..f8525829c
--- /dev/null
+++ b/pkg/registry/plugin_test.go
@@ -0,0 +1,93 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "testing"
+)
+
+func TestGetPluginName(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ expected string
+ expectErr bool
+ }{
+ {
+ name: "valid OCI reference with tag",
+ source: "oci://ghcr.io/user/plugin-name:v1.0.0",
+ expected: "plugin-name",
+ },
+ {
+ name: "valid OCI reference with digest",
+ source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
+ expected: "plugin-name",
+ },
+ {
+ name: "valid OCI reference without tag",
+ source: "oci://ghcr.io/user/plugin-name",
+ expected: "plugin-name",
+ },
+ {
+ name: "valid OCI reference with multiple path segments",
+ source: "oci://registry.example.com/org/team/plugin-name:latest",
+ expected: "plugin-name",
+ },
+ {
+ name: "valid OCI reference with plus signs in tag",
+ source: "oci://registry.example.com/user/plugin-name:v1.0.0+build.1",
+ expected: "plugin-name",
+ },
+ {
+ name: "valid OCI reference - single path segment",
+ source: "oci://registry.example.com/plugin",
+ expected: "plugin",
+ },
+ {
+ name: "invalid OCI reference - no repository",
+ source: "oci://registry.example.com",
+ expectErr: true,
+ },
+ {
+ name: "invalid OCI reference - malformed",
+ source: "not-an-oci-reference",
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ pluginName, err := GetPluginName(tt.source)
+
+ if tt.expectErr {
+ if err == nil {
+ t.Errorf("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)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/reference.go b/pkg/registry/reference.go
new file mode 100644
index 000000000..b5677761d
--- /dev/null
+++ b/pkg/registry/reference.go
@@ -0,0 +1,78 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "strings"
+
+ "oras.land/oras-go/v2/registry"
+)
+
+type reference struct {
+ orasReference registry.Reference
+ Registry string
+ Repository string
+ Tag string
+ Digest string
+}
+
+// newReference will parse and validate the reference, and clean tags when
+// applicable tags are only cleaned when plus (+) signs are present, and are
+// converted to underscores (_) before pushing
+// See https://github.com/helm/helm/issues/10166
+func newReference(raw string) (result reference, err error) {
+ // Remove oci:// prefix if it is there
+ raw = strings.TrimPrefix(raw, OCIScheme+"://")
+
+ // The sole possible reference modification is replacing plus (+) signs
+ // present in tags with underscores (_). To do this properly, we first
+ // need to identify a tag, and then pass it on to the reference parser
+ // NOTE: Passing immediately to the reference parser will fail since (+)
+ // signs are an invalid tag character, and simply replacing all plus (+)
+ // occurrences could invalidate other portions of the URI
+ lastIndex := strings.LastIndex(raw, "@")
+ if lastIndex >= 0 {
+ result.Digest = raw[(lastIndex + 1):]
+ raw = raw[:lastIndex]
+ }
+ parts := strings.Split(raw, ":")
+ if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") {
+ tag := parts[len(parts)-1]
+
+ if tag != "" {
+ // Replace any plus (+) signs with known underscore (_) conversion
+ newTag := strings.ReplaceAll(tag, "+", "_")
+ raw = strings.ReplaceAll(raw, tag, newTag)
+ }
+ }
+
+ result.orasReference, err = registry.ParseReference(raw)
+ if err != nil {
+ return result, err
+ }
+ result.Registry = result.orasReference.Registry
+ result.Repository = result.orasReference.Repository
+ result.Tag = result.orasReference.Reference
+ return result, nil
+}
+
+func (r *reference) String() string {
+ if r.Tag == "" {
+ return r.orasReference.String() + "@" + r.Digest
+ }
+ return r.orasReference.String()
+}
diff --git a/pkg/registry/reference_test.go b/pkg/registry/reference_test.go
new file mode 100644
index 000000000..b6872cc37
--- /dev/null
+++ b/pkg/registry/reference_test.go
@@ -0,0 +1,100 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import "testing"
+
+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)
+ }
+ expectedString := registry
+ if repository != "" {
+ expectedString = expectedString + "/" + repository
+ }
+ if tag != "" {
+ expectedString = expectedString + ":" + tag
+ } else {
+ expectedString = expectedString + "@" + digest
+ }
+ if actual.String() != expectedString {
+ t.Errorf("String expected %s actual %s", expectedString, actual.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)
+ }
+ 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)
+ }
+ 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)
+ }
+ verify(t, actual, "a", "b", "1", "c")
+
+ actual, err = newReference("a/b:@")
+ if err != nil {
+ t.Errorf("Unexpected error %v", 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)
+ }
+ 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)
+ }
+ verify(t, actual, "", "", "", "")
+
+ actual, err = newReference("registry.example.com/the/repository@sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888")
+ if err != nil {
+ t.Errorf("Unexpected error %v", err)
+ }
+ verify(t, actual, "registry.example.com", "the/repository", "", "sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888")
+}
diff --git a/pkg/registry/testdata/tls/ca.crt b/pkg/registry/testdata/tls/ca.crt
index d5b845acb..8c46ff81e 100644
--- a/pkg/registry/testdata/tls/ca.crt
+++ b/pkg/registry/testdata/tls/ca.crt
@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
-MIIDhzCCAm+gAwIBAgIUEtjKXd8LxpkQf3C5LgdzM1++R3swDQYJKoZIhvcNAQEL
+MIIDiTCCAnGgAwIBAgIUbTTp/VG6blpKnXwWpSVtw54jxzswDQYJKoZIhvcNAQEL
BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG
-A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw
-ODEwNDkzOFoXDTI0MDYwNzEwNDkzOFowUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgM
-AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwM
-QWNtZSBSb290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApgrX
-Lv3k3trxje2JEoqusYN67Z3byZg69djRatfdboS3JKoTIHtcY7MMLdfhjAK97/wv
-BaIMuVNgueu4qH6bea7FCP8XWz2BYBrH2GcKjVrBMkUrlIzjG9gnohkeknJQvQvl
-oVbqLgZJn0HQcZtsPDnLwfjWDZrNkFBtvPSIMaRQbmtOFdSqAQjLKezbwlznBCJ5
-qpLsgc67ttDW5QAS+GszWPmypUlw8Ih7m8J95eT9aUESP0DbdraeUktWJQTdqukd
-NflLaA2ZoV+uTX+wVE4yyXgSjD3Sd93+XhoSSzDzkzRnLsocRutxrTiNC/1S+qhb
-Z72XLk0bvNwQhJjHDQIDAQABo1MwUTAdBgNVHQ4EFgQUoSKAVvuJDGszE361K7IF
-RXOVj2YwHwYDVR0jBBgwFoAUoSKAVvuJDGszE361K7IFRXOVj2YwDwYDVR0TAQH/
-BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAOqH/JFuT1sqY/zVxCsATE1ze85/o
-r6yPw3AuXsFzWtHe/XOFJzvbfOBWfocVLXTDc5933f1Ws/+PcxQKEQCwnUHrEAso
-jLPzy+igHc07pi9PqHJ21Sn8FF5JVv+Y6CcZKaF5aEzUISsVjbF2vGK8FotMS9rs
-Jw//dDfKhHjO9MHPBdkhOrM31LV6gwYPepno/YYygrJwHGQ5V9sdY8ifRBG6lX2a
-xK4N2bl5q3Cpz+iERLNGP2c8OVQwLfSYLpFRSbHS8UiN4z6WqfgYHG7YurvbiMiJ
-/AFkUatVJQ5YLmfCz4FMAiaxNtEOkZh5cvL1eCLK7nzvgAPCI33mEp6eoA==
+A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMCAXDTI0MDQy
+MTA5NDUxOFoYDzMzOTMwNDA0MDk0NTE4WjBTMQswCQYDVQQGEwJDTjELMAkGA1UE
+CAwCR0QxCzAJBgNVBAcMAlNaMRMwEQYDVQQKDApBY21lLCBJbmMuMRUwEwYDVQQD
+DAxBY21lIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq
+OrCgXpMjeSjWJYanmSG/2K4zk0HXeU3eMt5bkshlqHnEwJFD5tMZkJZUsGPiJr9A
+vAqYu2V9/gMKUptvHgxmMkh9BZYCnXAGzhl+OogYcJA5l/YBuDvmgz8M3aRZr7xd
+IA9KtepnDlp7NRWXsgRHzJNMBkV4PpEVHbJTVdjHVYERCw0C1kcb6wjzshnmUmJJ
+JVEQDRCCaYymtIymR6kKrZzIw2FeyXxcccbvTsKILItEECYmRNevo1mc5/f8BEXx
+IzEPhDpoKSTq5JjWHCQH1shkwWyg2neL7g0UJ8nyV0pqqScE0L1WUZ1BHnVJAmGm
+R61WXxA3xCFzJHSc2enRAgMBAAGjUzBRMB0GA1UdDgQWBBREgz+BR+lJFNaG2D7+
+tDVzzyjc4jAfBgNVHSMEGDAWgBREgz+BR+lJFNaG2D7+tDVzzyjc4jAPBgNVHRMB
+Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAL9DjzmNwDljtMWvwAbDC11bIw
+zHON10J/bLcoZy3r7SaD1ZjPigzdpd0oVaoq+Kcg/J0JuIN2fBzyFljft//9knDA
+GgO4TvDdd7dk4gv6C/fbmeh+/HsnjRDHQmExzgth5akSnmtxyk5HQR72FrWICqjf
+oEqg8xs0gVwl8Z0xXLgJ7BZEzRxYlV/G2+vjA1FYIGd3Qfiyg8Qd68Y5bs2/HdBC
+a0EteVUNhS1XVjFFxDZnegPKZs30RwDHcVt9Pj/dLVXu2BgtdYupWtMbtfXNmsg2
+pJcFk7Ve1CAtfrQ2t8DAwOpKHkKIqExupQaGwbdTAtNiQtdGntv4oHuEGJ9p
-----END CERTIFICATE-----
diff --git a/pkg/registry/testdata/tls/ca.key b/pkg/registry/testdata/tls/ca.key
new file mode 100644
index 000000000..f228b4d24
--- /dev/null
+++ b/pkg/registry/testdata/tls/ca.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqOrCgXpMjeSjW
+JYanmSG/2K4zk0HXeU3eMt5bkshlqHnEwJFD5tMZkJZUsGPiJr9AvAqYu2V9/gMK
+UptvHgxmMkh9BZYCnXAGzhl+OogYcJA5l/YBuDvmgz8M3aRZr7xdIA9KtepnDlp7
+NRWXsgRHzJNMBkV4PpEVHbJTVdjHVYERCw0C1kcb6wjzshnmUmJJJVEQDRCCaYym
+tIymR6kKrZzIw2FeyXxcccbvTsKILItEECYmRNevo1mc5/f8BEXxIzEPhDpoKSTq
+5JjWHCQH1shkwWyg2neL7g0UJ8nyV0pqqScE0L1WUZ1BHnVJAmGmR61WXxA3xCFz
+JHSc2enRAgMBAAECggEAJVX2A1Z64x7hzAYzAHNfqZo2qu0zVbUvVPrHNkJ9XX6U
+Jokt0zy/NC44Kp79aU6iR+p2UIVZf0bFF/CCUt6+TXPd3j3pZu1s8rElekAQNXwK
+xfcEZ+AmkypaG9JJB7q5j5tGf1Zi8PN++OLtt3W95pmB/PyrI/JlE8KNqCV+BEnq
+jLheACmehK+G7Rtez128lPvWHAnUTuQQ0wql1z4Z9VB5UwCYD3AxDz34jd8lwZQ1
+RQLUQblN46zpzkBTAX7sTmi9/y0nHJ7rJukTKxDciZ0xPkhtiAKjh6R2wb1TO51Q
+fyGT7iyvtxnqQf+VoNYZGiQ/L7DMppSEHUMm0gkZuQKBgQDoFmLz5J7spQgASjXi
+OLt8lWQOovzNC7K/pjILhD86o58efbZs6NdBrdq8GbeBtowd8HW0nwrxPbk0YN8W
+Fr8kl6hAHYd4UYpMWYNDmB7KIVTAoU/Fk+p5AjXIBwQcYm9H66tDAO/yC8G8EEzu
+iPoBTBQGMss87LH0jsSCDO0oQwKBgQC7xLY58zrU/cdK+ZbKmNA158CibH6ksXHP
+Z4gm+yMW0t7Jdd39L+CfyAEWF9BAagJUuiaxIq3ZiHu7rA6PJ2G8jqRcIHyFgMRk
+sxKTd7F86AI/IEZy7k0l//E4AsXERVgafvRuuSwYsm+ns6cuVYjAYRaHHinZpQao
+Y98SxuxeWwKBgGFE+KX1XHIb3JWahKjSVCmrxuqnfsJFM95Evla7T3C5ILg7wdg1
+Yfoh7jnFoXZY1rK5k+tmeMSQtO1x6C2uzN9+PELa3Wsc6ZSEM5KBz+2xOH8fXHqX
+Or8KoRW7cwqears+12FWpDnSmZjDUCrs97LRetb6NNnM7exsZYmH92FXAoGBAJDZ
+fm4UCfWXVK+s/TuLSUvcXYmvQr9QN+j1CF5x7C7GO6GUcMzJq3H3e4cMldWrMeMk
+u4Z4pz6iADnV0GF00vv/2iFL2mOu41J/pjvm4R/nZxxFjLNKzG8dE3vO/7uadw3x
+lCT6al8e/+2SNM0UpOsrupI/na9NlGZArSyyElPzAoGBAIVv0H798SZjUxpfLT8s
++DI1QFbenNeoEaeXdkYtGrSPUhfZQQ2F744QDsbMm6+4oFkD9yg2A3DvSbd9+WrP
+eDKKA5MAeNiD3X6glEcQOE1x6iTZ0jEXArv1n/qCl1qaUDPDUr8meIlkuwRgwyyW
+vKxiQdtK+ZLUNfU2R5xZwo+X
+-----END PRIVATE KEY-----
diff --git a/pkg/registry/testdata/tls/client.crt b/pkg/registry/testdata/tls/client.crt
index 5b1daf278..f54f46c77 100644
--- a/pkg/registry/testdata/tls/client.crt
+++ b/pkg/registry/testdata/tls/client.crt
@@ -1,20 +1,21 @@
-----BEGIN CERTIFICATE-----
-MIIDWzCCAkOgAwIBAgIUdJ6uRYm6RYesJ3CRoLokemFFgX8wDQYJKoZIhvcNAQEL
-BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG
-A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw
-ODEwNTA0OFoXDTI0MDYwNzEwNTA0OFowWTELMAkGA1UEBhMCQ04xCzAJBgNVBAgM
-AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEbMBkGA1UEAwwS
-aGVsbS10ZXN0LXJlZ2lzdHJ5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
-AQEAxuVrOJyfUO71wlqe/ae8pNVf3z+6b7aCYRrKJ4l66RKMPz9uP5lHD9QImCTU
-LddER48iRr5nzaUKqNUsPn4tTcdaH9EEra+PDp+YeToyZARO+coxCq8yt1NxXrlb
-E/q9Ie9QUlruhthrgr+5DC+qogZA8kcVPOs2+ObqeCCO6QGpECxROO2ysXHyjy2b
-nwGCzZRz90M4z0ifXcey9RLzbmEsYymq6RbaeQvdzevgXhzIANktILuB0D3wJ2ae
-WWP2CfBrjaPbOBtzdDhyl4T1aqLiUpDELUJLVpf/h6xCh52Q0svpsGVGtyO+npPe
-kZ1LSVAnVGS6JlWWhs7RL0eaPwIDAQABoyEwHzAdBgNVHREEFjAUghJoZWxtLXRl
-c3QtcmVnaXN0cnkwDQYJKoZIhvcNAQELBQADggEBABbxtODFOAeTJg4Q3SXqJ8Gq
-zh3/1DaAEnMGHILYuS9tK5lisTLiUerqeQaHKR6U90HK/P1vVxe7PvwfHBrVsGkR
-4YC6nivf8LMySKBQmsPUHjdotNZZ8O1pqd+CMqZe2ZuvzLZ4pPdw25lKjhZ7qI+t
-hQ8yotiJALzEUWLJSgP5Y8k4hFfRGSso1oAC+WppQeW6ITqDo1MrzH7gpjnp+CJG
-NWM1oAQCB1qIdo6gY386w6yLyUhfHtAVa3vviQ0dkRLiK95He5xZcO11rlDNdmgF
-cF6lElkci8gPuH8UkKAT5bP9dAEbHPSjAIvg5O9NviknLiNAdFRKeTri+hqNLhE=
+MIIDijCCAnKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJDTjEL
+MAkGA1UECAwCR0QxCzAJBgNVBAcMAlNaMRMwEQYDVQQKDApBY21lLCBJbmMuMRUw
+EwYDVQQDDAxBY21lIFJvb3QgQ0EwIBcNMjQwNDIxMTA1MzA1WhgPMzM5MzA0MDQx
+MDUzMDVaMFkxCzAJBgNVBAYTAkNOMQswCQYDVQQIDAJHRDELMAkGA1UEBwwCU1ox
+EzARBgNVBAoMCkFjbWUsIEluYy4xGzAZBgNVBAMMEmhlbG0tdGVzdC1yZWdpc3Ry
+eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALK1aOjQvB337gmjkORj
+QQyBDsScyWCnc1gjypcwPvi97+FFlp/jZUWasIa+FXeYWhwWiUI2tUttDNPZATqq
+c2My1uME2Dm0PG9qAUuvW5CEdE7Bw3T2K/8A1myfux/vyMXEjXKHAl+uhTcqDlew
+/yIF2gfO2dKYk+xnZwdE6w8bIQTqnaG0JxtK7Q0ULldsCOFtF+a4C9Zye6ggdieh
+cwVuV41ehbVCK3E7AylTFwbALB6ZQ4z3V6jXrXBNdMKSLyesWAAwROcUB+S68NEa
+5AWSfGXOT2glHzMHe7fJoulTetvJiaKBpxnFInMquBRzxpNO7A6eVmp6FQfpXqof
+wikCAwEAAaNhMF8wHQYDVR0RBBYwFIISaGVsbS10ZXN0LXJlZ2lzdHJ5MB0GA1Ud
+DgQWBBT6yXtjugflf08vGK3ClkHGw/D9HzAfBgNVHSMEGDAWgBREgz+BR+lJFNaG
+2D7+tDVzzyjc4jANBgkqhkiG9w0BAQsFAAOCAQEAoDEJSYcegsEH1/mzAT8CUul5
+MkxF8U1Dtc8m6Nyosolh16AlJ5dmF5d537lqf0VwHDFtQiwexWVohTW9ngpk0C0Z
+Jphf0+9ptpzBQn9x0mcHyKJRD3TbUc80oehY33bHAhPNdV3C1gwCfcbdX8Gz89ZT
+MdLY0BfDELeBKVpaHd2vuK+E06X0a7T5P7vnYmNFpQOMyyytl7vM1TofmU905sNI
+hrHqKH6c2G6QKW+vuiPoX+QbZFZ4NJ+Lco176wnpJjMZx3+Z6t4TV4sCaZgxj3RT
+gDQBRnsD6m03ZoVZvIOlApUs3IEKXsqsrXJpuxfvU89u9z6vOn6TteFsExXiuA==
-----END CERTIFICATE-----
diff --git a/pkg/registry/testdata/tls/client.key b/pkg/registry/testdata/tls/client.key
index 2f6a8aa12..3e7645003 100644
--- a/pkg/registry/testdata/tls/client.key
+++ b/pkg/registry/testdata/tls/client.key
@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
-MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDG5Ws4nJ9Q7vXC
-Wp79p7yk1V/fP7pvtoJhGsoniXrpEow/P24/mUcP1AiYJNQt10RHjyJGvmfNpQqo
-1Sw+fi1Nx1of0QStr48On5h5OjJkBE75yjEKrzK3U3FeuVsT+r0h71BSWu6G2GuC
-v7kML6qiBkDyRxU86zb45up4II7pAakQLFE47bKxcfKPLZufAYLNlHP3QzjPSJ9d
-x7L1EvNuYSxjKarpFtp5C93N6+BeHMgA2S0gu4HQPfAnZp5ZY/YJ8GuNo9s4G3N0
-OHKXhPVqouJSkMQtQktWl/+HrEKHnZDSy+mwZUa3I76ek96RnUtJUCdUZLomVZaG
-ztEvR5o/AgMBAAECggEBAKTaovRZXPOIHMrqsb0sun8lHEG+YJkXfRlfSw9aNDXa
-2cPSn163fN7xr+3rGLKmKkHlsVNRnlgk46Dsj698hbBh+6FDbc1IJhrIzWgthHbB
-23PO0rc4X6Dz2JParlLxELJ/2ONp2yqJVxMYNhiTqaqB5HLr1/6WNwo220CWO92D
-vLz3rBHO5Vw5b5Y6Kt6MN6ciIHB2k+obhh4GQRJjUhvmmKCzbk1/R1PFYNwhhMN0
-Av6BdwFgngvNzJ8KMxGia7WJSvDYUk0++RRZ1esiZqwWRVCFFkm4Hj+gKJq6Xnz0
-a2nSvlC9k4GJvD9yY9VcDTJY+WsNN3Ny29gIFUeU9IECgYEA4norD3XakMthgOQk
-3NE3HSvpZ22xtVgN9uN0b/JXbg7CLlYzn3tabpbQM/4uI6VG3Mk5Pk83QfKnr4W1
-aYO3YTEQ9B4g0eu3t4zfQOibY2+/Jb7Yfv/fH+pjkI26zYDQn61gsFdV9uxF7Pgu
-NGNVe/eY+RkxEWsTtb40jcrbCgsCgYEA4NLWAdlrGKWZP5nLvM1hVB8r4WS82c0e
-Orfyv2NhiqfRasARC1lQCqwbmCjb0c/eQiW7lJ7iSECc/8xW3HrJBYpG/tCxi9+m
-SWxZXzRXDL8bmuoVvYeA/hFZayef5qCc8eiTYGQp6N5ozQHLXuPbNu7n6YSwvoU4
-ANrVBDRXxR0CgYEAmwbfhPS6iVT+yFjjNthrrqdJXQhElgrRfEfUg3DTEj4+A7P0
-IF4y1/KaUIzUjofrSuTfL1zQSW9OA6M2PCTymTAaF9CrzKZbGuTuSaMwAtASe0b5
-MW37EQDD6MZrsZJUvIjU38DY0m6Hqx9zmV7JvFMPPqxU30R5uHWbyderOmMCgYA5
-P3afIe3TaNeNCmyGtwWBli5mRnCQRVrdONnnQjckR3db52xvp15qWUjthfnzgyrl
-TRZm0c5s94cC29WCbwGhF4Tcfee35ktBhwV66KkB5efxmonOqSJ/j4tlbcGZyGwu
-bTqZ4OeLFJc7HKncj8jSRCNpoxAec22/SfnUCEARQQKBgAnwaN6kmGqIW2EsNOwB
-DXCvG4HI9np5xN5Wo2dz7wqGtrt0TVtJ/PNBL3iadDLyPHahwoEVceFrQwqxjPsV
-AoSwVDTdX96PKM/v/2ysw1JLf7UMT59mpxFoYiXCPn5Do4D1/25UfMOsJSmFo1Ij
-Hkw1bqG8QneuME16BnDQfY3b
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCytWjo0Lwd9+4J
+o5DkY0EMgQ7EnMlgp3NYI8qXMD74ve/hRZaf42VFmrCGvhV3mFocFolCNrVLbQzT
+2QE6qnNjMtbjBNg5tDxvagFLr1uQhHROwcN09iv/ANZsn7sf78jFxI1yhwJfroU3
+Kg5XsP8iBdoHztnSmJPsZ2cHROsPGyEE6p2htCcbSu0NFC5XbAjhbRfmuAvWcnuo
+IHYnoXMFbleNXoW1QitxOwMpUxcGwCwemUOM91eo161wTXTCki8nrFgAMETnFAfk
+uvDRGuQFknxlzk9oJR8zB3u3yaLpU3rbyYmigacZxSJzKrgUc8aTTuwOnlZqehUH
+6V6qH8IpAgMBAAECggEAFv5M3oG25pM3GyHiu2QC41k6nXT/2xIIfvtx7lR8kbQc
+iGtT90QCjHtcAaY07GObmngS1oRj/K2uBBbsd9AlEwsgR2rg6EHGsd4dhw+rtBM6
+xMRdAfBHlmKU9Dp0EOag+kMxIN56oXV6ue+NE17YYNgIZs9ISvarN7RRNwf4x4NS
+wpeWBqt120B3p9mGS64vE6wFxpRKSpFcpIp+yUswI45x8mbvCBr4tNW0OQ7y+WwS
+rPp7GayutEUB9etRWviw10D7pz3HrxfarrZJm65IH1Fw5Ye6ayteoWg4IY2s3qSS
+gh4qMZNMPeE6G3UBmkMdUf27+Udt8bSrSoz2Z8OlVQKBgQDcMY6h0BTFJcioBLhV
+qe0FmckVNzs5jtzdwXFSjQduUCZ74ag5hsW3jQ0KNvd1B/xOv/Df6rYJY3ww8cQ1
++KRTzt5B4qZwC1swuzqHWjR/W5XBlX3hRbs+I3imveaQ9zNFpktDZhaG72AWLLpa
+Y31ddrkG4a8rTZFSuOVCbyj7JQKBgQDPxN/2Ayt/x+n/A4LNDSUQiUSALIeBHCCo
+UzNQojcQLyobBVCIu5E3gRqIbvyRde7MQMGhfpLuaW7wmW0hqkUtRDYb4Hy52YMg
+PFkno11wdpoEN3McLJNH08q+2dFjUKzQWygelDvkQMkwiL2syu+rEoUIEOCWyW6V
+mPEPmfcdtQKBgEbqgwhkTrwr7hMG6iNUxex+2f9GOYHRHBsjeQ7gMtt5XtuZEqfs
+WvNBr0hx6YK8nqryMG69VgFyFAZjZxEG0k3Xm0dW6sm9LpJkSnZbO/skkPe24MLT
+xXk+zVXOZVqc8ttksmqzj1/H6odZwm7oCfE3EmI//z2QDtS4jcW2rVktAoGABfdn
+Xw80PpUlGRemt/C6scDfYLbmpUSDg5HwFU6zOhnAocoDSAnq36crdeOKCTtTwjXR
+2ati2MnaT7p4MdFL70LYMvC9ZDDk3RYekU7VrhcZ0Skuew6kpBlm5xgmNS3p6InV
+mxsypRlfLa+fksi5HTaI73RcnrfmHxGnSoVnXUkCgYAHggM+T7e11OB+aEQ0nFcL
+nS58M7QgB3/Xd7jGrl9Fi5qogtHE80epiV/srWaACZV6ricCZoDikOZzH1rRL2AA
+Wlmb4j9yKp4P4uN0tniU0JuFEIQgLklAsEb4BG6izHI0UpXZTKVXY0XymOBdNtaw
+QakjUJVKk+LqapUGIR8xRw==
-----END PRIVATE KEY-----
diff --git a/pkg/registry/testdata/tls/server.crt b/pkg/registry/testdata/tls/server.crt
index 5fae09bb9..42585e775 100644
--- a/pkg/registry/testdata/tls/server.crt
+++ b/pkg/registry/testdata/tls/server.crt
@@ -1,20 +1,21 @@
-----BEGIN CERTIFICATE-----
-MIIDWzCCAkOgAwIBAgIUdJ6uRYm6RYesJ3CRoLokemFFgX4wDQYJKoZIhvcNAQEL
-BQAwUzELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjETMBEG
-A1UECgwKQWNtZSwgSW5jLjEVMBMGA1UEAwwMQWNtZSBSb290IENBMB4XDTIzMDYw
-ODEwNTAzM1oXDTI0MDYwNzEwNTAzM1owWTELMAkGA1UEBhMCQ04xCzAJBgNVBAgM
-AkdEMQswCQYDVQQHDAJTWjETMBEGA1UECgwKQWNtZSwgSW5jLjEbMBkGA1UEAwwS
-aGVsbS10ZXN0LXJlZ2lzdHJ5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
-AQEA59jg4ml82uyvrg+tXf/0S8WHuayl5fB3k1lIPtOrTt5KBNh6z5XHZDogsQ3m
-UEko4gVUvKL0Einm1i5c3C6KFFj0RNib0QpOZtxu54mx2Rxazkge0yjoTMwl/P1o
-pvRI6qfRri8LdlqWwU9wBIYmKqEM8jPjxKcCOaR0WyQmEJ6KbayTzsVNHaQxG/f3
-aIDCkp3tFl+LaTJHjGdZN7tvJsZ1wXlQy6gXTJIPXHDTS/uh3Xp8jgqhlnQPIr44
-HikiAp9DMnOBGO4u4cZjCr04cQnLS9knsBAQCjja9J9DnZ5vKatBHF3nOVAtGoBM
-o69HcYoX5F10Qg8YOa7QwIYjpQIDAQABoyEwHzAdBgNVHREEFjAUghJoZWxtLXRl
-c3QtcmVnaXN0cnkwDQYJKoZIhvcNAQELBQADggEBABMYICc/rzijGhFPFOeSrXyk
-xFX9SSrGMl0CzV44sxzJFJ89BrW9bUWf4rLuc2ugqWp78kRKGMKgaytDrmGGuZKy
-Qy+xl3DTAoc9FYOBphtcH1QndWdbpKSc2sTKvdeV6SslKwWXlAvcqIain80fWAkn
-J+9Fd/rq3sJxCYsYhEf17pDjHDnG5ZUsBAWWzN+YjtSAe4PzT1KdljUPCC1GbF+H
-1dx+MwapV+atftzlGjld8H73MXrKRNUSZM5lEFvzCZz48J1Ml6UVnYO+QCybeJtQ
-lBT3/wclJ86e0eNkZJI0WTmrqlaNS/J7mbZ+4BhfjuO5PyZbLg8DcWmaKeNtT8M=
+MIIDijCCAnKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJDTjEL
+MAkGA1UECAwCR0QxCzAJBgNVBAcMAlNaMRMwEQYDVQQKDApBY21lLCBJbmMuMRUw
+EwYDVQQDDAxBY21lIFJvb3QgQ0EwIBcNMjQwNDIxMTA1MzM4WhgPMzM5MzA0MDQx
+MDUzMzhaMFkxCzAJBgNVBAYTAkNOMQswCQYDVQQIDAJHRDELMAkGA1UEBwwCU1ox
+EzARBgNVBAoMCkFjbWUsIEluYy4xGzAZBgNVBAMMEmhlbG0tdGVzdC1yZWdpc3Ry
+eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAME7cQl/08+JJv8aR07t
+9nAnqQ6fYUwMBX8ULS2i6dXUoR0WpTBS8VgGUb2pNnH83r/VbvAcHSY/3LSUdt1d
+j+cyCBQHXf8ySolInVP3L3s435WJuB9yzVZmlI8xrLOYmfVLnoyWjsirZT2KjLSw
+gVgn0N9PQ6K+IvrIph/jgBsv9c6oCLvWH1TcVtS5AN6gb5aSvr2cXRCVelntLH9V
+QpsmceMtHfzJUW37AarEvTj8NNTOWMIPNs1rqNpFEy1AepHy388C63SJuqy69dvx
+9wE1DCCduH3PMgF7cxWicow9JcIK4kZLrBD4ULdSxTmqA1+yLf+VHhSrDIQy3Lwj
+bBcCAwEAAaNhMF8wHQYDVR0RBBYwFIISaGVsbS10ZXN0LXJlZ2lzdHJ5MB0GA1Ud
+DgQWBBSQliNnbB0bCKi3c3mqifj3CPZbxTAfBgNVHSMEGDAWgBREgz+BR+lJFNaG
+2D7+tDVzzyjc4jANBgkqhkiG9w0BAQsFAAOCAQEAPztylxowZuLT3zRdB0JHkmnI
+zoUmG1hwBeRtruMqQGZnSX0F2glTVKcJzC+Wl5XzMHt2AcRmYl4qk7flWfFavlFp
+7ycIbbKH/4MVmuJF53Zy40fOZ2rDSfyjNsPNQLxTg3tlWVbEAcuyKAWLJ5RZG+hL
+fSKVFzdEsV+Ux//BUuce/q42hTBbZF09GtG+Lg7/DgxGIY7CLzID8GfdcYRBv4sX
+eeOHeGnDC1zttMcnWU49zghJ8MXwo7tOsybQEZmSZZdwQwm+pEwxdibJAXQ/OSGb
+c7RI+clTmnwbP/vnig5RnMALFbUaP2aE/mTMYLWBBV1VqWkfx4Xc7xbE9lrpuA==
-----END CERTIFICATE-----
diff --git a/pkg/registry/testdata/tls/server.key b/pkg/registry/testdata/tls/server.key
index da44121a7..4f7bd54fb 100644
--- a/pkg/registry/testdata/tls/server.key
+++ b/pkg/registry/testdata/tls/server.key
@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
-MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDn2ODiaXza7K+u
-D61d//RLxYe5rKXl8HeTWUg+06tO3koE2HrPlcdkOiCxDeZQSSjiBVS8ovQSKebW
-LlzcLooUWPRE2JvRCk5m3G7nibHZHFrOSB7TKOhMzCX8/Wim9Ejqp9GuLwt2WpbB
-T3AEhiYqoQzyM+PEpwI5pHRbJCYQnoptrJPOxU0dpDEb9/dogMKSne0WX4tpMkeM
-Z1k3u28mxnXBeVDLqBdMkg9ccNNL+6HdenyOCqGWdA8ivjgeKSICn0Myc4EY7i7h
-xmMKvThxCctL2SewEBAKONr0n0Odnm8pq0EcXec5UC0agEyjr0dxihfkXXRCDxg5
-rtDAhiOlAgMBAAECggEBAJ6kfFzwqYpz4lJMT+i+Nz+RzilyxaHtRSUCNrkmxVWW
-LTfbmU1pw6IFVFFSnYHaTas60pyxNCkpmtZ7qvbOsZTyuVJSlWwYjUU9GHY+df+F
-s2zrVIxQtYO3PVc7Xty+0xYd9xAlCMbXfciQvqmZ0Yvh36Xrc7MgRBmFOkkTFyjO
-xaT70D5jwK0QKU8sMY+b9XvvaX59jbRmYAHL0wNcke/E7J4NKEAYfRI+x7kuFhP4
-yDbs9YE0u51cHYAGV4EujZhnv2AwvDnAWs0yHqIbVOIWI9+JRYKmPScr7b1bJfd/
-yy24GXvBu7Ss4TkfsJ/FdGXESr0Gj0ZIPIneDn/vrQECgYEA9jHu4FjTbRff+4tV
-3zJJe88+yByjC6Hhj223JmRpCXQrXl2WLAYXl94p7M5NFdkD5QG7jsNUogLb73dV
-ekUjuQl7IhJZYcRAXcnlkF+8pKt1duA0uRa22VtlR2wyn8oSnLV/9088Moh35sCP
-MjWQDlZ/BW7YUPrOtB14eUCvMjECgYEA8RSpmXZVQdGnIIm6gC3rEhtfHQqAoBn0
-JRvnRXC/LKeVSgVF3ijeT9P/0JQuM9uxubV314nY+fhXsM5kkMZUoXMMSoxE+xPw
-cgArpzwsleMn7BQ/UF3GLpdkUgNFI8bolZFbIa54F7YSFNto0NBp3mkceCJwoWmZ
-BPIoo4zpV7UCgYEAviK2L8GqF5jWvPhRK300z0+xVu725ObywsijKB1oGYsEa26v
-qfRSiFFl46M4WWUu4tBBv/IPDMhUf06UT0fSXPd7h0bQjPb6FvT0PFoT4MEiiNqD
-HWbzdE5nm49uUYXIdgqed6tT/Fr07ttMPCStysT2eIWwvmnU9bnE7zALniECgYAr
-HM7XqtnEU4HXx8macpu/OTXhM6ec+gc3O644NNl7WtzPx/GesSBQllEBM/6vN3Kp
-C1LLMNOkoEzOSZqiaVVpKfHgwwTzAbXWLUGhPpmalGznQxevf5WZb2l5YSxUIZYm
-aUAq3dCMLPs+z54G+b51D8cPlNkfhIrg34108hYooQKBgQDWMbc6wY6frvJCmesx
-i7F/JHJweqcQdW649RCvtK8M/O062/3vvSNTxqEjPaJOGiD4Cn+D5pYchVujqlTM
-8DK77N97NzQvpHm81lpKVIg5sObarvT3RnCSRpOumbX5SCBoBUs+nVC01/zZz79c
-AJFLAeHI1RjhB0AFpRDCvZZk6w==
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDBO3EJf9PPiSb/
+GkdO7fZwJ6kOn2FMDAV/FC0tounV1KEdFqUwUvFYBlG9qTZx/N6/1W7wHB0mP9y0
+lHbdXY/nMggUB13/MkqJSJ1T9y97ON+Vibgfcs1WZpSPMayzmJn1S56Mlo7Iq2U9
+ioy0sIFYJ9DfT0OiviL6yKYf44AbL/XOqAi71h9U3FbUuQDeoG+Wkr69nF0QlXpZ
+7Sx/VUKbJnHjLR38yVFt+wGqxL04/DTUzljCDzbNa6jaRRMtQHqR8t/PAut0ibqs
+uvXb8fcBNQwgnbh9zzIBe3MVonKMPSXCCuJGS6wQ+FC3UsU5qgNfsi3/lR4UqwyE
+Mty8I2wXAgMBAAECggEAAKk5/ytSlGCTicatCcZJbb0xy3ZpUcyuVCH28ABuEyiY
+DugEU3PLll6Aw+JWG/Ieg1xKj3dSwWe+H785eazK3W9pYanCY4+1FSuMOW/pPkWs
+IvA536ARhCmNRo27JoSJU+Wyh1tlTHOk2mukt/vs/vOb6x4NTPttIs7lUP42DC6O
+e/gTvwD13Rrg9PC0aDpZzLqdmXyUoHQ4h8dfYytDE9rZ1gC2CNdd7NWvt2JUppRx
+qWR5OQxm+QiZqrMDUFTZISB/bD7MX/Ubq5InAfwdznzyav4uWsxq72FuoFFGl9xh
+l6WEdusyKay/eNZgXqrHyuJvmt1PUL+Azu8ZYD+C2QKBgQD/nogcrVKLzmmrnggG
+lMAvF5tp3gMI7+wqALH/79Gelvj5CWzGBnS7BcuXFR5cbpLk1cW6mj16IPIRA2CR
+xpGfYKtYt0j5hvIZTg3TpK3Pj/kqEv0AicdGP6SYduJYgaUwFKRzHSR+N3121v5X
+MVXKb5q6pD1wb7cOc2FJAOySHQKBgQDBhR8bAg99EgvVNioSkot++kRffWxwZ9uS
+k1jmhLl7djb1tND4yZGZmi8+bdw7qz7J5yEJHuJiMwOkDsBokpKykk36tjBx3UiV
+Z46OiKbRkiwBLg6fio6BVwAuQpoQ+qMWwkjZFPzWiEhxTPo3ZyiJP8JlT8sG3rV4
+My3wvLagwwKBgFT3RRcDJaUC/2zkIpbNavQ8TJRsD2YxGbb8dC42cN7eH/Pnhhhs
+nPBthLa7dlQTDRCzXf4gtr6ZpNyy2q6Z6l2nrEzY35DRojd3EnF/E6cinBe4KBC9
+u1dGYFetbJ8uuNG6is8YqMCrgTC3VeN1qqaXYj8XyLRO7fIHuBakD/6hAoGARDal
+cUK3rPF4hE5UZDmNvFOBWFuAptqlFjSkKJVuQCu6Ub/LzXZXwVoM/yeAcvP47Phw
+t6NQTycGSIT+o53O4e0aWZ5w0yIaHLflEy7uBn9MzZmrg+c2NjcxlBzb69I9PJ99
+SC/Ss9hUGMP2iyLssfxsjIOk4CYOt3Dq56nNgjsCgYBWOLVMCV10DpYKUY5LFq60
+CJppqPyBfGB+5LLYfOp8JSIh1ZwSL139A2oCynGjrIyyPksdkBUMcS/qLhT1vmzo
+zdUZMwK8D/TjF037F/t34LUHweP/2pl90DUcNPHJJs/IhXji7Kpdnqf3LhSXmgNs
+d7TshLFRKM1z2BlZPZ56cA==
-----END PRIVATE KEY-----
diff --git a/pkg/registry/transport.go b/pkg/registry/transport.go
new file mode 100644
index 000000000..9d6a37326
--- /dev/null
+++ b/pkg/registry/transport.go
@@ -0,0 +1,175 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime"
+ "net/http"
+ "strings"
+ "sync/atomic"
+
+ "oras.land/oras-go/v2/registry/remote/retry"
+)
+
+var (
+ // requestCount records the number of logged request-response pairs and will
+ // be used as the unique id for the next pair.
+ requestCount atomic.Uint64
+
+ // toScrub is a set of headers that should be scrubbed from the log.
+ toScrub = []string{
+ "Authorization",
+ "Set-Cookie",
+ }
+)
+
+// payloadSizeLimit limits the maximum size of the response body to be printed.
+const payloadSizeLimit int64 = 16 * 1024 // 16 KiB
+
+// LoggingTransport is an http.RoundTripper that keeps track of the in-flight
+// request and add hooks to report HTTP tracing events.
+type LoggingTransport struct {
+ http.RoundTripper
+}
+
+// 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
+ 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...
+ transport = t.Clone()
+ }
+ if debug {
+ transport = &LoggingTransport{RoundTripper: transport}
+ }
+
+ return retry.NewTransport(transport)
+}
+
+// RoundTrip calls base round trip while keeping track of the current request.
+func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
+ id := requestCount.Add(1) - 1
+
+ slog.Debug(req.Method, "id", id, "url", req.URL, "header", logHeader(req.Header))
+ resp, err = t.RoundTripper.RoundTrip(req)
+ if err != nil {
+ slog.Debug("Response"[:len(req.Method)], "id", id, "error", err)
+ } else if resp != nil {
+ slog.Debug("Response"[:len(req.Method)], "id", id, "status", resp.Status, "header", logHeader(resp.Header), "body", logResponseBody(resp))
+ } else {
+ slog.Debug("Response"[:len(req.Method)], "id", id, "response", "nil")
+ }
+
+ return resp, err
+}
+
+// logHeader prints out the provided header keys and values, with auth header scrubbed.
+func logHeader(header http.Header) string {
+ if len(header) > 0 {
+ headers := []string{}
+ for k, v := range header {
+ for _, h := range toScrub {
+ if strings.EqualFold(k, h) {
+ v = []string{"*****"}
+ }
+ }
+ headers = append(headers, fmt.Sprintf(" %q: %q", k, strings.Join(v, ", ")))
+ }
+ return strings.Join(headers, "\n")
+ }
+ return " Empty header"
+}
+
+// logResponseBody prints out the response body if it is printable and within size limit.
+func logResponseBody(resp *http.Response) string {
+ if resp.Body == nil || resp.Body == http.NoBody {
+ return " No response body to print"
+ }
+
+ // non-applicable body is not printed and remains untouched for subsequent processing
+ contentType := resp.Header.Get("Content-Type")
+ if contentType == "" {
+ return " Response body without a content type is not printed"
+ }
+ if !isPrintableContentType(contentType) {
+ return fmt.Sprintf(" Response body of content type %q is not printed", contentType)
+ }
+
+ buf := bytes.NewBuffer(nil)
+ body := resp.Body
+ // restore the body by concatenating the read body with the remaining body
+ resp.Body = struct {
+ io.Reader
+ io.Closer
+ }{
+ Reader: io.MultiReader(buf, body),
+ Closer: body,
+ }
+ // read the body up to limit+1 to check if the body exceeds the limit
+ if _, err := io.CopyN(buf, body, payloadSizeLimit+1); err != nil && err != io.EOF {
+ return fmt.Sprintf(" Error reading response body: %v", err)
+ }
+
+ readBody := buf.String()
+ if len(readBody) == 0 {
+ return " Response body is empty"
+ }
+ if containsCredentials(readBody) {
+ return " Response body redacted due to potential credentials"
+ }
+ if len(readBody) > int(payloadSizeLimit) {
+ return readBody[:payloadSizeLimit] + "\n...(truncated)"
+ }
+ return readBody
+}
+
+// isPrintableContentType returns true if the contentType is printable.
+func isPrintableContentType(contentType string) bool {
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ return false
+ }
+
+ switch mediaType {
+ case "application/json", // JSON types
+ "text/plain", "text/html": // text types
+ return true
+ }
+ return strings.HasSuffix(mediaType, "+json")
+}
+
+// containsCredentials returns true if the body contains potential credentials.
+func containsCredentials(body string) bool {
+ return strings.Contains(body, `"token"`) || strings.Contains(body, `"access_token"`)
+}
diff --git a/pkg/registry/transport_test.go b/pkg/registry/transport_test.go
new file mode 100644
index 000000000..b4990c526
--- /dev/null
+++ b/pkg/registry/transport_test.go
@@ -0,0 +1,399 @@
+/*
+Copyright The Helm Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package registry
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "net/http"
+ "testing"
+)
+
+var errMockRead = errors.New("mock read error")
+
+type errorReader struct{}
+
+func (e *errorReader) Read(_ []byte) (n int, err error) {
+ return 0, errMockRead
+}
+
+func Test_isPrintableContentType(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ want bool
+ }{
+ {
+ name: "Empty content type",
+ contentType: "",
+ want: false,
+ },
+ {
+ name: "General JSON type",
+ contentType: "application/json",
+ want: true,
+ },
+ {
+ name: "General JSON type with charset",
+ contentType: "application/json; charset=utf-8",
+ want: true,
+ },
+ {
+ name: "Random type with application/json prefix",
+ contentType: "application/jsonwhatever",
+ want: false,
+ },
+ {
+ name: "Manifest type in JSON",
+ contentType: "application/vnd.oci.image.manifest.v1+json",
+ want: true,
+ },
+ {
+ name: "Manifest type in JSON with charset",
+ contentType: "application/vnd.oci.image.manifest.v1+json; charset=utf-8",
+ want: true,
+ },
+ {
+ name: "Random content type in JSON",
+ contentType: "application/whatever+json",
+ want: true,
+ },
+ {
+ name: "Plain text type",
+ contentType: "text/plain",
+ want: true,
+ },
+ {
+ name: "Plain text type with charset",
+ contentType: "text/plain; charset=utf-8",
+ want: true,
+ },
+ {
+ name: "Random type with text/plain prefix",
+ contentType: "text/plainnnnn",
+ want: false,
+ },
+ {
+ name: "HTML type",
+ contentType: "text/html",
+ want: true,
+ },
+ {
+ name: "Plain text type with charset",
+ contentType: "text/html; charset=utf-8",
+ want: true,
+ },
+ {
+ name: "Random type with text/html prefix",
+ contentType: "text/htmlllll",
+ want: false,
+ },
+ {
+ name: "Binary type",
+ contentType: "application/octet-stream",
+ want: false,
+ },
+ {
+ name: "Unknown type",
+ contentType: "unknown/unknown",
+ want: false,
+ },
+ {
+ name: "Invalid type",
+ contentType: "text/",
+ want: false,
+ },
+ {
+ name: "Random string",
+ contentType: "random123!@#",
+ want: false,
+ },
+ }
+
+ 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)
+ }
+ })
+ }
+}
+
+func Test_logResponseBody(t *testing.T) {
+ tests := []struct {
+ name string
+ resp *http.Response
+ want string
+ wantData []byte
+ }{
+ {
+ name: "Nil body",
+ resp: &http.Response{
+ Body: nil,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ },
+ want: " No response body to print",
+ },
+ {
+ name: "No body",
+ wantData: nil,
+ resp: &http.Response{
+ Body: http.NoBody,
+ ContentLength: 100, // in case of HEAD response, the content length is set but the body is empty
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ },
+ want: " No response body to print",
+ },
+ {
+ name: "Empty body",
+ wantData: []byte(""),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte(""))),
+ ContentLength: 0,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: " Response body is empty",
+ },
+ {
+ name: "Unknown content length",
+ wantData: []byte("whatever"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("whatever"))),
+ ContentLength: -1,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: "whatever",
+ },
+ {
+ name: "Missing content type header",
+ wantData: []byte("whatever"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("whatever"))),
+ ContentLength: 8,
+ },
+ want: " Response body without a content type is not printed",
+ },
+ {
+ name: "Empty content type header",
+ wantData: []byte("whatever"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("whatever"))),
+ ContentLength: 8,
+ Header: http.Header{"Content-Type": []string{""}},
+ },
+ want: " Response body without a content type is not printed",
+ },
+ {
+ name: "Non-printable content type",
+ wantData: []byte("binary data"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("binary data"))),
+ ContentLength: 11,
+ Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
+ },
+ want: " Response body of content type \"application/octet-stream\" is not printed",
+ },
+ {
+ name: "Body at the limit",
+ wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit)),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit)))),
+ ContentLength: payloadSizeLimit,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))),
+ },
+ {
+ name: "Body larger than limit",
+ wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit
+ ContentLength: payloadSizeLimit + 1,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)",
+ },
+ {
+ name: "Printable content type within limit",
+ wantData: []byte("data"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("data"))),
+ ContentLength: 4,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: "data",
+ },
+ {
+ name: "Actual body size is larger than content length",
+ wantData: []byte("data"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("data"))),
+ ContentLength: 3, // mismatched content length
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: "data",
+ },
+ {
+ name: "Actual body size is larger than content length and exceeds limit",
+ wantData: bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), int(payloadSizeLimit+1)))), // 1 byte larger than limit
+ ContentLength: 1, // mismatched content length
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: string(bytes.Repeat([]byte("a"), int(payloadSizeLimit))) + "\n...(truncated)",
+ },
+ {
+ name: "Actual body size is smaller than content length",
+ wantData: []byte("data"),
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte("data"))),
+ ContentLength: 5, // mismatched content length
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: "data",
+ },
+ {
+ name: "Body contains token",
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte(`{"token":"12345"}`))),
+ ContentLength: 17,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ },
+ wantData: []byte(`{"token":"12345"}`),
+ want: " Response body redacted due to potential credentials",
+ },
+ {
+ name: "Body contains access_token",
+ resp: &http.Response{
+ Body: io.NopCloser(bytes.NewReader([]byte(`{"access_token":"12345"}`))),
+ ContentLength: 17,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ },
+ wantData: []byte(`{"access_token":"12345"}`),
+ want: " Response body redacted due to potential credentials",
+ },
+ }
+
+ 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)
+ }
+ // 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)
+ }
+ }
+ })
+ }
+}
+
+func Test_logResponseBody_error(t *testing.T) {
+ tests := []struct {
+ name string
+ resp *http.Response
+ want string
+ }{
+ {
+ name: "Error reading body",
+ resp: &http.Response{
+ Body: io.NopCloser(&errorReader{}),
+ ContentLength: 10,
+ Header: http.Header{"Content-Type": []string{"text/plain"}},
+ },
+ want: " Error reading response body: mock read error",
+ },
+ }
+
+ 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)
+ }
+ })
+ }
+}
+
+func Test_containsCredentials(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want bool
+ }{
+ {
+ name: "Contains token keyword",
+ body: `{"token": "12345"}`,
+ want: true,
+ },
+ {
+ name: "Contains quoted token keyword",
+ body: `whatever "token" blah`,
+ want: true,
+ },
+ {
+ name: "Contains unquoted token keyword",
+ body: `whatever token blah`,
+ want: false,
+ },
+ {
+ name: "Contains access_token keyword",
+ body: `{"access_token": "12345"}`,
+ want: true,
+ },
+ {
+ name: "Contains quoted access_token keyword",
+ body: `whatever "access_token" blah`,
+ want: true,
+ },
+ {
+ name: "Contains unquoted access_token keyword",
+ body: `whatever access_token blah`,
+ want: false,
+ },
+ {
+ name: "Does not contain credentials",
+ body: `{"key": "value"}`,
+ want: false,
+ },
+ {
+ name: "Empty body",
+ body: ``,
+ want: false,
+ },
+ }
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/pkg/registry/util.go b/pkg/registry/util.go
index 6fb1d0cda..b31ab63fe 100644
--- a/pkg/registry/util.go
+++ b/pkg/registry/util.go
@@ -14,30 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package registry // import "helm.sh/helm/v3/pkg/registry"
+package registry // import "helm.sh/helm/v4/pkg/registry"
import (
"bytes"
- "context"
- "encoding/base64"
"fmt"
"io"
"net/http"
+ "slices"
"strings"
"time"
- helmtime "helm.sh/helm/v3/pkg/time"
+ "helm.sh/helm/v4/internal/tlsutil"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ helmtime "helm.sh/helm/v4/pkg/time"
"github.com/Masterminds/semver/v3"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
- "github.com/pkg/errors"
- "github.com/sirupsen/logrus"
- orascontext "oras.land/oras-go/pkg/context"
- "oras.land/oras-go/pkg/registry"
-
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
)
var immutableOciAnnotations = []string{
@@ -45,19 +39,14 @@ var immutableOciAnnotations = []string{
ocispec.AnnotationTitle,
}
-// IsOCI determines whether or not a URL is to be treated as an OCI URL
+// IsOCI determines whether a URL is to be treated as an OCI URL
func IsOCI(url string) bool {
return strings.HasPrefix(url, fmt.Sprintf("%s://", OCIScheme))
}
// ContainsTag determines whether a tag is found in a provided list of tags
func ContainsTag(tags []string, tag string) bool {
- for _, t := range tags {
- if tag == t {
- return true
- }
- }
- return false
+ return slices.Contains(tags, tag)
}
func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (string, error) {
@@ -66,8 +55,7 @@ func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (str
// If string is empty, set wildcard constraint
constraint, _ = semver.NewConstraint("*")
} else {
- // when customer input exact version, check whether have exact match
- // one first
+ // when customer inputs specific version, check whether there's an exact match first
for _, v := range tags {
if versionString == v {
return v, nil
@@ -94,7 +82,7 @@ func GetTagMatchingVersionOrConstraint(tags []string, versionString string) (str
}
}
- return "", errors.Errorf("Could not locate a version matching provided version string %s", versionString)
+ return "", fmt.Errorf("could not locate a version matching provided version string %s", versionString)
}
// extractChartMeta is used to extract a chart metadata from a byte array
@@ -106,45 +94,13 @@ func extractChartMeta(chartData []byte) (*chart.Metadata, error) {
return ch.Metadata, nil
}
-// ctx retrieves a fresh context.
-// disable verbose logging coming from ORAS (unless debug is enabled)
-func ctx(out io.Writer, debug bool) context.Context {
- if !debug {
- return orascontext.Background()
- }
- ctx := orascontext.WithLoggerFromWriter(context.Background(), out)
- orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel)
- return ctx
-}
-
-// parseReference will parse and validate the reference, and clean tags when
-// applicable tags are only cleaned when plus (+) signs are present, and are
-// converted to underscores (_) before pushing
-// See https://github.com/helm/helm/issues/10166
-func parseReference(raw string) (registry.Reference, error) {
- // The sole possible reference modification is replacing plus (+) signs
- // present in tags with underscores (_). To do this properly, we first
- // need to identify a tag, and then pass it on to the reference parser
- // NOTE: Passing immediately to the reference parser will fail since (+)
- // signs are an invalid tag character, and simply replacing all plus (+)
- // occurrences could invalidate other portions of the URI
- parts := strings.Split(raw, ":")
- if len(parts) > 1 && !strings.Contains(parts[len(parts)-1], "/") {
- tag := parts[len(parts)-1]
-
- if tag != "" {
- // Replace any plus (+) signs with known underscore (_) conversion
- newTag := strings.ReplaceAll(tag, "+", "_")
- raw = strings.ReplaceAll(raw, tag, newTag)
- }
- }
-
- return registry.ParseReference(raw)
-}
-
// NewRegistryClientWithTLS is a helper function to create a new registry client with TLS enabled.
func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, insecureSkipTLSverify bool, registryConfig string, debug bool) (*Client, error) {
- tlsConf, err := tlsutil.NewClientTLS(certFile, keyFile, caFile, insecureSkipTLSverify)
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(insecureSkipTLSverify),
+ tlsutil.WithCertKeyPairFiles(certFile, keyFile),
+ tlsutil.WithCAFile(caFile),
+ )
if err != nil {
return nil, fmt.Errorf("can't create TLS config for client: %s", err)
}
@@ -157,6 +113,7 @@ func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, i
ClientOptHTTPClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConf,
+ Proxy: http.ProxyFromEnvironment,
},
}),
)
@@ -167,10 +124,10 @@ func NewRegistryClientWithTLS(out io.Writer, certFile, keyFile, caFile string, i
}
// generateOCIAnnotations will generate OCI annotations to include within the OCI manifest
-func generateOCIAnnotations(meta *chart.Metadata, test bool) map[string]string {
+func generateOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
// Get annotations from Chart attributes
- ociAnnotations := generateChartOCIAnnotations(meta, test)
+ ociAnnotations := generateChartOCIAnnotations(meta, creationTime)
// Copy Chart annotations
annotations:
@@ -190,8 +147,8 @@ annotations:
return ociAnnotations
}
-// getChartOCIAnnotations will generate OCI annotations from the provided chart
-func generateChartOCIAnnotations(meta *chart.Metadata, test bool) map[string]string {
+// generateChartOCIAnnotations will generate OCI annotations from the provided chart
+func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[string]string {
chartOCIAnnotations := map[string]string{}
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationDescription, meta.Description)
@@ -199,15 +156,17 @@ func generateChartOCIAnnotations(meta *chart.Metadata, test bool) map[string]str
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationVersion, meta.Version)
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home)
- if !test {
- chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, helmtime.Now().UTC().Format(time.RFC3339))
+ if len(creationTime) == 0 {
+ creationTime = helmtime.Now().UTC().Format(time.RFC3339)
}
+ chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, creationTime)
+
if len(meta.Sources) > 0 {
chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationSource, meta.Sources[0])
}
- if meta.Maintainers != nil && len(meta.Maintainers) > 0 {
+ if len(meta.Maintainers) > 0 {
var maintainerSb strings.Builder
for maintainerIdx, maintainer := range meta.Maintainers {
@@ -246,32 +205,3 @@ func addToMap(inputMap map[string]string, newKey string, newValue string) map[st
return inputMap
}
-
-// See 2 (end of page 4) https://www.ietf.org/rfc/rfc2617.txt
-// "To receive authorization, the client sends the userid and password,
-// separated by a single colon (":") character, within a base64
-// encoded string in the credentials."
-// It is not meant to be urlencoded.
-func basicAuth(username, password string) string {
- auth := username + ":" + password
- return base64.StdEncoding.EncodeToString([]byte(auth))
-}
-
-// authHeader generates an HTTP authorization header based on the provided
-// username and password and sets it in the provided HTTP headers pointer.
-//
-// If both username and password are empty, no header is set.
-// If only the password is provided, a "Bearer" token is created and set in
-// the Authorization header.
-// If both username and password are provided, a "Basic" authentication token
-// is created using the basicAuth function, and set in the Authorization header.
-func authHeader(username, password string, headers *http.Header) {
- if username == "" && password == "" {
- return
- }
- if username == "" {
- headers.Set("Authorization", fmt.Sprintf("Bearer %s", password))
- return
- }
- headers.Set("Authorization", fmt.Sprintf("Basic %s", basicAuth(username, password)))
-}
diff --git a/pkg/registry/util_test.go b/pkg/registry/util_test.go
index f641801fe..c8ce4e4a4 100644
--- a/pkg/registry/util_test.go
+++ b/pkg/registry/util_test.go
@@ -14,22 +14,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package registry // import "helm.sh/helm/v3/pkg/registry"
+package registry // import "helm.sh/helm/v4/pkg/registry"
import (
- "net/http"
"reflect"
"testing"
"time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
- "helm.sh/helm/v3/pkg/chart"
- helmtime "helm.sh/helm/v3/pkg/time"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
func TestGenerateOCIChartAnnotations(t *testing.T) {
+ nowString := helmtime.Now().Format(time.RFC3339)
+
tests := []struct {
name string
chart *chart.Metadata
@@ -44,6 +45,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
},
},
{
@@ -57,6 +59,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
"org.opencontainers.image.description": "OCI Helm Chart",
"org.opencontainers.image.url": "https://helm.sh",
},
@@ -77,6 +80,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
"org.opencontainers.image.description": "OCI Helm Chart",
"org.opencontainers.image.url": "https://helm.sh",
"org.opencontainers.image.authors": "John Snow",
@@ -96,6 +100,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
"org.opencontainers.image.description": "OCI Helm Chart",
"org.opencontainers.image.url": "https://helm.sh",
"org.opencontainers.image.authors": "John Snow (john@winterfell.com)",
@@ -116,6 +121,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
"org.opencontainers.image.description": "OCI Helm Chart",
"org.opencontainers.image.url": "https://helm.sh",
"org.opencontainers.image.authors": "John Snow (john@winterfell.com), Jane Snow",
@@ -134,6 +140,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
"org.opencontainers.image.description": "OCI Helm Chart",
"org.opencontainers.image.source": "https://github.com/helm/helm",
},
@@ -142,7 +149,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
for _, tt := range tests {
- result := generateChartOCIAnnotations(tt.chart, true)
+ result := generateChartOCIAnnotations(tt.chart, nowString)
if !reflect.DeepEqual(tt.expect, result) {
t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result)
@@ -153,6 +160,8 @@ func TestGenerateOCIChartAnnotations(t *testing.T) {
func TestGenerateOCIAnnotations(t *testing.T) {
+ nowString := helmtime.Now().Format(time.RFC3339)
+
tests := []struct {
name string
chart *chart.Metadata
@@ -167,6 +176,7 @@ func TestGenerateOCIAnnotations(t *testing.T) {
map[string]string{
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
+ "org.opencontainers.image.created": nowString,
},
},
{
@@ -184,6 +194,7 @@ func TestGenerateOCIAnnotations(t *testing.T) {
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
"org.opencontainers.image.description": "OCI Helm Chart",
+ "org.opencontainers.image.created": nowString,
"extrakey": "extravlue",
"anotherkey": "anothervalue",
},
@@ -204,6 +215,7 @@ func TestGenerateOCIAnnotations(t *testing.T) {
"org.opencontainers.image.title": "oci",
"org.opencontainers.image.version": "0.0.1",
"org.opencontainers.image.description": "OCI Helm Chart",
+ "org.opencontainers.image.created": nowString,
"extrakey": "extravlue",
},
},
@@ -211,7 +223,7 @@ func TestGenerateOCIAnnotations(t *testing.T) {
for _, tt := range tests {
- result := generateOCIAnnotations(tt.chart, true)
+ result := generateOCIAnnotations(tt.chart, nowString)
if !reflect.DeepEqual(tt.expect, result) {
t.Errorf("%s: expected map %v, got %v", tt.name, tt.expect, result)
@@ -221,12 +233,16 @@ func TestGenerateOCIAnnotations(t *testing.T) {
}
func TestGenerateOCICreatedAnnotations(t *testing.T) {
+
+ nowTime := helmtime.Now()
+ nowTimeString := nowTime.Format(time.RFC3339)
+
chart := &chart.Metadata{
Name: "oci",
Version: "0.0.1",
}
- result := generateOCIAnnotations(chart, false)
+ result := generateOCIAnnotations(chart, nowTimeString)
// Check that created annotation exists
if _, ok := result[ocispec.AnnotationCreated]; !ok {
@@ -238,78 +254,22 @@ func TestGenerateOCICreatedAnnotations(t *testing.T) {
t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated])
}
-}
+ // Verify default creation time set
+ result = generateOCIAnnotations(chart, "")
-func Test_basicAuth(t *testing.T) {
- type args struct {
- username string
- password string
- }
- tests := []struct {
- name string
- args args
- want string
- }{
- {
- name: "Basic Auth",
- args: args{
- username: "admin",
- password: "passw0rd",
- },
- want: "YWRtaW46cGFzc3cwcmQ=",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := basicAuth(tt.args.username, tt.args.password); got != tt.want {
- t.Errorf("basicAuth() = %v, want %v", got, tt.want)
- }
- })
+ // Check that created annotation exists
+ if _, ok := result[ocispec.AnnotationCreated]; !ok {
+ t.Errorf("%s annotation not created", ocispec.AnnotationCreated)
}
-}
-func Test_authHeader(t *testing.T) {
- tests := []struct {
- name string
- username string
- password string
- expectedHeader http.Header
- }{
- {
- name: "basic login header with username and password",
- username: "admin",
- password: "passw0rd",
- expectedHeader: func() http.Header {
- header := http.Header{}
- header.Set("Authorization", "Basic YWRtaW46cGFzc3cwcmQ=")
- return header
- }(),
- },
- {
- name: "bearer login header with no username and password",
- username: "",
- password: "hunter2",
- expectedHeader: func() http.Header {
- header := http.Header{}
- header.Set("Authorization", "Bearer hunter2")
- return header
- }(),
- },
- {
- name: "no change in header with neither username nor password",
- username: "",
- password: "",
- expectedHeader: http.Header{},
- },
- }
+ if createdTimeAnnotation, err := helmtime.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)
+ }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := &http.Header{}
- authHeader(tt.username, tt.password, got)
- if !reflect.DeepEqual(*got, tt.expectedHeader) {
- t.Errorf("authHeader got %#v wanted %#v", *got, tt.expectedHeader)
- }
- })
}
+
}
diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go
index 74aa0dbc0..b46317fc6 100644
--- a/pkg/registry/utils_test.go
+++ b/pkg/registry/utils_test.go
@@ -29,6 +29,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"time"
"github.com/distribution/distribution/v3/configuration"
@@ -36,11 +37,10 @@ import (
_ "github.com/distribution/distribution/v3/registry/auth/htpasswd"
_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
"github.com/foxcpp/go-mockdns"
- "github.com/phayes/freeport"
"github.com/stretchr/testify/suite"
"golang.org/x/crypto/bcrypt"
- "helm.sh/helm/v3/internal/tlsutil"
+ "helm.sh/helm/v4/internal/tlsutil"
)
const (
@@ -88,15 +88,20 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry {
ClientOptEnableCache(true),
ClientOptWriter(suite.Out),
ClientOptCredentialsFile(credentialsFile),
- ClientOptResolver(nil),
+ ClientOptBasicAuth(testUsername, testPassword),
}
if tlsEnabled {
var tlsConf *tls.Config
if insecure {
- tlsConf, err = tlsutil.NewClientTLS("", "", "", true)
+ tlsConf, err = tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(true),
+ )
} else {
- tlsConf, err = tlsutil.NewClientTLS(tlsCert, tlsKey, tlsCA, false)
+ tlsConf, err = tlsutil.NewTLSConfig(
+ tlsutil.WithCertKeyPairFiles(tlsCert, tlsKey),
+ tlsutil.WithCAFile(tlsCA),
+ )
}
httpClient := &http.Client{
Transport: &http.Transport{
@@ -116,37 +121,37 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry {
pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost)
suite.Nil(err, "no error generating bcrypt password for test htpasswd file")
htpasswdPath := filepath.Join(suite.WorkspaceDir, testHtpasswdFileBasename)
- err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644)
+ err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0644)
suite.Nil(err, "no error creating test htpasswd file")
// Registry config
config := &configuration.Configuration{}
- port, err := freeport.GetFreePort()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
suite.Nil(err, "no error finding free port for test registry")
+ defer ln.Close()
// Change the registry host to another host which is not localhost.
// This is required because Docker enforces HTTP if the registry
// host is localhost/127.0.0.1.
+ port := ln.Addr().(*net.TCPAddr).Port
suite.DockerRegistryHost = fmt.Sprintf("helm-test-registry:%d", port)
- suite.srv, _ = mockdns.NewServer(map[string]mockdns.Zone{
+ suite.srv, err = mockdns.NewServer(map[string]mockdns.Zone{
"helm-test-registry.": {
A: []string{"127.0.0.1"},
},
}, false)
+ suite.Nil(err, "no error creating mock DNS server")
suite.srv.PatchNet(net.DefaultResolver)
- config.HTTP.Addr = fmt.Sprintf(":%d", 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{}{}}
- // Basic auth is not possible if we are serving HTTP.
- if tlsEnabled {
- config.Auth = configuration.Auth{
- "htpasswd": configuration.Parameters{
- "realm": "localhost",
- "path": htpasswdPath,
- },
- }
+ config.Auth = configuration.Auth{
+ "htpasswd": configuration.Parameters{
+ "realm": "localhost",
+ "path": htpasswdPath,
+ },
}
// config tls
@@ -169,6 +174,9 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry {
}
func teardown(suite *TestSuite) {
+ var lock sync.Mutex
+ lock.Lock()
+ defer lock.Unlock()
if suite.srv != nil {
mockdns.UnpatchNet(net.DefaultResolver)
suite.srv.Close()
@@ -179,11 +187,9 @@ func initCompromisedRegistryTestServer() string {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "manifests") {
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
- w.WriteHeader(200)
+ w.WriteHeader(http.StatusOK)
- // layers[0] is the blob []byte("a")
- w.Write([]byte(
- fmt.Sprintf(`{ "schemaVersion": 2, "config": {
+ fmt.Fprintf(w, `{ "schemaVersion": 2, "config": {
"mediaType": "%s",
"digest": "sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133",
"size": 181
@@ -195,19 +201,19 @@ func initCompromisedRegistryTestServer() string {
"size": 1
}
]
-}`, ConfigMediaType, ChartLayerMediaType)))
+}`, ConfigMediaType, ChartLayerMediaType)
} else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:a705ee2789ab50a5ba20930f246dbd5cc01ff9712825bb98f57ee8414377f133" {
w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
+ w.WriteHeader(http.StatusOK)
w.Write([]byte("{\"name\":\"mychart\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\\n" +
"an 'application' or a 'library' chart.\",\"apiVersion\":\"v2\",\"appVersion\":\"1.16.0\",\"type\":" +
"\"application\"}"))
} else if r.URL.Path == "/v2/testrepo/supposedlysafechart/blobs/sha256:ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" {
w.Header().Set("Content-Type", ChartLayerMediaType)
- w.WriteHeader(200)
+ w.WriteHeader(http.StatusOK)
w.Write([]byte("b"))
} else {
- w.WriteHeader(500)
+ w.WriteHeader(http.StatusInternalServerError)
}
}))
@@ -216,9 +222,12 @@ func initCompromisedRegistryTestServer() string {
}
func testPush(suite *TestSuite) {
+
+ testingChartCreationTime := "1977-09-02T22:04:05Z"
+
// Bad bytes
ref := fmt.Sprintf("%s/testrepo/testchart:1.2.3", suite.DockerRegistryHost)
- _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptTest(true))
+ _, err := suite.RegistryClient.Push([]byte("hello"), ref, PushOptCreationTime(testingChartCreationTime))
suite.NotNil(err, "error pushing non-chart bytes")
// Load a test chart
@@ -229,20 +238,20 @@ func testPush(suite *TestSuite) {
// non-strict ref (chart name)
ref = fmt.Sprintf("%s/testrepo/boop:%s", suite.DockerRegistryHost, meta.Version)
- _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true))
+ _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime))
suite.NotNil(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), PushOptTest(true))
+ _, 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")
// non-strict ref (chart version)
ref = fmt.Sprintf("%s/testrepo/%s:latest", suite.DockerRegistryHost, meta.Name)
- _, err = suite.RegistryClient.Push(chartData, ref, PushOptTest(true))
+ _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime))
suite.NotNil(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), PushOptTest(true))
+ _, 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")
// basic push, good ref
@@ -251,7 +260,7 @@ func testPush(suite *TestSuite) {
meta, err = extractChartMeta(chartData)
suite.Nil(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, PushOptTest(true))
+ _, err = suite.RegistryClient.Push(chartData, ref, PushOptCreationTime(testingChartCreationTime))
suite.Nil(err, "no error pushing good ref")
_, err = suite.RegistryClient.Pull(ref)
@@ -269,10 +278,10 @@ func testPush(suite *TestSuite) {
// 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), PushOptTest(true))
+ result, err := suite.RegistryClient.Push(chartData, ref, PushOptProvData(provData), PushOptCreationTime(testingChartCreationTime))
suite.Nil(err, "no error pushing good ref with prov")
- _, err = suite.RegistryClient.Pull(ref)
+ _, err = suite.RegistryClient.Pull(ref, PullOptWithProv(true))
suite.Nil(err, "no error pulling a simple chart")
// Validate the output
@@ -281,12 +290,12 @@ func testPush(suite *TestSuite) {
suite.Equal(ref, result.Ref)
suite.Equal(meta.Name, result.Chart.Meta.Name)
suite.Equal(meta.Version, result.Chart.Meta.Version)
- suite.Equal(int64(684), result.Manifest.Size)
+ suite.Equal(int64(742), result.Manifest.Size)
suite.Equal(int64(99), result.Config.Size)
suite.Equal(int64(973), result.Chart.Size)
suite.Equal(int64(695), result.Prov.Size)
suite.Equal(
- "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6",
+ "sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2",
result.Manifest.Digest)
suite.Equal(
"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@@ -346,7 +355,7 @@ func testPull(suite *TestSuite) {
// full pull with chart and prov
result, err := suite.RegistryClient.Pull(ref, PullOptWithProv(true))
- suite.Nil(err, "no error pulling a chart with prov")
+ suite.Require().Nil(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,
@@ -354,12 +363,12 @@ func testPull(suite *TestSuite) {
suite.Equal(ref, result.Ref)
suite.Equal(meta.Name, result.Chart.Meta.Name)
suite.Equal(meta.Version, result.Chart.Meta.Version)
- suite.Equal(int64(684), result.Manifest.Size)
+ suite.Equal(int64(742), result.Manifest.Size)
suite.Equal(int64(99), result.Config.Size)
suite.Equal(int64(973), result.Chart.Size)
suite.Equal(int64(695), result.Prov.Size)
suite.Equal(
- "sha256:b57e8ffd938c43253f30afedb3c209136288e6b3af3b33473e95ea3b805888e6",
+ "sha256:fbbade96da6050f68f94f122881e3b80051a18f13ab5f4081868dd494538f5c2",
result.Manifest.Digest)
suite.Equal(
"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580",
@@ -370,7 +379,7 @@ func testPull(suite *TestSuite) {
suite.Equal(
"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256",
result.Prov.Digest)
- suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}",
+ suite.Equal("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"sha256:8d17cb6bf6ccd8c29aace9a658495cbd5e2e87fc267876e86117c7db681c9580\",\"size\":99},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.provenance.v1.prov\",\"digest\":\"sha256:b0a02b7412f78ae93324d48df8fcc316d8482e5ad7827b5b238657a29a22f256\",\"size\":695},{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"sha256:e5ef611620fb97704d8751c16bab17fedb68883bfb0edc76f78a70e9173f9b55\",\"size\":973}],\"annotations\":{\"org.opencontainers.image.created\":\"1977-09-02T22:04:05Z\",\"org.opencontainers.image.description\":\"A Helm chart for Kubernetes\",\"org.opencontainers.image.title\":\"signtest\",\"org.opencontainers.image.version\":\"0.1.0\"}}",
string(result.Manifest.Data))
suite.Equal("{\"name\":\"signtest\",\"version\":\"0.1.0\",\"description\":\"A Helm chart for Kubernetes\",\"apiVersion\":\"v1\"}",
string(result.Config.Data))
diff --git a/pkg/releaseutil/filter.go b/pkg/release/util/filter.go
similarity index 94%
rename from pkg/releaseutil/filter.go
rename to pkg/release/util/filter.go
index dbd0df8e2..f0a082cfd 100644
--- a/pkg/releaseutil/filter.go
+++ b/pkg/release/util/filter.go
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil"
+package util // import "helm.sh/helm/v4/pkg/release/util"
-import rspb "helm.sh/helm/v3/pkg/release"
+import rspb "helm.sh/helm/v4/pkg/release/v1"
// FilterFunc returns true if the release object satisfies
// the predicate of the underlying filter func.
diff --git a/pkg/releaseutil/filter_test.go b/pkg/release/util/filter_test.go
similarity index 94%
rename from pkg/releaseutil/filter_test.go
rename to pkg/release/util/filter_test.go
index 31ac306f6..5d2564619 100644
--- a/pkg/releaseutil/filter_test.go
+++ b/pkg/release/util/filter_test.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil"
+package util // import "helm.sh/helm/v4/pkg/release/util"
import (
"testing"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestFilterAny(t *testing.T) {
diff --git a/pkg/releaseutil/kind_sorter.go b/pkg/release/util/kind_sorter.go
similarity index 91%
rename from pkg/releaseutil/kind_sorter.go
rename to pkg/release/util/kind_sorter.go
index b5d75b88b..bc074340f 100644
--- a/pkg/releaseutil/kind_sorter.go
+++ b/pkg/release/util/kind_sorter.go
@@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil
+package util
import (
"sort"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// KindSortOrder is an ordering of Kinds.
@@ -65,12 +65,17 @@ var InstallOrder KindSortOrder = []string{
"IngressClass",
"Ingress",
"APIService",
+ "MutatingWebhookConfiguration",
+ "ValidatingWebhookConfiguration",
}
// UninstallOrder is the order in which manifests should be uninstalled (by Kind).
//
// Those occurring earlier in the list get uninstalled before those occurring later in the list.
var UninstallOrder KindSortOrder = []string{
+ // For uninstall, we remove validation before mutation to ensure webhooks don't block removal
+ "ValidatingWebhookConfiguration",
+ "MutatingWebhookConfiguration",
"APIService",
"Ingress",
"IngressClass",
@@ -132,7 +137,7 @@ func sortHooksByKind(hooks []*release.Hook, ordering KindSortOrder) []*release.H
return h
}
-func lessByKind(a interface{}, b interface{}, kindA string, kindB string, o KindSortOrder) bool {
+func lessByKind(_ interface{}, _ interface{}, 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/releaseutil/kind_sorter_test.go b/pkg/release/util/kind_sorter_test.go
similarity index 95%
rename from pkg/releaseutil/kind_sorter_test.go
rename to pkg/release/util/kind_sorter_test.go
index 9e24c4399..919de24e5 100644
--- a/pkg/releaseutil/kind_sorter_test.go
+++ b/pkg/release/util/kind_sorter_test.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil
+package util
import (
"bytes"
"testing"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestKindSorter(t *testing.T) {
@@ -173,6 +173,14 @@ func TestKindSorter(t *testing.T) {
Name: "F",
Head: &SimpleHead{Kind: "PriorityClass"},
},
+ {
+ Name: "M",
+ Head: &SimpleHead{Kind: "MutatingWebhookConfiguration"},
+ },
+ {
+ Name: "V",
+ Head: &SimpleHead{Kind: "ValidatingWebhookConfiguration"},
+ },
}
for _, test := range []struct {
@@ -180,8 +188,8 @@ func TestKindSorter(t *testing.T) {
order KindSortOrder
expected string
}{
- {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvw!"},
- {"uninstall", UninstallOrder, "wvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"},
+ {"install", InstallOrder, "FaAbcC3deEf1gh2iIjJkKlLmnopqrxstuUvwMV!"},
+ {"uninstall", UninstallOrder, "VMwvUmutsxrqponLlKkJjIi2hg1fEed3CcbAaF!"},
} {
var buf bytes.Buffer
t.Run(test.description, func(t *testing.T) {
diff --git a/pkg/releaseutil/manifest.go b/pkg/release/util/manifest.go
similarity index 99%
rename from pkg/releaseutil/manifest.go
rename to pkg/release/util/manifest.go
index 0b04a4599..9a87949f8 100644
--- a/pkg/releaseutil/manifest.go
+++ b/pkg/release/util/manifest.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil
+package util
import (
"fmt"
diff --git a/pkg/releaseutil/manifest_sorter.go b/pkg/release/util/manifest_sorter.go
similarity index 83%
rename from pkg/releaseutil/manifest_sorter.go
rename to pkg/release/util/manifest_sorter.go
index 413de30e2..21fdec7c6 100644
--- a/pkg/releaseutil/manifest_sorter.go
+++ b/pkg/release/util/manifest_sorter.go
@@ -14,20 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil
+package util
import (
- "log"
+ "fmt"
+ "log/slog"
"path"
"sort"
"strconv"
"strings"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
// Manifest represents a manifest file, which has a name and some content.
@@ -41,7 +41,6 @@ type Manifest struct {
type manifestFile struct {
entries map[string]string
path string
- apis chartutil.VersionSet
}
// result is an intermediate structure used during sorting.
@@ -75,7 +74,7 @@ var events = map[string]release.HookEvent{
//
// Files that do not parse into the expected format are simply placed into a map and
// returned.
-func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) {
+func SortManifests(files map[string]string, _ chartutil.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) {
result := &result{}
var sortedFilePaths []string
@@ -100,7 +99,6 @@ func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering
manifestFile := &manifestFile{
entries: SplitManifests(content),
path: filePath,
- apis: apis,
}
if err := manifestFile.sort(result); err != nil {
@@ -130,6 +128,14 @@ func SortManifests(files map[string]string, apis chartutil.VersionSet, ordering
// metadata:
// annotations:
// helm.sh/hook-delete-policy: hook-succeeded
+//
+// To determine the policy to output logs of the hook (for Pod and Job only), it looks for a YAML structure like this:
+//
+// kind: Pod
+// apiVersion: v1
+// metadata:
+// annotations:
+// helm.sh/hook-output-log-policy: hook-succeeded,hook-failed
func (file *manifestFile) sort(result *result) error {
// Go through manifests in order found in file (function `SplitManifests` creates integer-sortable keys)
var sortedEntryKeys []string
@@ -143,7 +149,7 @@ func (file *manifestFile) sort(result *result) error {
var entry SimpleHead
if err := yaml.Unmarshal([]byte(m), &entry); err != nil {
- return errors.Wrapf(err, "YAML parse error on %s", file.path)
+ return fmt.Errorf("YAML parse error on %s: %w", file.path, err)
}
if !hasAnyAnnotation(entry) {
@@ -168,17 +174,18 @@ func (file *manifestFile) sort(result *result) error {
hw := calculateHookWeight(entry)
h := &release.Hook{
- Name: entry.Metadata.Name,
- Kind: entry.Kind,
- Path: file.path,
- Manifest: m,
- Events: []release.HookEvent{},
- Weight: hw,
- DeletePolicies: []release.HookDeletePolicy{},
+ Name: entry.Metadata.Name,
+ Kind: entry.Kind,
+ Path: file.path,
+ Manifest: m,
+ Events: []release.HookEvent{},
+ Weight: hw,
+ DeletePolicies: []release.HookDeletePolicy{},
+ OutputLogPolicies: []release.HookOutputLogPolicy{},
}
isUnknownHook := false
- for _, hookType := range strings.Split(hookTypes, ",") {
+ for hookType := range strings.SplitSeq(hookTypes, ",") {
hookType = strings.ToLower(strings.TrimSpace(hookType))
e, ok := events[hookType]
if !ok {
@@ -189,7 +196,7 @@ func (file *manifestFile) sort(result *result) error {
}
if isUnknownHook {
- log.Printf("info: skipping unknown hook: %q", hookTypes)
+ slog.Info("skipping unknown hooks", "hookTypes", hookTypes)
continue
}
@@ -198,6 +205,10 @@ func (file *manifestFile) sort(result *result) error {
operateAnnotationValues(entry, release.HookDeleteAnnotation, func(value string) {
h.DeletePolicies = append(h.DeletePolicies, release.HookDeletePolicy(value))
})
+
+ operateAnnotationValues(entry, release.HookOutputLogAnnotation, func(value string) {
+ h.OutputLogPolicies = append(h.OutputLogPolicies, release.HookOutputLogPolicy(value))
+ })
}
return nil
@@ -225,7 +236,7 @@ func calculateHookWeight(entry SimpleHead) int {
// operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation
func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) {
if dps, ok := entry.Metadata.Annotations[annotation]; ok {
- for _, dp := range strings.Split(dps, ",") {
+ for dp := range strings.SplitSeq(dps, ",") {
dp = strings.ToLower(strings.TrimSpace(dp))
operate(dp)
}
diff --git a/pkg/releaseutil/manifest_sorter_test.go b/pkg/release/util/manifest_sorter_test.go
similarity index 96%
rename from pkg/releaseutil/manifest_sorter_test.go
rename to pkg/release/util/manifest_sorter_test.go
index 20d809317..4360013e5 100644
--- a/pkg/releaseutil/manifest_sorter_test.go
+++ b/pkg/release/util/manifest_sorter_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil
+package util
import (
"reflect"
@@ -22,8 +22,7 @@ import (
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chartutil"
- "helm.sh/helm/v3/pkg/release"
+ release "helm.sh/helm/v4/pkg/release/v1"
)
func TestSortManifests(t *testing.T) {
@@ -139,7 +138,7 @@ metadata:
manifests[o.path] = o.manifest
}
- hs, generic, err := SortManifests(manifests, chartutil.VersionSet{"v1", "v1beta1"}, InstallOrder)
+ hs, generic, err := SortManifests(manifests, nil, InstallOrder)
if err != nil {
t.Fatalf("Unexpected error: %s", err)
}
diff --git a/pkg/releaseutil/manifest_test.go b/pkg/release/util/manifest_test.go
similarity index 95%
rename from pkg/releaseutil/manifest_test.go
rename to pkg/release/util/manifest_test.go
index 8664d20ef..cfc19563d 100644
--- a/pkg/releaseutil/manifest_test.go
+++ b/pkg/release/util/manifest_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil"
+package util // import "helm.sh/helm/v4/pkg/release/util"
import (
"reflect"
diff --git a/pkg/releaseutil/sorter.go b/pkg/release/util/sorter.go
similarity index 57%
rename from pkg/releaseutil/sorter.go
rename to pkg/release/util/sorter.go
index 1a8aa78a6..1b09d0f3b 100644
--- a/pkg/releaseutil/sorter.go
+++ b/pkg/release/util/sorter.go
@@ -14,43 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil"
+package util // import "helm.sh/helm/v4/pkg/release/util"
import (
"sort"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
-type list []*rspb.Release
-
-func (s list) Len() int { return len(s) }
-func (s list) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
-
-// ByName sorts releases by name
-type ByName struct{ list }
-
-// Less compares to releases
-func (s ByName) Less(i, j int) bool { return s.list[i].Name < s.list[j].Name }
-
-// ByDate sorts releases by date
-type ByDate struct{ list }
-
-// Less compares to releases
-func (s ByDate) Less(i, j int) bool {
- ti := s.list[i].Info.LastDeployed.Unix()
- tj := s.list[j].Info.LastDeployed.Unix()
- return ti < tj
-}
-
-// ByRevision sorts releases by revision number
-type ByRevision struct{ list }
-
-// Less compares to releases
-func (s ByRevision) Less(i, j int) bool {
- return s.list[i].Version < s.list[j].Version
-}
-
// Reverse reverses the list of releases sorted by the sort func.
func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) {
sortFn(list)
@@ -62,17 +33,25 @@ func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) {
// SortByName returns the list of releases sorted
// in lexicographical order.
func SortByName(list []*rspb.Release) {
- sort.Sort(ByName{list})
+ sort.Slice(list, func(i, j int) bool {
+ return list[i].Name < list[j].Name
+ })
}
// SortByDate returns the list of releases sorted by a
// release's last deployed time (in seconds).
func SortByDate(list []*rspb.Release) {
- sort.Sort(ByDate{list})
+ sort.Slice(list, func(i, j int) bool {
+ ti := list[i].Info.LastDeployed.Unix()
+ tj := list[j].Info.LastDeployed.Unix()
+ return ti < tj
+ })
}
// SortByRevision returns the list of releases sorted by a
// release's revision number (release.Version).
func SortByRevision(list []*rspb.Release) {
- sort.Sort(ByRevision{list})
+ sort.Slice(list, func(i, j int) bool {
+ return list[i].Version < list[j].Version
+ })
}
diff --git a/pkg/releaseutil/sorter_test.go b/pkg/release/util/sorter_test.go
similarity index 94%
rename from pkg/releaseutil/sorter_test.go
rename to pkg/release/util/sorter_test.go
index 9544d2018..7ca540441 100644
--- a/pkg/releaseutil/sorter_test.go
+++ b/pkg/release/util/sorter_test.go
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package releaseutil // import "helm.sh/helm/v3/pkg/releaseutil"
+package util // import "helm.sh/helm/v4/pkg/release/util"
import (
"testing"
"time"
- rspb "helm.sh/helm/v3/pkg/release"
- helmtime "helm.sh/helm/v3/pkg/time"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
+ helmtime "helm.sh/helm/v4/pkg/time"
)
// note: this test data is shared with filter_test.go.
@@ -43,6 +43,7 @@ func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rs
}
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)
diff --git a/pkg/release/hook.go b/pkg/release/v1/hook.go
similarity index 84%
rename from pkg/release/hook.go
rename to pkg/release/v1/hook.go
index cb9955582..1ef5c1eb8 100644
--- a/pkg/release/hook.go
+++ b/pkg/release/v1/hook.go
@@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
import (
- "helm.sh/helm/v3/pkg/time"
+ "helm.sh/helm/v4/pkg/time"
)
// HookEvent specifies the hook event
@@ -50,6 +50,17 @@ const (
func (x HookDeletePolicy) String() string { return string(x) }
+// HookOutputLogPolicy specifies the hook output log policy
+type HookOutputLogPolicy string
+
+// Hook output log policy types
+const (
+ HookOutputOnSucceeded HookOutputLogPolicy = "hook-succeeded"
+ HookOutputOnFailed HookOutputLogPolicy = "hook-failed"
+)
+
+func (x HookOutputLogPolicy) String() string { return string(x) }
+
// HookAnnotation is the label name for a hook
const HookAnnotation = "helm.sh/hook"
@@ -59,6 +70,9 @@ const HookWeightAnnotation = "helm.sh/hook-weight"
// HookDeleteAnnotation is the label name for the delete policy for a hook
const HookDeleteAnnotation = "helm.sh/hook-delete-policy"
+// HookOutputLogAnnotation is the label name for the output log policy for a hook
+const HookOutputLogAnnotation = "helm.sh/hook-output-log-policy"
+
// Hook defines a hook object.
type Hook struct {
Name string `json:"name,omitempty"`
@@ -76,6 +90,8 @@ type Hook struct {
Weight int `json:"weight,omitempty"`
// DeletePolicies are the policies that indicate when to delete the hook
DeletePolicies []HookDeletePolicy `json:"delete_policies,omitempty"`
+ // OutputLogPolicies defines whether we should copy hook logs back to main process
+ OutputLogPolicies []HookOutputLogPolicy `json:"output_log_policies,omitempty"`
}
// A HookExecution records the result for the last execution of a hook for a given release.
diff --git a/pkg/release/info.go b/pkg/release/v1/info.go
similarity index 96%
rename from pkg/release/info.go
rename to pkg/release/v1/info.go
index b030a8a54..ff98ab63e 100644
--- a/pkg/release/info.go
+++ b/pkg/release/v1/info.go
@@ -13,12 +13,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
import (
"k8s.io/apimachinery/pkg/runtime"
- "helm.sh/helm/v3/pkg/time"
+ "helm.sh/helm/v4/pkg/time"
)
// Info describes release information.
diff --git a/pkg/release/mock.go b/pkg/release/v1/mock.go
similarity index 80%
rename from pkg/release/mock.go
rename to pkg/release/v1/mock.go
index a28e1dc16..3d3b0c2e2 100644
--- a/pkg/release/mock.go
+++ b/pkg/release/v1/mock.go
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
import (
"fmt"
"math/rand"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/time"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/time"
)
// MockHookTemplate is the hook template used for all mock release objects.
@@ -46,6 +46,7 @@ type MockReleaseOptions struct {
Chart *chart.Chart
Status Status
Namespace string
+ Labels map[string]string
}
// Mock creates a mock release object based on options set by MockReleaseOptions. This function should typically not be used outside of testing.
@@ -66,6 +67,10 @@ func Mock(opts *MockReleaseOptions) *Release {
if namespace == "" {
namespace = "default"
}
+ var labels map[string]string
+ if len(opts.Labels) > 0 {
+ labels = opts.Labels
+ }
ch := opts.Chart
if opts.Chart == nil {
@@ -74,6 +79,24 @@ func Mock(opts *MockReleaseOptions) *Release {
Name: "foo",
Version: "0.1.0-beta.1",
AppVersion: "1.0",
+ Annotations: map[string]string{
+ "category": "web-apps",
+ "supported": "true",
+ },
+ Dependencies: []*chart.Dependency{
+ {
+ Name: "cool-plugin",
+ Version: "1.0.0",
+ Repository: "https://coolplugin.io/charts",
+ Condition: "coolPlugin.enabled",
+ Enabled: true,
+ },
+ {
+ Name: "crds",
+ Version: "2.7.1",
+ Condition: "crds.enabled",
+ },
+ },
},
Templates: []*chart.File{
{Name: "templates/foo.tpl", Data: []byte(MockManifest)},
@@ -112,5 +135,6 @@ func Mock(opts *MockReleaseOptions) *Release {
},
},
Manifest: MockManifest,
+ Labels: labels,
}
}
diff --git a/pkg/release/release.go b/pkg/release/v1/release.go
similarity index 96%
rename from pkg/release/release.go
rename to pkg/release/v1/release.go
index b90612873..74e834f7b 100644
--- a/pkg/release/release.go
+++ b/pkg/release/v1/release.go
@@ -13,9 +13,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
-import "helm.sh/helm/v3/pkg/chart"
+import (
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+)
// Release describes a deployment of a chart, together with the chart
// and the variables used to deploy that chart.
diff --git a/pkg/release/responses.go b/pkg/release/v1/responses.go
similarity index 98%
rename from pkg/release/responses.go
rename to pkg/release/v1/responses.go
index 7ee1fc2ee..2a5608c67 100644
--- a/pkg/release/responses.go
+++ b/pkg/release/v1/responses.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
// UninstallReleaseResponse represents a successful response to an uninstall request.
type UninstallReleaseResponse struct {
diff --git a/pkg/release/status.go b/pkg/release/v1/status.go
similarity index 92%
rename from pkg/release/status.go
rename to pkg/release/v1/status.go
index e0e3ed62a..8d6459013 100644
--- a/pkg/release/status.go
+++ b/pkg/release/v1/status.go
@@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package release
+package v1
// Status is the status of a release
type Status string
@@ -31,13 +31,13 @@ const (
StatusSuperseded Status = "superseded"
// StatusFailed indicates that the release was not successfully deployed.
StatusFailed Status = "failed"
- // StatusUninstalling indicates that a uninstall operation is underway.
+ // StatusUninstalling indicates that an uninstall operation is underway.
StatusUninstalling Status = "uninstalling"
// StatusPendingInstall indicates that an install operation is underway.
StatusPendingInstall Status = "pending-install"
// StatusPendingUpgrade indicates that an upgrade operation is underway.
StatusPendingUpgrade Status = "pending-upgrade"
- // StatusPendingRollback indicates that an rollback operation is underway.
+ // StatusPendingRollback indicates that a rollback operation is underway.
StatusPendingRollback Status = "pending-rollback"
)
diff --git a/pkg/repo/chartrepo.go b/pkg/repo/chartrepo.go
index d9022ee6e..c54197d60 100644
--- a/pkg/repo/chartrepo.go
+++ b/pkg/repo/chartrepo.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package repo // import "helm.sh/helm/v3/pkg/repo"
+package repo // import "helm.sh/helm/v4/pkg/repo"
import (
"crypto/rand"
@@ -22,19 +22,14 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
+ "log/slog"
"net/url"
"os"
"path/filepath"
"strings"
- "github.com/pkg/errors"
- "sigs.k8s.io/yaml"
-
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
- "helm.sh/helm/v3/pkg/provenance"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
)
// Entry represents a collection of parameters for chart repository
@@ -52,23 +47,22 @@ type Entry struct {
// ChartRepository represents a chart repository
type ChartRepository struct {
- Config *Entry
- ChartPaths []string
- IndexFile *IndexFile
- Client getter.Getter
- CachePath string
+ Config *Entry
+ IndexFile *IndexFile
+ Client getter.Getter
+ CachePath string
}
// NewChartRepository constructs ChartRepository
func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository, error) {
u, err := url.Parse(cfg.URL)
if err != nil {
- return nil, errors.Errorf("invalid chart URL format: %s", cfg.URL)
+ return nil, fmt.Errorf("invalid chart URL format: %s", cfg.URL)
}
client, err := getters.ByScheme(u.Scheme)
if err != nil {
- return nil, errors.Errorf("could not find protocol handler for: %s", u.Scheme)
+ return nil, fmt.Errorf("could not find protocol handler for: %s", u.Scheme)
}
return &ChartRepository{
@@ -79,40 +73,6 @@ func NewChartRepository(cfg *Entry, getters getter.Providers) (*ChartRepository,
}, nil
}
-// Load loads a directory of charts as if it were a repository.
-//
-// It requires the presence of an index.yaml file in the directory.
-//
-// Deprecated: remove in Helm 4.
-func (r *ChartRepository) Load() error {
- dirInfo, err := os.Stat(r.Config.Name)
- if err != nil {
- return err
- }
- if !dirInfo.IsDir() {
- return errors.Errorf("%q is not a directory", r.Config.Name)
- }
-
- // FIXME: Why are we recursively walking directories?
- // FIXME: Why are we not reading the repositories.yaml to figure out
- // what repos to use?
- filepath.Walk(r.Config.Name, func(path string, f os.FileInfo, err error) error {
- if !f.IsDir() {
- if strings.Contains(f.Name(), "-index.yaml") {
- i, err := LoadIndexFile(path)
- if err != nil {
- return err
- }
- r.IndexFile = i
- } else if strings.HasSuffix(f.Name(), ".tgz") {
- r.ChartPaths = append(r.ChartPaths, path)
- }
- }
- return nil
- })
- return nil
-}
-
// DownloadIndexFile fetches the index from a repository.
func (r *ChartRepository) DownloadIndexFile() (string, error) {
indexURL, err := ResolveReferenceURL(r.Config.URL, "index.yaml")
@@ -156,73 +116,65 @@ func (r *ChartRepository) DownloadIndexFile() (string, error) {
return fname, os.WriteFile(fname, index, 0644)
}
-// Index generates an index for the chart repository and writes an index.yaml file.
-func (r *ChartRepository) Index() error {
- err := r.generateIndex()
- if err != nil {
- return err
- }
- return r.saveIndexFile()
+type findChartInRepoURLOptions struct {
+ Username string
+ Password string
+ PassCredentialsAll bool
+ InsecureSkipTLSverify bool
+ CertFile string
+ KeyFile string
+ CAFile string
+ ChartVersion string
}
-func (r *ChartRepository) saveIndexFile() error {
- index, err := yaml.Marshal(r.IndexFile)
- if err != nil {
- return err
+type FindChartInRepoURLOption func(*findChartInRepoURLOptions)
+
+// WithChartVersion specifies the chart version to find
+func WithChartVersion(chartVersion string) FindChartInRepoURLOption {
+ return func(options *findChartInRepoURLOptions) {
+ options.ChartVersion = chartVersion
}
- return os.WriteFile(filepath.Join(r.Config.Name, indexPath), index, 0644)
}
-func (r *ChartRepository) generateIndex() error {
- for _, path := range r.ChartPaths {
- ch, err := loader.Load(path)
- if err != nil {
- return err
- }
-
- digest, err := provenance.DigestFile(path)
- if err != nil {
- return err
- }
-
- if !r.IndexFile.Has(ch.Name(), ch.Metadata.Version) {
- if err := r.IndexFile.MustAdd(ch.Metadata, path, r.Config.URL, digest); err != nil {
- return errors.Wrapf(err, "failed adding to %s to index", path)
- }
- }
- // TODO: If a chart exists, but has a different Digest, should we error?
+// WithUsernamePassword specifies the username/password credntials for the repository
+func WithUsernamePassword(username, password string) FindChartInRepoURLOption {
+ return func(options *findChartInRepoURLOptions) {
+ options.Username = username
+ options.Password = password
}
- r.IndexFile.SortEntries()
- return nil
}
-// FindChartInRepoURL finds chart in chart repository pointed by repoURL
-// without adding repo to repositories
-func FindChartInRepoURL(repoURL, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) {
- return FindChartInAuthRepoURL(repoURL, "", "", chartName, chartVersion, certFile, keyFile, caFile, getters)
+// WithPassCredentialsAll flags whether credentials should be passed on to other domains
+func WithPassCredentialsAll(passCredentialsAll bool) FindChartInRepoURLOption {
+ return func(options *findChartInRepoURLOptions) {
+ options.PassCredentialsAll = passCredentialsAll
+ }
}
-// FindChartInAuthRepoURL finds chart in chart repository pointed by repoURL
-// without adding repo to repositories, like FindChartInRepoURL,
-// but it also receives credentials for the chart repository.
-func FindChartInAuthRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, getters getter.Providers) (string, error) {
- return FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, false, getters)
+// WithClientTLS species the cert, key, and CA files for client mTLS
+func WithClientTLS(certFile, keyFile, caFile string) FindChartInRepoURLOption {
+ return func(options *findChartInRepoURLOptions) {
+ options.CertFile = certFile
+ options.KeyFile = keyFile
+ options.CAFile = caFile
+ }
}
-// FindChartInAuthAndTLSRepoURL finds chart in chart repository pointed by repoURL
-// without adding repo to repositories, like FindChartInRepoURL,
-// but it also receives credentials and TLS verify flag for the chart repository.
-// TODO Helm 4, FindChartInAuthAndTLSRepoURL should be integrated into FindChartInAuthRepoURL.
-func FindChartInAuthAndTLSRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify bool, getters getter.Providers) (string, error) {
- return FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile, insecureSkipTLSverify, false, getters)
+// WithInsecureSkipTLSverify skips TLS verification for repository communication
+func WithInsecureSkipTLSverify(insecureSkipTLSverify bool) FindChartInRepoURLOption {
+ return func(options *findChartInRepoURLOptions) {
+ options.InsecureSkipTLSverify = insecureSkipTLSverify
+ }
}
-// FindChartInAuthAndTLSAndPassRepoURL finds chart in chart repository pointed by repoURL
-// without adding repo to repositories, like FindChartInRepoURL,
-// but it also receives credentials, TLS verify flag, and if credentials should
-// be passed on to other domains.
-// TODO Helm 4, FindChartInAuthAndTLSAndPassRepoURL should be integrated into FindChartInAuthRepoURL.
-func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName, chartVersion, certFile, keyFile, caFile string, insecureSkipTLSverify, passCredentialsAll bool, getters getter.Providers) (string, error) {
+// 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)
+ }
// Download and write the index file to a temporary location
buf := make([]byte, 20)
@@ -231,14 +183,14 @@ func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName,
c := Entry{
URL: repoURL,
- Username: username,
- Password: password,
- PassCredentialsAll: passCredentialsAll,
- CertFile: certFile,
- KeyFile: keyFile,
- CAFile: caFile,
+ Username: opts.Username,
+ Password: opts.Password,
+ PassCredentialsAll: opts.PassCredentialsAll,
+ CertFile: opts.CertFile,
+ KeyFile: opts.KeyFile,
+ CAFile: opts.CAFile,
Name: name,
- InsecureSkipTLSverify: insecureSkipTLSverify,
+ InsecureSkipTLSverify: opts.InsecureSkipTLSverify,
}
r, err := NewChartRepository(&c, getters)
if err != nil {
@@ -246,7 +198,7 @@ func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName,
}
idx, err := r.DownloadIndexFile()
if err != nil {
- return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURL)
+ return "", fmt.Errorf("looks like %q is not a valid chart repository or cannot be reached: %w", repoURL, err)
}
defer func() {
os.RemoveAll(filepath.Join(r.CachePath, helmpath.CacheChartsFile(r.Config.Name)))
@@ -260,23 +212,26 @@ func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName,
}
errMsg := fmt.Sprintf("chart %q", chartName)
- if chartVersion != "" {
- errMsg = fmt.Sprintf("%s version %q", errMsg, chartVersion)
+ if opts.ChartVersion != "" {
+ errMsg = fmt.Sprintf("%s version %q", errMsg, opts.ChartVersion)
}
- cv, err := repoIndex.Get(chartName, chartVersion)
+ cv, err := repoIndex.Get(chartName, opts.ChartVersion)
if err != nil {
- return "", errors.Errorf("%s not found in %s repository", errMsg, repoURL)
+ return "", ChartNotFoundError{
+ Chart: errMsg,
+ RepoURL: repoURL,
+ }
}
if len(cv.URLs) == 0 {
- return "", errors.Errorf("%s has no downloadable URLs", errMsg)
+ return "", fmt.Errorf("%s has no downloadable URLs", errMsg)
}
chartURL := cv.URLs[0]
absoluteChartURL, err := ResolveReferenceURL(repoURL, chartURL)
if err != nil {
- return "", errors.Wrap(err, "failed to make chart URL absolute")
+ return "", fmt.Errorf("failed to make chart URL absolute: %w", err)
}
return absoluteChartURL, nil
@@ -287,7 +242,7 @@ func FindChartInAuthAndTLSAndPassRepoURL(repoURL, username, password, chartName,
func ResolveReferenceURL(baseURL, refURL string) (string, error) {
parsedRefURL, err := url.Parse(refURL)
if err != nil {
- return "", errors.Wrapf(err, "failed to parse %s as URL", refURL)
+ return "", fmt.Errorf("failed to parse %s as URL: %w", refURL, err)
}
if parsedRefURL.IsAbs() {
@@ -296,7 +251,7 @@ func ResolveReferenceURL(baseURL, refURL string) (string, error) {
parsedBaseURL, err := url.Parse(baseURL)
if err != nil {
- return "", errors.Wrapf(err, "failed to parse %s as URL", baseURL)
+ return "", fmt.Errorf("failed to parse %s as URL: %w", baseURL, err)
}
// We need a trailing slash for ResolveReference to work, but make sure there isn't already one
@@ -311,7 +266,8 @@ func ResolveReferenceURL(baseURL, refURL string) (string, error) {
func (e *Entry) String() string {
buf, err := json.Marshal(e)
if err != nil {
- log.Panic(err)
+ slog.Error("failed to marshal entry", slog.Any("error", err))
+ panic(err)
}
return string(buf)
}
diff --git a/pkg/repo/chartrepo_test.go b/pkg/repo/chartrepo_test.go
index 4d4395c2d..05e034dd8 100644
--- a/pkg/repo/chartrepo_test.go
+++ b/pkg/repo/chartrepo_test.go
@@ -18,11 +18,10 @@ package repo
import (
"bytes"
+ "errors"
"net/http"
"net/http/httptest"
"os"
- "path/filepath"
- "reflect"
"runtime"
"strings"
"testing"
@@ -30,92 +29,15 @@ import (
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
)
-const (
- testRepository = "testdata/repository"
- testURL = "http://example-charts.com"
-)
-
-func TestLoadChartRepository(t *testing.T) {
- r, err := NewChartRepository(&Entry{
- Name: testRepository,
- URL: testURL,
- }, getter.All(&cli.EnvSettings{}))
- if err != nil {
- t.Errorf("Problem creating chart repository from %s: %v", testRepository, err)
- }
-
- if err := r.Load(); err != nil {
- t.Errorf("Problem loading chart repository from %s: %v", testRepository, err)
- }
-
- paths := []string{
- filepath.Join(testRepository, "frobnitz-1.2.3.tgz"),
- filepath.Join(testRepository, "sprocket-1.1.0.tgz"),
- filepath.Join(testRepository, "sprocket-1.2.0.tgz"),
- filepath.Join(testRepository, "universe/zarthal-1.0.0.tgz"),
- }
-
- if r.Config.Name != testRepository {
- t.Errorf("Expected %s as Name but got %s", testRepository, r.Config.Name)
- }
-
- if !reflect.DeepEqual(r.ChartPaths, paths) {
- t.Errorf("Expected %#v but got %#v\n", paths, r.ChartPaths)
- }
-
- if r.Config.URL != testURL {
- t.Errorf("Expected url for chart repository to be %s but got %s", testURL, r.Config.URL)
- }
-}
-
-func TestIndex(t *testing.T) {
- r, err := NewChartRepository(&Entry{
- Name: testRepository,
- URL: testURL,
- }, getter.All(&cli.EnvSettings{}))
- if err != nil {
- t.Errorf("Problem creating chart repository from %s: %v", testRepository, err)
- }
-
- if err := r.Load(); err != nil {
- t.Errorf("Problem loading chart repository from %s: %v", testRepository, err)
- }
-
- err = r.Index()
- if err != nil {
- t.Errorf("Error performing index: %v\n", err)
- }
-
- tempIndexPath := filepath.Join(testRepository, indexPath)
- actual, err := LoadIndexFile(tempIndexPath)
- defer os.Remove(tempIndexPath) // clean up
- if err != nil {
- t.Errorf("Error loading index file %v", err)
- }
- verifyIndex(t, actual)
-
- // Re-index and test again.
- err = r.Index()
- if err != nil {
- t.Errorf("Error performing re-index: %s\n", err)
- }
- second, err := LoadIndexFile(tempIndexPath)
- if err != nil {
- t.Errorf("Error re-loading index file %v", err)
- }
- verifyIndex(t, second)
-}
-
type CustomGetter struct {
repoUrls []string
}
-func (g *CustomGetter) Get(href string, options ...getter.Option) (*bytes.Buffer, error) {
+func (g *CustomGetter) Get(href string, _ ...getter.Option) (*bytes.Buffer, error) {
index := &IndexFile{
APIVersion: "v1",
Generated: time.Now(),
@@ -132,7 +54,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) {
repoName := "gcs-repo"
repoURL := "gs://some-gcs-bucket"
myCustomGetter := &CustomGetter{}
- customGetterConstructor := func(options ...getter.Option) (getter.Getter, error) {
+ customGetterConstructor := func(_ ...getter.Option) (getter.Getter, error) {
return myCustomGetter, nil
}
providers := getter.Providers{{
@@ -148,7 +70,7 @@ func TestIndexCustomSchemeDownload(t *testing.T) {
}
repo.CachePath = t.TempDir()
- tempIndexFile, err := os.CreateTemp("", "test-repo")
+ tempIndexFile, err := os.CreateTemp(t.TempDir(), "test-repo")
if err != nil {
t.Fatalf("Failed to create temp index file: %v", err)
}
@@ -169,97 +91,6 @@ func TestIndexCustomSchemeDownload(t *testing.T) {
}
}
-func verifyIndex(t *testing.T, actual *IndexFile) {
- var empty time.Time
- if actual.Generated.Equal(empty) {
- t.Errorf("Generated should be greater than 0: %s", actual.Generated)
- }
-
- if actual.APIVersion != APIVersionV1 {
- t.Error("Expected v1 API")
- }
-
- entries := actual.Entries
- if numEntries := len(entries); numEntries != 3 {
- t.Errorf("Expected 3 charts to be listed in index file but got %v", numEntries)
- }
-
- expects := map[string]ChartVersions{
- "frobnitz": {
- {
- Metadata: &chart.Metadata{
- Name: "frobnitz",
- Version: "1.2.3",
- },
- },
- },
- "sprocket": {
- {
- Metadata: &chart.Metadata{
- Name: "sprocket",
- Version: "1.2.0",
- },
- },
- {
- Metadata: &chart.Metadata{
- Name: "sprocket",
- Version: "1.1.0",
- },
- },
- },
- "zarthal": {
- {
- Metadata: &chart.Metadata{
- Name: "zarthal",
- Version: "1.0.0",
- },
- },
- },
- }
-
- for name, versions := range expects {
- got, ok := entries[name]
- if !ok {
- t.Errorf("Could not find %q entry", name)
- continue
- }
- if len(versions) != len(got) {
- t.Errorf("Expected %d versions, got %d", len(versions), len(got))
- continue
- }
- for i, e := range versions {
- g := got[i]
- if e.Name != g.Name {
- t.Errorf("Expected %q, got %q", e.Name, g.Name)
- }
- if e.Version != g.Version {
- t.Errorf("Expected %q, got %q", e.Version, g.Version)
- }
- if len(g.Keywords) != 3 {
- t.Error("Expected 3 keywords.")
- }
- if len(g.Maintainers) != 2 {
- t.Error("Expected 2 maintainers.")
- }
- if g.Created.Equal(empty) {
- t.Error("Expected created to be non-empty")
- }
- if g.Description == "" {
- t.Error("Expected description to be non-empty")
- }
- if g.Home == "" {
- t.Error("Expected home to be non-empty")
- }
- if g.Digest == "" {
- t.Error("Expected digest to be non-empty")
- }
- if len(g.URLs) != 1 {
- t.Error("Expected exactly 1 URL")
- }
- }
- }
-}
-
// startLocalServerForTests Start the local helm server
func startLocalServerForTests(handler http.Handler) (*httptest.Server, error) {
if handler == nil {
@@ -267,7 +98,7 @@ func startLocalServerForTests(handler http.Handler) (*httptest.Server, error) {
if err != nil {
return nil, err
}
- handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(fileBytes)
})
}
@@ -282,7 +113,7 @@ func startLocalTLSServerForTests(handler http.Handler) (*httptest.Server, error)
if err != nil {
return nil, err
}
- handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(fileBytes)
})
}
@@ -297,7 +128,12 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) {
}
defer srv.Close()
- chartURL, err := FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "", "", "", "", true, false, getter.All(&cli.EnvSettings{}))
+ chartURL, err := FindChartInRepoURL(
+ srv.URL,
+ "nginx",
+ getter.All(&cli.EnvSettings{}),
+ WithInsecureSkipTLSverify(true),
+ )
if err != nil {
t.Fatalf("%v", err)
}
@@ -305,8 +141,8 @@ func TestFindChartInAuthAndTLSAndPassRepoURL(t *testing.T) {
t.Errorf("%s is not the valid URL", chartURL)
}
- // If the insecureSkipTLsverify is false, it will return an error that contains "x509: certificate signed by unknown authority".
- _, err = FindChartInAuthAndTLSAndPassRepoURL(srv.URL, "", "", "nginx", "0.1.0", "", "", "", false, false, getter.All(&cli.EnvSettings{}))
+ // If the insecureSkipTLSVerify is false, it will return an error that contains "x509: certificate signed by unknown authority".
+ _, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0"))
// Go communicates with the platform and different platforms return different messages. Go itself tests darwin
// differently for its message. On newer versions of Darwin the message includes the "Acme Co" portion while older
// versions of Darwin do not. As there are people developing Helm using both old and new versions of Darwin we test
@@ -327,7 +163,7 @@ func TestFindChartInRepoURL(t *testing.T) {
}
defer srv.Close()
- chartURL, err := FindChartInRepoURL(srv.URL, "nginx", "", "", "", "", getter.All(&cli.EnvSettings{}))
+ chartURL, err := FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}))
if err != nil {
t.Fatalf("%v", err)
}
@@ -335,7 +171,7 @@ func TestFindChartInRepoURL(t *testing.T) {
t.Errorf("%s is not the valid URL", chartURL)
}
- chartURL, err = FindChartInRepoURL(srv.URL, "nginx", "0.1.0", "", "", "", getter.All(&cli.EnvSettings{}))
+ chartURL, err = FindChartInRepoURL(srv.URL, "nginx", getter.All(&cli.EnvSettings{}), WithChartVersion("0.1.0"))
if err != nil {
t.Errorf("%s", err)
}
@@ -350,7 +186,7 @@ func TestErrorFindChartInRepoURL(t *testing.T) {
RepositoryCache: t.TempDir(),
})
- if _, err := FindChartInRepoURL("http://someserver/something", "nginx", "", "", "", "", g); err == nil {
+ if _, err := FindChartInRepoURL("http://someserver/something", "nginx", g); err == nil {
t.Errorf("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)
@@ -362,19 +198,22 @@ func TestErrorFindChartInRepoURL(t *testing.T) {
}
defer srv.Close()
- if _, err = FindChartInRepoURL(srv.URL, "nginx1", "", "", "", "", g); err == nil {
+ if _, err = FindChartInRepoURL(srv.URL, "nginx1", g); err == nil {
t.Errorf("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")
+ }
- if _, err = FindChartInRepoURL(srv.URL, "nginx1", "0.1.0", "", "", "", g); err == nil {
+ 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")
} 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 {
+ if _, err = FindChartInRepoURL(srv.URL, "chartWithNoURL", g); err == nil {
t.Errorf("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)
@@ -385,11 +224,15 @@ func TestResolveReferenceURL(t *testing.T) {
for _, tt := range []struct {
baseURL, refURL, chartURL string
}{
+ {"http://localhost:8123/", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"},
{"http://localhost:8123/charts/", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz"},
+ {"http://localhost:8123/charts/", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"},
{"http://localhost:8123/charts-with-no-trailing-slash", "nginx-0.2.0.tgz", "http://localhost:8123/charts-with-no-trailing-slash/nginx-0.2.0.tgz"},
{"http://localhost:8123", "https://charts.helm.sh/stable/nginx-0.2.0.tgz", "https://charts.helm.sh/stable/nginx-0.2.0.tgz"},
{"http://localhost:8123/charts%2fwith%2fescaped%2fslash", "nginx-0.2.0.tgz", "http://localhost:8123/charts%2fwith%2fescaped%2fslash/nginx-0.2.0.tgz"},
+ {"http://localhost:8123/charts%2fwith%2fescaped%2fslash", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz"},
{"http://localhost:8123/charts?with=queryparameter", "nginx-0.2.0.tgz", "http://localhost:8123/charts/nginx-0.2.0.tgz?with=queryparameter"},
+ {"http://localhost:8123/charts?with=queryparameter", "/nginx-0.2.0.tgz", "http://localhost:8123/nginx-0.2.0.tgz?with=queryparameter"},
} {
chartURL, err := ResolveReferenceURL(tt.baseURL, tt.refURL)
if err != nil {
diff --git a/pkg/repo/error.go b/pkg/repo/error.go
new file mode 100644
index 000000000..16264ed26
--- /dev/null
+++ b/pkg/repo/error.go
@@ -0,0 +1,35 @@
+/*
+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 repo
+
+import (
+ "fmt"
+)
+
+type ChartNotFoundError struct {
+ RepoURL string
+ Chart string
+}
+
+func (e ChartNotFoundError) Error() string {
+ return fmt.Sprintf("%s not found in %s repository", e.Chart, e.RepoURL)
+}
+
+func (e ChartNotFoundError) Is(err error) bool {
+ _, ok := err.(ChartNotFoundError)
+ return ok
+}
diff --git a/pkg/repo/index.go b/pkg/repo/index.go
index 8a23ba060..4de8bb463 100644
--- a/pkg/repo/index.go
+++ b/pkg/repo/index.go
@@ -19,7 +19,9 @@ package repo
import (
"bytes"
"encoding/json"
- "log"
+ "errors"
+ "fmt"
+ "log/slog"
"os"
"path"
"path/filepath"
@@ -28,18 +30,15 @@ import (
"time"
"github.com/Masterminds/semver/v3"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/internal/fileutil"
- "helm.sh/helm/v3/internal/urlutil"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/provenance"
+ "helm.sh/helm/v4/internal/fileutil"
+ "helm.sh/helm/v4/internal/urlutil"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ "helm.sh/helm/v4/pkg/provenance"
)
-var indexPath = "index.yaml"
-
// APIVersionV1 is the v1 API version for index and repository files.
const APIVersionV1 = "v1"
@@ -110,7 +109,7 @@ func LoadIndexFile(path string) (*IndexFile, error) {
}
i, err := loadIndex(b, path)
if err != nil {
- return nil, errors.Wrapf(err, "error loading %s", path)
+ return nil, fmt.Errorf("error loading %s: %w", path, err)
}
return i, nil
}
@@ -126,7 +125,7 @@ func (i IndexFile) MustAdd(md *chart.Metadata, filename, baseURL, digest string)
md.APIVersion = chart.APIVersionV1
}
if err := md.Validate(); err != nil {
- return errors.Wrapf(err, "validate failed for %s", filename)
+ return fmt.Errorf("validate failed for %s: %w", filename, err)
}
u := filename
@@ -154,7 +153,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 {
- log.Printf("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 %q %q from %s: %s", md.Name, md.Version, filename, err)
}
}
@@ -200,7 +199,7 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) {
}
}
- // when customer input exact version, check whether have exact match one first
+ // when customer inputs specific version, check whether there's an exact match first
if len(version) != 0 {
for _, ver := range vs {
if version == ver.Version {
@@ -219,7 +218,7 @@ func (i IndexFile) Get(name, version string) (*ChartVersion, error) {
return ver, nil
}
}
- return nil, errors.Errorf("no chart version found for %s-%s", name, version)
+ return nil, fmt.Errorf("no chart version found for %s-%s", name, version)
}
// WriteFile writes an index file to the given destination path.
@@ -332,7 +331,7 @@ func IndexDirectory(dir, baseURL string) (*IndexFile, error) {
return index, err
}
if err := index.MustAdd(c.Metadata, fname, parentURL, hash); err != nil {
- return index, errors.Wrapf(err, "failed adding to %s to index", fname)
+ return index, fmt.Errorf("failed adding to %s to index: %w", fname, err)
}
}
return index, nil
@@ -356,17 +355,24 @@ 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 {
- log.Printf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)
+ slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, 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 cvs[idx].APIVersion == "" {
cvs[idx].APIVersion = chart.APIVersionV1
}
- if err := cvs[idx].Validate(); err != nil {
- log.Printf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)
+ 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))
cvs = append(cvs[:idx], cvs[idx+1:]...)
}
}
+ // adjust slice to only contain a set of valid versions
+ i.Entries[name] = cvs
}
i.SortEntries()
if i.APIVersion == "" {
@@ -388,3 +394,23 @@ func jsonOrYamlUnmarshal(b []byte, i interface{}) error {
}
return yaml.UnmarshalStrict(b, i)
}
+
+// ignoreSkippableChartValidationError inspect the given error and returns nil if
+// the error isn't important for index loading
+//
+// In particular, charts may introduce validations that don't impact repository indexes
+// And repository indexes may be generated by older/non-compliant software, which doesn't
+// conform to all validations.
+func ignoreSkippableChartValidationError(err error) error {
+ verr, ok := err.(chart.ValidationError)
+ if !ok {
+ return err
+ }
+
+ // https://github.com/helm/helm/issues/12748 (JFrog repository strips alias field)
+ if strings.HasPrefix(verr.Error(), "validation: more than one dependency with name or alias") {
+ return nil
+ }
+
+ return err
+}
diff --git a/pkg/repo/index_test.go b/pkg/repo/index_test.go
index efb50ba6a..a8aadadec 100644
--- a/pkg/repo/index_test.go
+++ b/pkg/repo/index_test.go
@@ -20,6 +20,7 @@ import (
"bufio"
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"os"
"path/filepath"
@@ -27,10 +28,10 @@ import (
"strings"
"testing"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/cli"
- "helm.sh/helm/v3/pkg/getter"
- "helm.sh/helm/v3/pkg/helmpath"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/cli"
+ "helm.sh/helm/v4/pkg/getter"
+ "helm.sh/helm/v4/pkg/helmpath"
)
const (
@@ -67,8 +68,13 @@ entries:
grafana:
- apiVersion: v2
name: grafana
+ - null
foo:
-
+ bar:
+ - digest: "sha256:1234567890abcdef"
+ urls:
+ - https://charts.helm.sh/stable/alpine-1.0.0.tgz
`
)
@@ -118,17 +124,17 @@ func TestIndexFile(t *testing.T) {
}
cv, err := i.Get("setter", "0.1.9")
- if err == nil && !strings.Contains(cv.Metadata.Version, "0.1.9") {
- t.Errorf("Unexpected version: %s", cv.Metadata.Version)
+ if err == nil && !strings.Contains(cv.Version, "0.1.9") {
+ t.Errorf("Unexpected version: %s", cv.Version)
}
cv, err = i.Get("setter", "0.1.9+alpha")
- if err != nil || cv.Metadata.Version != "0.1.9+alpha" {
+ if err != nil || cv.Version != "0.1.9+alpha" {
t.Errorf("Expected version: 0.1.9+alpha")
}
cv, err = i.Get("setter", "0.1.8")
- if err != nil || cv.Metadata.Version != "0.1.8" {
+ if err != nil || cv.Version != "0.1.8" {
t.Errorf("Expected version: 0.1.8")
}
}
@@ -154,7 +160,6 @@ func TestLoadIndex(t *testing.T) {
}
for _, tc := range tests {
- tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
i, err := LoadIndexFile(tc.Filename)
@@ -347,6 +352,7 @@ func TestDownloadIndexFile(t *testing.T) {
}
func verifyLocalIndex(t *testing.T, i *IndexFile) {
+ t.Helper()
numEntries := len(i.Entries)
if numEntries != 3 {
t.Errorf("Expected 3 entries in index file but got %d", numEntries)
@@ -445,7 +451,8 @@ func verifyLocalIndex(t *testing.T, i *IndexFile) {
}
func verifyLocalChartsFile(t *testing.T, chartsContent []byte, indexContent *IndexFile) {
- var expected, real []string
+ t.Helper()
+ var expected, reald []string
for chart := range indexContent.Entries {
expected = append(expected, chart)
}
@@ -453,12 +460,12 @@ func verifyLocalChartsFile(t *testing.T, chartsContent []byte, indexContent *Ind
scanner := bufio.NewScanner(bytes.NewReader(chartsContent))
for scanner.Scan() {
- real = append(real, scanner.Text())
+ reald = append(reald, scanner.Text())
}
- sort.Strings(real)
+ sort.Strings(reald)
- if strings.Join(expected, " ") != strings.Join(real, " ") {
- t.Errorf("Cached charts file content unexpected. Expected:\n%s\ngot:\n%s", expected, real)
+ if strings.Join(expected, " ") != strings.Join(reald, " ") {
+ t.Errorf("Cached charts file content unexpected. Expected:\n%s\ngot:\n%s", expected, reald)
}
}
@@ -592,3 +599,124 @@ func TestAddFileIndexEntriesNil(t *testing.T) {
}
}
}
+
+func TestIgnoreSkippableChartValidationError(t *testing.T) {
+ type TestCase struct {
+ Input error
+ ErrorSkipped bool
+ }
+ testCases := map[string]TestCase{
+ "nil": {
+ Input: nil,
+ },
+ "generic_error": {
+ Input: fmt.Errorf("foo"),
+ },
+ "non_skipped_validation_error": {
+ Input: chart.ValidationError("chart.metadata.type must be application or library"),
+ },
+ "skipped_validation_error": {
+ Input: chart.ValidationErrorf("more than one dependency with name or alias %q", "foo"),
+ ErrorSkipped: true,
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ result := ignoreSkippableChartValidationError(tc.Input)
+
+ if tc.Input == nil {
+ if result != nil {
+ t.Error("expected nil result for nil input")
+ }
+ return
+ }
+
+ if tc.ErrorSkipped {
+ if result != nil {
+ t.Error("expected nil result for skipped error")
+ }
+ return
+ }
+
+ if tc.Input != result {
+ t.Error("expected the result equal to input")
+ }
+
+ })
+ }
+}
+
+var indexWithDuplicatesInChartDeps = `
+apiVersion: v1
+entries:
+ nginx:
+ - urls:
+ - https://charts.helm.sh/stable/alpine-1.0.0.tgz
+ - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz
+ name: alpine
+ description: string
+ home: https://github.com/something
+ digest: "sha256:1234567890abcdef"
+ - urls:
+ - https://charts.helm.sh/stable/nginx-0.2.0.tgz
+ name: nginx
+ description: string
+ version: 0.2.0
+ home: https://github.com/something/else
+ digest: "sha256:1234567890abcdef"
+`
+var indexWithDuplicatesInLastChartDeps = `
+apiVersion: v1
+entries:
+ nginx:
+ - urls:
+ - https://charts.helm.sh/stable/nginx-0.2.0.tgz
+ name: nginx
+ description: string
+ version: 0.2.0
+ home: https://github.com/something/else
+ digest: "sha256:1234567890abcdef"
+ - urls:
+ - https://charts.helm.sh/stable/alpine-1.0.0.tgz
+ - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz
+ name: alpine
+ description: string
+ home: https://github.com/something
+ digest: "sha256:111"
+`
+
+func TestLoadIndex_DuplicateChartDeps(t *testing.T) {
+ tests := []struct {
+ source string
+ data string
+ }{
+ {
+ source: "indexWithDuplicatesInChartDeps",
+ data: indexWithDuplicatesInChartDeps,
+ },
+ {
+ source: "indexWithDuplicatesInLastChartDeps",
+ data: indexWithDuplicatesInLastChartDeps,
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.source, func(t *testing.T) {
+ idx, err := loadIndex([]byte(tc.data), tc.source)
+ if err != nil {
+ t.Fatalf("unexpected error: %s", err)
+ }
+ cvs := idx.Entries["nginx"]
+ if cvs == nil {
+ if err != nil {
+ t.Error("expected one chart version not to be filtered out")
+ }
+ }
+ for _, v := range cvs {
+ if v.Name == "alpine" {
+ t.Error("malformed version was not filtered out")
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go
index 834d554bd..48c0e0193 100644
--- a/pkg/repo/repo.go
+++ b/pkg/repo/repo.go
@@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package repo // import "helm.sh/helm/v3/pkg/repo"
+package repo // import "helm.sh/helm/v4/pkg/repo"
import (
+ "fmt"
"os"
"path/filepath"
"time"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
)
@@ -48,7 +48,7 @@ func LoadFile(path string) (*File, error) {
r := new(File)
b, err := os.ReadFile(path)
if err != nil {
- return r, errors.Wrapf(err, "couldn't load repositories file (%s)", path)
+ return r, fmt.Errorf("couldn't load repositories file (%s): %w", path, err)
}
err = yaml.Unmarshal(b, r)
diff --git a/pkg/repo/repo_test.go b/pkg/repo/repo_test.go
index c2087ebbe..bdaa61eda 100644
--- a/pkg/repo/repo_test.go
+++ b/pkg/repo/repo_test.go
@@ -197,7 +197,7 @@ func TestWriteFile(t *testing.T) {
},
)
- file, err := os.CreateTemp("", "helm-repo")
+ file, err := os.CreateTemp(t.TempDir(), "helm-repo")
if err != nil {
t.Errorf("failed to create test-file (%v)", err)
}
diff --git a/pkg/repo/repotest/server.go b/pkg/repo/repotest/server.go
index d9a5201aa..8f9f82281 100644
--- a/pkg/repo/repotest/server.go
+++ b/pkg/repo/repotest/server.go
@@ -16,8 +16,9 @@ limitations under the License.
package repotest
import (
- "context"
+ "crypto/tls"
"fmt"
+ "net"
"net/http"
"net/http/httptest"
"os"
@@ -29,46 +30,112 @@ 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/phayes/freeport"
"golang.org/x/crypto/bcrypt"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/internal/tlsutil"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/chartutil"
- ociRegistry "helm.sh/helm/v3/pkg/registry"
- "helm.sh/helm/v3/pkg/repo"
+ chart "helm.sh/helm/v4/pkg/chart/v2"
+ "helm.sh/helm/v4/pkg/chart/v2/loader"
+ chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
+ ociRegistry "helm.sh/helm/v4/pkg/registry"
+ "helm.sh/helm/v4/pkg/repo"
)
-// NewTempServerWithCleanup creates a server inside of a temp dir.
+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)
+ }
+ })
+}
+
+type ServerOption func(*testing.T, *Server)
+
+func WithTLSConfig(tlsConfig *tls.Config) ServerOption {
+ return func(_ *testing.T, server *Server) {
+ server.tlsConfig = tlsConfig
+ }
+}
+
+func WithMiddleware(middleware http.HandlerFunc) ServerOption {
+ return func(_ *testing.T, server *Server) {
+ server.middleware = middleware
+ }
+}
+
+func WithChartSourceGlob(glob string) ServerOption {
+ return func(_ *testing.T, server *Server) {
+ server.chartSourceGlob = glob
+ }
+}
+
+// Server is an implementation of a repository server for testing.
+type Server struct {
+ docroot string
+ srv *httptest.Server
+ middleware http.HandlerFunc
+ tlsConfig *tls.Config
+ chartSourceGlob string
+}
+
+// NewTempServer creates a server inside of a temp dir.
//
// If the passed in string is not "", it will be treated as a shell glob, and files
// will be copied from that path to the server's docroot.
//
-// The caller is responsible for stopping the server.
+// The server is started automatically. The caller is responsible for stopping
+// the server.
+//
// The temp dir will be removed by testing package automatically when test finished.
-func NewTempServerWithCleanup(t *testing.T, glob string) (*Server, error) {
- srv, err := NewTempServer(glob)
+func NewTempServer(t *testing.T, options ...ServerOption) *Server {
+ t.Helper()
+ docrootTempDir := t.TempDir()
+
+ srv := newServer(t, docrootTempDir, options...)
+
t.Cleanup(func() { os.RemoveAll(srv.docroot) })
- return srv, err
+
+ if srv.chartSourceGlob != "" {
+ if _, err := srv.CopyCharts(srv.chartSourceGlob); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ return srv
}
-// Set up a fake repo with basic auth enabled
-func NewTempServerWithCleanupAndBasicAuth(t *testing.T, glob string) *Server {
- srv, err := NewTempServerWithCleanup(t, glob)
- srv.Stop()
+// Create the server, but don't yet start it
+func newServer(t *testing.T, docroot string, options ...ServerOption) *Server {
+ t.Helper()
+ absdocroot, err := filepath.Abs(docroot)
if err != nil {
t.Fatal(err)
}
- srv.WithMiddleware(http.HandlerFunc(func(w 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)
+
+ s := &Server{
+ docroot: absdocroot,
+ }
+
+ for _, option := range options {
+ option(t, s)
+ }
+
+ s.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if s.middleware != nil {
+ s.middleware.ServeHTTP(w, r)
}
+ http.FileServer(http.Dir(s.Root())).ServeHTTP(w, r)
}))
- srv.Start()
- return srv
+
+ 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)
+ }
+
+ return s
}
type OCIServer struct {
@@ -93,6 +160,7 @@ func WithDependingChart(c *chart.Chart) OCIServerOpt {
}
func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) {
+ t.Helper()
testHtpasswdFileBasename := "authtest.htpasswd"
testUsername, testPassword := "username", "password"
@@ -101,19 +169,21 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) {
t.Fatal("error generating bcrypt password for test htpasswd file")
}
htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename)
- err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644)
+ err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0o644)
if err != nil {
t.Fatalf("error creating test htpasswd file")
}
// Registry config
config := &configuration.Configuration{}
- port, err := freeport.GetFreePort()
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("error finding free port for test registry")
}
+ defer ln.Close()
- config.HTTP.Addr = fmt.Sprintf(":%d", port)
+ 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.Auth = configuration.Auth{
@@ -125,7 +195,7 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) {
registryURL := fmt.Sprintf("localhost:%d", port)
- r, err := registry.NewRegistry(context.Background(), config)
+ r, err := registry.NewRegistry(t.Context(), config)
if err != nil {
t.Fatal(err)
}
@@ -140,6 +210,7 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) {
}
func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) {
+ t.Helper()
cfg := &OCIServerRunConfig{}
for _, fn := range opts {
fn(cfg)
@@ -163,9 +234,10 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) {
err = registryClient.Login(
srv.RegistryURL,
ociRegistry.LoginOptBasicAuth(srv.TestUsername, srv.TestPassword),
- ociRegistry.LoginOptInsecure(false))
+ ociRegistry.LoginOptInsecure(true),
+ ociRegistry.LoginOptPlainText(true))
if err != nil {
- t.Fatalf("error logging into registry with good credentials")
+ t.Fatalf("error logging into registry with good credentials: %v", err)
}
ref := fmt.Sprintf("%s/u/ocitestuser/oci-dependent-chart:0.1.0", srv.RegistryURL)
@@ -238,69 +310,6 @@ func (srv *OCIServer) Run(t *testing.T, opts ...OCIServerOpt) {
result.Chart.Digest, result.Chart.Size)
}
-// NewTempServer creates a server inside of a temp dir.
-//
-// If the passed in string is not "", it will be treated as a shell glob, and files
-// will be copied from that path to the server's docroot.
-//
-// The caller is responsible for destroying the temp directory as well as stopping
-// the server.
-//
-// Deprecated: use NewTempServerWithCleanup
-func NewTempServer(glob string) (*Server, error) {
- tdir, err := os.MkdirTemp("", "helm-repotest-")
- if err != nil {
- return nil, err
- }
- srv := NewServer(tdir)
-
- if glob != "" {
- if _, err := srv.CopyCharts(glob); err != nil {
- srv.Stop()
- return srv, err
- }
- }
-
- return srv, nil
-}
-
-// NewServer creates a repository server for testing.
-//
-// docroot should be a temp dir managed by the caller.
-//
-// This will start the server, serving files off of the docroot.
-//
-// Use CopyCharts to move charts into the repository and then index them
-// for service.
-func NewServer(docroot string) *Server {
- root, err := filepath.Abs(docroot)
- if err != nil {
- panic(err)
- }
- srv := &Server{
- docroot: root,
- }
- srv.Start()
- // Add the testing repository as the only repo.
- if err := setTestingRepository(srv.URL(), filepath.Join(root, "repositories.yaml")); err != nil {
- panic(err)
- }
- return srv
-}
-
-// Server is an implementation of a repository server for testing.
-type Server struct {
- docroot string
- srv *httptest.Server
- middleware http.HandlerFunc
-}
-
-// WithMiddleware injects middleware in front of the server. This can be used to inject
-// additional functionality like layering in an authentication frontend.
-func (s *Server) WithMiddleware(middleware http.HandlerFunc) {
- s.middleware = middleware
-}
-
// Root gets the docroot for the server.
func (s *Server) Root() string {
return s.docroot
@@ -320,7 +329,7 @@ func (s *Server) CopyCharts(origin string) ([]string, error) {
if err != nil {
return []string{}, err
}
- if err := os.WriteFile(newname, data, 0644); err != nil {
+ if err := os.WriteFile(newname, data, 0o644); err != nil {
return []string{}, err
}
copied[i] = newname
@@ -344,49 +353,15 @@ func (s *Server) CreateIndex() error {
}
ifile := filepath.Join(s.docroot, "index.yaml")
- return os.WriteFile(ifile, d, 0644)
-}
-
-func (s *Server) Start() {
- s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if s.middleware != nil {
- s.middleware.ServeHTTP(w, r)
- }
- http.FileServer(http.Dir(s.docroot)).ServeHTTP(w, r)
- }))
+ return os.WriteFile(ifile, d, 0o644)
}
-func (s *Server) StartTLS() {
- cd := "../../testdata"
- ca, pub, priv := filepath.Join(cd, "rootca.crt"), filepath.Join(cd, "crt.pem"), filepath.Join(cd, "key.pem")
- insecure := false
-
- s.srv = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if s.middleware != nil {
- s.middleware.ServeHTTP(w, r)
- }
- http.FileServer(http.Dir(s.Root())).ServeHTTP(w, r)
- }))
- tlsConf, err := tlsutil.NewClientTLS(pub, priv, ca, insecure)
- if err != nil {
- panic(err)
- }
- tlsConf.ServerName = "helm.sh"
- s.srv.TLS = tlsConf
- s.srv.StartTLS()
-
- // Set up repositories config with ca file
- repoConfig := filepath.Join(s.Root(), "repositories.yaml")
-
- r := repo.NewFile()
- r.Add(&repo.Entry{
- Name: "test",
- URL: s.URL(),
- CAFile: filepath.Join("../../testdata", "rootca.crt"),
- })
-
- if err := r.WriteFile(repoConfig, 0600); err != nil {
- panic(err)
+func (s *Server) start() {
+ if s.tlsConfig != nil {
+ s.srv.TLS = s.tlsConfig
+ s.srv.StartTLS()
+ } else {
+ s.srv.Start()
}
}
@@ -406,6 +381,10 @@ func (s *Server) URL() string {
return s.srv.URL
}
+func (s *Server) Client() *http.Client {
+ return s.srv.Client()
+}
+
// LinkIndices links the index created with CreateIndex and makes a symbolic link to the cache index.
//
// This makes it possible to simulate a local cache of a repository.
@@ -417,10 +396,14 @@ func (s *Server) LinkIndices() error {
// setTestingRepository sets up a testing repository.yaml with only the given URL.
func setTestingRepository(url, fname string) error {
+ if url == "" {
+ panic("no url")
+ }
+
r := repo.NewFile()
r.Add(&repo.Entry{
Name: "test",
URL: url,
})
- return r.WriteFile(fname, 0640)
+ return r.WriteFile(fname, 0o640)
}
diff --git a/pkg/repo/repotest/server_test.go b/pkg/repo/repotest/server_test.go
index a7d7f5b95..4d62ef8ed 100644
--- a/pkg/repo/repotest/server_test.go
+++ b/pkg/repo/repotest/server_test.go
@@ -19,12 +19,13 @@ import (
"io"
"net/http"
"path/filepath"
+ "strings"
"testing"
"sigs.k8s.io/yaml"
- "helm.sh/helm/v3/internal/test/ensure"
- "helm.sh/helm/v3/pkg/repo"
+ "helm.sh/helm/v4/internal/test/ensure"
+ "helm.sh/helm/v4/pkg/repo"
)
// Young'n, in these here parts, we test our tests.
@@ -34,7 +35,7 @@ func TestServer(t *testing.T) {
rootDir := t.TempDir()
- srv := NewServer(rootDir)
+ srv := newServer(t, rootDir)
defer srv.Stop()
c, err := srv.CopyCharts("testdata/*.tgz")
@@ -91,7 +92,7 @@ func TestServer(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if res.StatusCode != 404 {
+ if res.StatusCode != http.StatusNotFound {
t.Fatalf("Expected 404, got %d", res.StatusCode)
}
}
@@ -99,18 +100,123 @@ func TestServer(t *testing.T) {
func TestNewTempServer(t *testing.T) {
ensure.HelmHome(t)
- srv, err := NewTempServerWithCleanup(t, "testdata/examplechart-0.1.0.tgz")
- if err != nil {
- t.Fatal(err)
+ type testCase struct {
+ options []ServerOption
}
- defer srv.Stop()
- res, err := http.Head(srv.URL() + "/examplechart-0.1.0.tgz")
- res.Body.Close()
- if err != nil {
- t.Error(err)
+ testCases := map[string]testCase{
+ "plainhttp": {
+ options: []ServerOption{
+ WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"),
+ },
+ },
+ "tls": {
+ options: []ServerOption{
+ WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"),
+ WithTLSConfig(MakeTestTLSConfig(t, "../../../testdata")),
+ },
+ },
+ }
+
+ for name, tc := range testCases {
+ t.Run(name, func(t *testing.T) {
+ srv := NewTempServer(
+ t,
+ tc.options...,
+ )
+ defer srv.Stop()
+
+ if srv.srv.URL == "" {
+ t.Fatal("unstarted server")
+ }
+
+ client := srv.Client()
+
+ {
+ res, err := client.Head(srv.URL() + "/repositories.yaml")
+ if err != nil {
+ t.Error(err)
+ }
+
+ res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Expected 200, got %d", res.StatusCode)
+ }
+
+ }
+
+ {
+ res, err := client.Head(srv.URL() + "/examplechart-0.1.0.tgz")
+ if err != nil {
+ t.Error(err)
+ }
+ res.Body.Close()
+
+ if res.StatusCode != http.StatusOK {
+ t.Errorf("Expected 200, got %d", res.StatusCode)
+ }
+ }
+
+ res, err := client.Get(srv.URL() + "/examplechart-0.1.0.tgz")
+ 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)
+ }
+
+ data, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ t.Fatal(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)
+ }
+
+ expect := "examplechart"
+ if !m.Has(expect, "0.1.0") {
+ t.Errorf("missing %q", expect)
+ }
+
+ res, err = client.Get(srv.URL() + "/index.yaml-nosuchthing")
+ res.Body.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.StatusCode != http.StatusNotFound {
+ t.Fatalf("Expected 404, got %d", res.StatusCode)
+ }
+ })
}
- if res.StatusCode != 200 {
- t.Errorf("Expected 200, got %d", res.StatusCode)
+
+}
+
+func TestNewTempServer_TLS(t *testing.T) {
+ ensure.HelmHome(t)
+
+ srv := NewTempServer(
+ t,
+ WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"),
+ WithTLSConfig(MakeTestTLSConfig(t, "../../../testdata")),
+ )
+ defer srv.Stop()
+
+ if !strings.HasPrefix(srv.URL(), "https://") {
+ t.Fatal("non-TLS server")
}
}
diff --git a/pkg/repo/repotest/tlsconfig.go b/pkg/repo/repotest/tlsconfig.go
new file mode 100644
index 000000000..3ea7338ff
--- /dev/null
+++ b/pkg/repo/repotest/tlsconfig.go
@@ -0,0 +1,44 @@
+/*
+Copyright The Helm Authors.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package repotest
+
+import (
+ "crypto/tls"
+ "path/filepath"
+ "testing"
+
+ "helm.sh/helm/v4/internal/tlsutil"
+
+ "github.com/stretchr/testify/require"
+)
+
+func MakeTestTLSConfig(t *testing.T, path string) *tls.Config {
+ t.Helper()
+ ca, pub, priv := filepath.Join(path, "rootca.crt"), filepath.Join(path, "crt.pem"), filepath.Join(path, "key.pem")
+
+ insecure := false
+ tlsConf, err := tlsutil.NewTLSConfig(
+ tlsutil.WithInsecureSkipVerify(insecure),
+ tlsutil.WithCertKeyPairFiles(pub, priv),
+ tlsutil.WithCAFile(ca),
+ )
+ //require.Nil(t, err, err.Error())
+ require.Nil(t, err)
+
+ tlsConf.ServerName = "helm.sh"
+
+ return tlsConf
+}
diff --git a/pkg/storage/driver/cfgmaps.go b/pkg/storage/driver/cfgmaps.go
index 0f3ec38a0..de097f294 100644
--- a/pkg/storage/driver/cfgmaps.go
+++ b/pkg/storage/driver/cfgmaps.go
@@ -14,15 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"context"
+ "fmt"
+ "log/slog"
"strconv"
"strings"
"time"
- "github.com/pkg/errors"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -30,7 +31,7 @@ import (
"k8s.io/apimachinery/pkg/util/validation"
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var _ Driver = (*ConfigMaps)(nil)
@@ -42,7 +43,6 @@ const ConfigMapsDriverName = "ConfigMap"
// ConfigMapsInterface.
type ConfigMaps struct {
impl corev1.ConfigMapInterface
- Log func(string, ...interface{})
}
// NewConfigMaps initializes a new ConfigMaps wrapping an implementation of
@@ -50,7 +50,6 @@ type ConfigMaps struct {
func NewConfigMaps(impl corev1.ConfigMapInterface) *ConfigMaps {
return &ConfigMaps{
impl: impl,
- Log: func(_ string, _ ...interface{}) {},
}
}
@@ -69,16 +68,16 @@ func (cfgmaps *ConfigMaps) Get(key string) (*rspb.Release, error) {
return nil, ErrReleaseNotFound
}
- cfgmaps.Log("get: failed to get %q: %s", key, err)
+ slog.Debug("failed to get release", "key", key, slog.Any("error", err))
return nil, err
}
// found the configmap, decode the base64 data string
r, err := decodeRelease(obj.Data["release"])
if err != nil {
- cfgmaps.Log("get: failed to decode data %q: %s", key, err)
+ slog.Debug("failed to decode data", "key", key, slog.Any("error", err))
return nil, err
}
- r.Labels = filterSystemLabels(obj.ObjectMeta.Labels)
+ r.Labels = filterSystemLabels(obj.Labels)
// return the release object
return r, nil
}
@@ -92,7 +91,7 @@ func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Releas
list, err := cfgmaps.impl.List(context.Background(), opts)
if err != nil {
- cfgmaps.Log("list: failed to list: %s", err)
+ slog.Debug("failed to list releases", slog.Any("error", err))
return nil, err
}
@@ -103,11 +102,11 @@ func (cfgmaps *ConfigMaps) List(filter func(*rspb.Release) bool) ([]*rspb.Releas
for _, item := range list.Items {
rls, err := decodeRelease(item.Data["release"])
if err != nil {
- cfgmaps.Log("list: failed to decode release: %v: %s", item, err)
+ slog.Debug("failed to decode release", "item", item, slog.Any("error", err))
continue
}
- rls.Labels = filterSystemLabels(item.ObjectMeta.Labels)
+ rls.Labels = item.Labels
if filter(rls) {
results = append(results, rls)
@@ -122,7 +121,7 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err
ls := kblabels.Set{}
for k, v := range labels {
if errs := validation.IsValidLabelValue(v); len(errs) != 0 {
- return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; "))
+ return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; "))
}
ls[k] = v
}
@@ -131,7 +130,7 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err
list, err := cfgmaps.impl.List(context.Background(), opts)
if err != nil {
- cfgmaps.Log("query: failed to query with labels: %s", err)
+ slog.Debug("failed to query with labels", slog.Any("error", err))
return nil, err
}
@@ -143,10 +142,10 @@ func (cfgmaps *ConfigMaps) Query(labels map[string]string) ([]*rspb.Release, err
for _, item := range list.Items {
rls, err := decodeRelease(item.Data["release"])
if err != nil {
- cfgmaps.Log("query: failed to decode release: %s", err)
+ slog.Debug("failed to decode release", slog.Any("error", err))
continue
}
- rls.Labels = filterSystemLabels(item.ObjectMeta.Labels)
+ rls.Labels = item.Labels
results = append(results, rls)
}
return results, nil
@@ -160,12 +159,12 @@ func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error {
lbs.init()
lbs.fromMap(rls.Labels)
- lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix())))
+ lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix()))
// create a new configmap to hold the release
obj, err := newConfigMapsObject(key, rls, lbs)
if err != nil {
- cfgmaps.Log("create: failed to encode release %q: %s", rls.Name, err)
+ slog.Debug("failed to encode release", "name", rls.Name, slog.Any("error", err))
return err
}
// push the configmap object out into the kubiverse
@@ -174,7 +173,7 @@ func (cfgmaps *ConfigMaps) Create(key string, rls *rspb.Release) error {
return ErrReleaseExists
}
- cfgmaps.Log("create: failed to create: %s", err)
+ slog.Debug("failed to create release", slog.Any("error", err))
return err
}
return nil
@@ -188,18 +187,18 @@ func (cfgmaps *ConfigMaps) Update(key string, rls *rspb.Release) error {
lbs.init()
lbs.fromMap(rls.Labels)
- lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix())))
+ lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix()))
// create a new configmap object to hold the release
obj, err := newConfigMapsObject(key, rls, lbs)
if err != nil {
- cfgmaps.Log("update: failed to encode release %q: %s", rls.Name, err)
+ slog.Debug("failed to encode release", "name", rls.Name, slog.Any("error", err))
return err
}
// push the configmap object out into the kubiverse
_, err = cfgmaps.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
if err != nil {
- cfgmaps.Log("update: failed to update: %s", err)
+ slog.Debug("failed to update release", slog.Any("error", err))
return err
}
return nil
diff --git a/pkg/storage/driver/cfgmaps_test.go b/pkg/storage/driver/cfgmaps_test.go
index 626c36cb9..a563eb7d9 100644
--- a/pkg/storage/driver/cfgmaps_test.go
+++ b/pkg/storage/driver/cfgmaps_test.go
@@ -16,12 +16,13 @@ package driver
import (
"encoding/base64"
"encoding/json"
+ "errors"
"reflect"
"testing"
v1 "k8s.io/api/core/v1"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestConfigMapName(t *testing.T) {
@@ -128,6 +129,16 @@ func TestConfigMapList(t *testing.T) {
if len(ssd) != 2 {
t.Errorf("Expected 2 superseded, got %d", len(ssd))
}
+ // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work.
+ rls := ssd[0]
+ _, ok := rls.Labels["name"]
+ if !ok {
+ t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels)
+ }
+ _, ok = rls.Labels["key1"]
+ if !ok {
+ t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels)
+ }
}
func TestConfigMapQuery(t *testing.T) {
@@ -232,10 +243,8 @@ func TestConfigMapDelete(t *testing.T) {
if !reflect.DeepEqual(rel, rls) {
t.Errorf("Expected {%v}, got {%v}", rel, rls)
}
-
- // fetch the deleted release
_, err = cfgmaps.Get(key)
- if !reflect.DeepEqual(ErrReleaseNotFound, err) {
+ if !errors.Is(err, ErrReleaseNotFound) {
t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err)
}
}
diff --git a/pkg/storage/driver/driver.go b/pkg/storage/driver/driver.go
index 9c01f3766..4f9d63928 100644
--- a/pkg/storage/driver/driver.go
+++ b/pkg/storage/driver/driver.go
@@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
+ "errors"
"fmt"
- "github.com/pkg/errors"
-
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var (
diff --git a/pkg/storage/driver/labels_test.go b/pkg/storage/driver/labels_test.go
index bfd80911b..81e561c15 100644
--- a/pkg/storage/driver/labels_test.go
+++ b/pkg/storage/driver/labels_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"testing"
diff --git a/pkg/storage/driver/memory.go b/pkg/storage/driver/memory.go
index 91378f588..79e7f090e 100644
--- a/pkg/storage/driver/memory.go
+++ b/pkg/storage/driver/memory.go
@@ -21,7 +21,7 @@ import (
"strings"
"sync"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var _ Driver = (*Memory)(nil)
diff --git a/pkg/storage/driver/memory_test.go b/pkg/storage/driver/memory_test.go
index 7a2e8578e..ee547b58b 100644
--- a/pkg/storage/driver/memory_test.go
+++ b/pkg/storage/driver/memory_test.go
@@ -21,7 +21,7 @@ import (
"reflect"
"testing"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestMemoryName(t *testing.T) {
diff --git a/pkg/storage/driver/mock_test.go b/pkg/storage/driver/mock_test.go
index 7a1541a02..7dba5fea2 100644
--- a/pkg/storage/driver/mock_test.go
+++ b/pkg/storage/driver/mock_test.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"context"
@@ -31,7 +31,7 @@ import (
kblabels "k8s.io/apimachinery/pkg/labels"
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func releaseStub(name string, vers int, namespace string, status rspb.Status) *rspb.Release {
@@ -52,6 +52,7 @@ func testKey(name string, vers int) string {
}
func tsFixtureMemory(t *testing.T) *Memory {
+ t.Helper()
hs := []*rspb.Release{
// rls-a
releaseStub("rls-a", 4, "default", rspb.StatusDeployed),
@@ -80,9 +81,10 @@ func tsFixtureMemory(t *testing.T) *Memory {
return mem
}
-// newTestFixture initializes a MockConfigMapsInterface.
+// newTestFixtureCfgMaps initializes a MockConfigMapsInterface.
// ConfigMaps are created for each release provided.
func newTestFixtureCfgMaps(t *testing.T, releases ...*rspb.Release) *ConfigMaps {
+ t.Helper()
var mock MockConfigMapsInterface
mock.Init(t, releases...)
@@ -98,6 +100,7 @@ type MockConfigMapsInterface struct {
// Init initializes the MockConfigMapsInterface with the set of releases.
func (mock *MockConfigMapsInterface) Init(t *testing.T, releases ...*rspb.Release) {
+ t.Helper()
mock.objects = map[string]*v1.ConfigMap{}
for _, rls := range releases {
@@ -120,7 +123,7 @@ func (mock *MockConfigMapsInterface) Get(_ context.Context, name string, _ metav
return object, nil
}
-// List returns the a of ConfigMaps.
+// List returns all ConfigMaps.
func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.ConfigMapList, error) {
var list v1.ConfigMapList
@@ -130,7 +133,7 @@ func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOpt
}
for _, cfgmap := range mock.objects {
- if labelSelector.Matches(kblabels.Set(cfgmap.ObjectMeta.Labels)) {
+ if labelSelector.Matches(kblabels.Set(cfgmap.Labels)) {
list.Items = append(list.Items, *cfgmap)
}
}
@@ -139,7 +142,7 @@ func (mock *MockConfigMapsInterface) List(_ context.Context, opts metav1.ListOpt
// Create creates a new ConfigMap.
func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.CreateOptions) (*v1.ConfigMap, error) {
- name := cfgmap.ObjectMeta.Name
+ name := cfgmap.Name
if object, ok := mock.objects[name]; ok {
return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name)
}
@@ -149,7 +152,7 @@ func (mock *MockConfigMapsInterface) Create(_ context.Context, cfgmap *v1.Config
// Update updates a ConfigMap.
func (mock *MockConfigMapsInterface) Update(_ context.Context, cfgmap *v1.ConfigMap, _ metav1.UpdateOptions) (*v1.ConfigMap, error) {
- name := cfgmap.ObjectMeta.Name
+ name := cfgmap.Name
if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name)
}
@@ -166,9 +169,10 @@ func (mock *MockConfigMapsInterface) Delete(_ context.Context, name string, _ me
return nil
}
-// newTestFixture initializes a MockSecretsInterface.
+// newTestFixtureSecrets initializes a MockSecretsInterface.
// Secrets are created for each release provided.
func newTestFixtureSecrets(t *testing.T, releases ...*rspb.Release) *Secrets {
+ t.Helper()
var mock MockSecretsInterface
mock.Init(t, releases...)
@@ -184,6 +188,7 @@ type MockSecretsInterface struct {
// Init initializes the MockSecretsInterface with the set of releases.
func (mock *MockSecretsInterface) Init(t *testing.T, releases ...*rspb.Release) {
+ t.Helper()
mock.objects = map[string]*v1.Secret{}
for _, rls := range releases {
@@ -206,7 +211,7 @@ func (mock *MockSecretsInterface) Get(_ context.Context, name string, _ metav1.G
return object, nil
}
-// List returns the a of Secret.
+// List returns all Secrets.
func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOptions) (*v1.SecretList, error) {
var list v1.SecretList
@@ -216,7 +221,7 @@ func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOption
}
for _, secret := range mock.objects {
- if labelSelector.Matches(kblabels.Set(secret.ObjectMeta.Labels)) {
+ if labelSelector.Matches(kblabels.Set(secret.Labels)) {
list.Items = append(list.Items, *secret)
}
}
@@ -225,7 +230,7 @@ func (mock *MockSecretsInterface) List(_ context.Context, opts metav1.ListOption
// Create creates a new Secret.
func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) {
- name := secret.ObjectMeta.Name
+ name := secret.Name
if object, ok := mock.objects[name]; ok {
return object, apierrors.NewAlreadyExists(v1.Resource("tests"), name)
}
@@ -235,7 +240,7 @@ func (mock *MockSecretsInterface) Create(_ context.Context, secret *v1.Secret, _
// Update updates a Secret.
func (mock *MockSecretsInterface) Update(_ context.Context, secret *v1.Secret, _ metav1.UpdateOptions) (*v1.Secret, error) {
- name := secret.ObjectMeta.Name
+ name := secret.Name
if _, ok := mock.objects[name]; !ok {
return nil, apierrors.NewNotFound(v1.Resource("tests"), name)
}
@@ -253,7 +258,8 @@ func (mock *MockSecretsInterface) Delete(_ context.Context, name string, _ metav
}
// newTestFixtureSQL mocks the SQL database (for testing purposes)
-func newTestFixtureSQL(t *testing.T, releases ...*rspb.Release) (*SQL, sqlmock.Sqlmock) {
+func newTestFixtureSQL(t *testing.T, _ ...*rspb.Release) (*SQL, sqlmock.Sqlmock) {
+ t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("error when opening stub database connection: %v", err)
@@ -262,7 +268,6 @@ func newTestFixtureSQL(t *testing.T, releases ...*rspb.Release) (*SQL, sqlmock.S
sqlxDB := sqlx.NewDb(sqlDB, "sqlmock")
return &SQL{
db: sqlxDB,
- Log: func(a string, b ...interface{}) {},
namespace: "default",
statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar),
}, mock
diff --git a/pkg/storage/driver/records.go b/pkg/storage/driver/records.go
index 9df173384..6b4efef3a 100644
--- a/pkg/storage/driver/records.go
+++ b/pkg/storage/driver/records.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"sort"
"strconv"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
// records holds a list of in-memory release records
diff --git a/pkg/storage/driver/records_test.go b/pkg/storage/driver/records_test.go
index 0a27839cc..34b2fb80c 100644
--- a/pkg/storage/driver/records_test.go
+++ b/pkg/storage/driver/records_test.go
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"reflect"
"testing"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestRecordsAdd(t *testing.T) {
diff --git a/pkg/storage/driver/secrets.go b/pkg/storage/driver/secrets.go
index 224026b07..23a8f5cab 100644
--- a/pkg/storage/driver/secrets.go
+++ b/pkg/storage/driver/secrets.go
@@ -14,15 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"context"
+ "fmt"
+ "log/slog"
"strconv"
"strings"
"time"
- "github.com/pkg/errors"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -30,7 +31,7 @@ import (
"k8s.io/apimachinery/pkg/util/validation"
corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var _ Driver = (*Secrets)(nil)
@@ -42,7 +43,6 @@ const SecretsDriverName = "Secret"
// SecretsInterface.
type Secrets struct {
impl corev1.SecretInterface
- Log func(string, ...interface{})
}
// NewSecrets initializes a new Secrets wrapping an implementation of
@@ -50,7 +50,6 @@ type Secrets struct {
func NewSecrets(impl corev1.SecretInterface) *Secrets {
return &Secrets{
impl: impl,
- Log: func(_ string, _ ...interface{}) {},
}
}
@@ -68,12 +67,15 @@ func (secrets *Secrets) Get(key string) (*rspb.Release, error) {
if apierrors.IsNotFound(err) {
return nil, ErrReleaseNotFound
}
- return nil, errors.Wrapf(err, "get: failed to get %q", key)
+ return nil, fmt.Errorf("get: failed to get %q: %w", key, err)
}
// found the secret, decode the base64 data string
r, err := decodeRelease(string(obj.Data["release"]))
- r.Labels = filterSystemLabels(obj.ObjectMeta.Labels)
- return r, errors.Wrapf(err, "get: failed to decode data %q", key)
+ if err != nil {
+ return r, fmt.Errorf("get: failed to decode data %q: %w", key, err)
+ }
+ r.Labels = filterSystemLabels(obj.Labels)
+ return r, nil
}
// List fetches all releases and returns the list releases such
@@ -85,7 +87,7 @@ func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release,
list, err := secrets.impl.List(context.Background(), opts)
if err != nil {
- return nil, errors.Wrap(err, "list: failed to list")
+ return nil, fmt.Errorf("list: failed to list: %w", err)
}
var results []*rspb.Release
@@ -95,11 +97,11 @@ func (secrets *Secrets) List(filter func(*rspb.Release) bool) ([]*rspb.Release,
for _, item := range list.Items {
rls, err := decodeRelease(string(item.Data["release"]))
if err != nil {
- secrets.Log("list: failed to decode release: %v: %s", item, err)
+ slog.Debug("list failed to decode release", "key", item.Name, slog.Any("error", err))
continue
}
- rls.Labels = filterSystemLabels(item.ObjectMeta.Labels)
+ rls.Labels = item.Labels
if filter(rls) {
results = append(results, rls)
@@ -114,7 +116,7 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error)
ls := kblabels.Set{}
for k, v := range labels {
if errs := validation.IsValidLabelValue(v); len(errs) != 0 {
- return nil, errors.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; "))
+ return nil, fmt.Errorf("invalid label value: %q: %s", v, strings.Join(errs, "; "))
}
ls[k] = v
}
@@ -123,7 +125,7 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error)
list, err := secrets.impl.List(context.Background(), opts)
if err != nil {
- return nil, errors.Wrap(err, "query: failed to query with labels")
+ return nil, fmt.Errorf("query: failed to query with labels: %w", err)
}
if len(list.Items) == 0 {
@@ -134,10 +136,10 @@ func (secrets *Secrets) Query(labels map[string]string) ([]*rspb.Release, error)
for _, item := range list.Items {
rls, err := decodeRelease(string(item.Data["release"]))
if err != nil {
- secrets.Log("query: failed to decode release: %s", err)
+ slog.Debug("failed to decode release", "key", item.Name, slog.Any("error", err))
continue
}
- rls.Labels = filterSystemLabels(item.ObjectMeta.Labels)
+ rls.Labels = item.Labels
results = append(results, rls)
}
return results, nil
@@ -151,12 +153,12 @@ func (secrets *Secrets) Create(key string, rls *rspb.Release) error {
lbs.init()
lbs.fromMap(rls.Labels)
- lbs.set("createdAt", strconv.Itoa(int(time.Now().Unix())))
+ lbs.set("createdAt", fmt.Sprintf("%v", time.Now().Unix()))
// create a new secret to hold the release
obj, err := newSecretsObject(key, rls, lbs)
if err != nil {
- return errors.Wrapf(err, "create: failed to encode release %q", rls.Name)
+ return fmt.Errorf("create: failed to encode release %q: %w", rls.Name, err)
}
// push the secret object out into the kubiverse
if _, err := secrets.impl.Create(context.Background(), obj, metav1.CreateOptions{}); err != nil {
@@ -164,7 +166,7 @@ func (secrets *Secrets) Create(key string, rls *rspb.Release) error {
return ErrReleaseExists
}
- return errors.Wrap(err, "create: failed to create")
+ return fmt.Errorf("create: failed to create: %w", err)
}
return nil
}
@@ -177,16 +179,19 @@ func (secrets *Secrets) Update(key string, rls *rspb.Release) error {
lbs.init()
lbs.fromMap(rls.Labels)
- lbs.set("modifiedAt", strconv.Itoa(int(time.Now().Unix())))
+ lbs.set("modifiedAt", fmt.Sprintf("%v", time.Now().Unix()))
// create a new secret object to hold the release
obj, err := newSecretsObject(key, rls, lbs)
if err != nil {
- return errors.Wrapf(err, "update: failed to encode release %q", rls.Name)
+ return fmt.Errorf("update: failed to encode release %q: %w", rls.Name, err)
}
// push the secret object out into the kubiverse
_, err = secrets.impl.Update(context.Background(), obj, metav1.UpdateOptions{})
- return errors.Wrap(err, "update: failed to update")
+ if err != nil {
+ return fmt.Errorf("update: failed to update: %w", err)
+ }
+ return nil
}
// Delete deletes the Secret holding the release named by key.
@@ -197,7 +202,10 @@ func (secrets *Secrets) Delete(key string) (rls *rspb.Release, err error) {
}
// delete the release
err = secrets.impl.Delete(context.Background(), key, metav1.DeleteOptions{})
- return rls, err
+ if err != nil {
+ return nil, err
+ }
+ return rls, nil
}
// newSecretsObject constructs a kubernetes Secret object
diff --git a/pkg/storage/driver/secrets_test.go b/pkg/storage/driver/secrets_test.go
index d509c7b3a..9e45bae67 100644
--- a/pkg/storage/driver/secrets_test.go
+++ b/pkg/storage/driver/secrets_test.go
@@ -16,12 +16,13 @@ package driver
import (
"encoding/base64"
"encoding/json"
+ "errors"
"reflect"
"testing"
v1 "k8s.io/api/core/v1"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestSecretName(t *testing.T) {
@@ -128,6 +129,16 @@ func TestSecretList(t *testing.T) {
if len(ssd) != 2 {
t.Errorf("Expected 2 superseded, got %d", len(ssd))
}
+ // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work.
+ rls := ssd[0]
+ _, ok := rls.Labels["name"]
+ if !ok {
+ t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels)
+ }
+ _, ok = rls.Labels["key1"]
+ if !ok {
+ t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels)
+ }
}
func TestSecretQuery(t *testing.T) {
@@ -232,10 +243,8 @@ func TestSecretDelete(t *testing.T) {
if !reflect.DeepEqual(rel, rls) {
t.Errorf("Expected {%v}, got {%v}", rel, rls)
}
-
- // fetch the deleted release
_, err = secrets.Get(key)
- if !reflect.DeepEqual(ErrReleaseNotFound, err) {
+ if !errors.Is(err, ErrReleaseNotFound) {
t.Errorf("Expected {%v}, got {%v}", ErrReleaseNotFound, err)
}
}
diff --git a/pkg/storage/driver/sql.go b/pkg/storage/driver/sql.go
index 743886e80..46f6c6b2e 100644
--- a/pkg/storage/driver/sql.go
+++ b/pkg/storage/driver/sql.go
@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"fmt"
+ "log/slog"
+ "maps"
"sort"
+ "strconv"
"time"
"github.com/jmoiron/sqlx"
@@ -29,7 +32,7 @@ import (
// Import pq for postgres dialect
_ "github.com/lib/pq"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var _ Driver = (*SQL)(nil)
@@ -71,8 +74,8 @@ const (
// Following limits based on k8s labels limits - https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
const (
- sqlCustomLabelsTableKeyMaxLenght = 253 + 1 + 63
- sqlCustomLabelsTableValueMaxLenght = 63
+ sqlCustomLabelsTableKeyMaxLength = 253 + 1 + 63
+ sqlCustomLabelsTableValueMaxLength = 63
)
const (
@@ -85,8 +88,6 @@ type SQL struct {
db *sqlx.DB
namespace string
statementBuilder sq.StatementBuilderType
-
- Log func(string, ...interface{})
}
// Name returns the name of the driver.
@@ -97,9 +98,9 @@ func (s *SQL) Name() string {
// Check if all migrations al
func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool {
// make map (set) of ids for fast search
- migrationsIds := make(map[string]struct{})
+ migrationsIDs := make(map[string]struct{})
for _, migration := range migrations {
- migrationsIds[migration.Id] = struct{}{}
+ migrationsIDs[migration.Id] = struct{}{}
}
// get list of applied migrations
@@ -107,21 +108,21 @@ func (s *SQL) checkAlreadyApplied(migrations []*migrate.Migration) bool {
records, err := migrate.GetMigrationRecords(s.db.DB, postgreSQLDialect)
migrate.SetDisableCreateTable(false)
if err != nil {
- s.Log("checkAlreadyApplied: failed to get migration records: %v", err)
+ slog.Debug("failed to get migration records", slog.Any("error", err))
return false
}
for _, record := range records {
- if _, ok := migrationsIds[record.Id]; ok {
- s.Log("checkAlreadyApplied: found previous migration (Id: %v) applied at %v", record.Id, record.AppliedAt)
- delete(migrationsIds, record.Id)
+ if _, ok := migrationsIDs[record.Id]; ok {
+ slog.Debug("found previous migration", "id", record.Id, "appliedAt", record.AppliedAt)
+ delete(migrationsIDs, record.Id)
}
}
- // check if all migrations appliyed
- if len(migrationsIds) != 0 {
- for id := range migrationsIds {
- s.Log("checkAlreadyApplied: find unapplied migration (id: %v)", id)
+ // check if all migrations applied
+ if len(migrationsIDs) != 0 {
+ for id := range migrationsIDs {
+ slog.Debug("find unapplied migration", "id", id)
}
return false
}
@@ -203,7 +204,7 @@ func (s *SQL) ensureDBSetup() error {
CREATE TABLE %s (
%s VARCHAR(64),
%s VARCHAR(67),
- %s VARCHAR(%d),
+ %s VARCHAR(%d),
%s VARCHAR(%d)
);
CREATE INDEX ON %s (%s, %s);
@@ -215,9 +216,9 @@ func (s *SQL) ensureDBSetup() error {
sqlCustomLabelsTableReleaseKeyColumn,
sqlCustomLabelsTableReleaseNamespaceColumn,
sqlCustomLabelsTableKeyColumn,
- sqlCustomLabelsTableKeyMaxLenght,
+ sqlCustomLabelsTableKeyMaxLength,
sqlCustomLabelsTableValueColumn,
- sqlCustomLabelsTableValueMaxLenght,
+ sqlCustomLabelsTableValueMaxLength,
sqlCustomLabelsTableName,
sqlCustomLabelsTableReleaseKeyColumn,
sqlCustomLabelsTableReleaseNamespaceColumn,
@@ -275,7 +276,7 @@ type SQLReleaseCustomLabelWrapper struct {
}
// NewSQL initializes a new sql driver.
-func NewSQL(connectionString string, logger func(string, ...interface{}), namespace string) (*SQL, error) {
+func NewSQL(connectionString string, namespace string) (*SQL, error) {
db, err := sqlx.Connect(postgreSQLDialect, connectionString)
if err != nil {
return nil, err
@@ -283,7 +284,6 @@ func NewSQL(connectionString string, logger func(string, ...interface{}), namesp
driver := &SQL{
db: db,
- Log: logger,
statementBuilder: sq.StatementBuilder.PlaceholderFormat(sq.Dollar),
}
@@ -308,24 +308,24 @@ func (s *SQL) Get(key string) (*rspb.Release, error) {
query, args, err := qb.ToSql()
if err != nil {
- s.Log("failed to build query: %v", err)
+ slog.Debug("failed to build query", slog.Any("error", err))
return nil, err
}
// Get will return an error if the result is empty
if err := s.db.Get(&record, query, args...); err != nil {
- s.Log("got SQL error when getting release %s: %v", key, err)
+ slog.Debug("got SQL error when getting release", "key", key, slog.Any("error", err))
return nil, ErrReleaseNotFound
}
release, err := decodeRelease(record.Body)
if err != nil {
- s.Log("get: failed to decode data %q: %v", key, err)
+ slog.Debug("failed to decode data", "key", key, slog.Any("error", err))
return nil, err
}
if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil {
- s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err)
+ slog.Debug("failed to get release custom labels", "namespace", s.namespace, "key", key, slog.Any("error", err))
return nil, err
}
@@ -346,13 +346,13 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) {
query, args, err := sb.ToSql()
if err != nil {
- s.Log("failed to build query: %v", err)
+ slog.Debug("failed to build query", slog.Any("error", err))
return nil, err
}
var records = []SQLReleaseWrapper{}
if err := s.db.Select(&records, query, args...); err != nil {
- s.Log("list: failed to list: %v", err)
+ slog.Debug("failed to list", slog.Any("error", err))
return nil, err
}
@@ -360,14 +360,15 @@ func (s *SQL) List(filter func(*rspb.Release) bool) ([]*rspb.Release, error) {
for _, record := range records {
release, err := decodeRelease(record.Body)
if err != nil {
- s.Log("list: failed to decode release: %v: %v", record, err)
+ slog.Debug("failed to decode release", "record", record, slog.Any("error", err))
continue
}
if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil {
- s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err)
+ slog.Debug("failed to get release custom labels", "namespace", record.Namespace, "key", record.Key, slog.Any("error", err))
return nil, err
}
+ maps.Copy(release.Labels, getReleaseSystemLabels(release))
if filter(release) {
releases = append(releases, release)
@@ -392,7 +393,7 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) {
if _, ok := labelMap[key]; ok {
sb = sb.Where(sq.Eq{key: labels[key]})
} else {
- s.Log("unknown label %s", key)
+ slog.Debug("unknown label", "key", key)
return nil, fmt.Errorf("unknown label %s", key)
}
}
@@ -405,13 +406,13 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) {
// Build our query
query, args, err := sb.ToSql()
if err != nil {
- s.Log("failed to build query: %v", err)
+ slog.Debug("failed to build query", slog.Any("error", err))
return nil, err
}
var records = []SQLReleaseWrapper{}
if err := s.db.Select(&records, query, args...); err != nil {
- s.Log("list: failed to query with labels: %v", err)
+ slog.Debug("failed to query with labels", slog.Any("error", err))
return nil, err
}
@@ -423,12 +424,12 @@ func (s *SQL) Query(labels map[string]string) ([]*rspb.Release, error) {
for _, record := range records {
release, err := decodeRelease(record.Body)
if err != nil {
- s.Log("list: failed to decode release: %v: %v", record, err)
+ slog.Debug("failed to decode release", "record", record, slog.Any("error", err))
continue
}
if release.Labels, err = s.getReleaseCustomLabels(record.Key, record.Namespace); err != nil {
- s.Log("failed to get release %s/%s custom labels: %v", record.Namespace, record.Key, err)
+ slog.Debug("failed to get release custom labels", "namespace", record.Namespace, "key", record.Key, slog.Any("error", err))
return nil, err
}
@@ -452,13 +453,13 @@ func (s *SQL) Create(key string, rls *rspb.Release) error {
body, err := encodeRelease(rls)
if err != nil {
- s.Log("failed to encode release: %v", err)
+ slog.Debug("failed to encode release", slog.Any("error", err))
return err
}
transaction, err := s.db.Beginx()
if err != nil {
- s.Log("failed to start SQL transaction: %v", err)
+ slog.Debug("failed to start SQL transaction", slog.Any("error", err))
return fmt.Errorf("error beginning transaction: %v", err)
}
@@ -487,7 +488,7 @@ func (s *SQL) Create(key string, rls *rspb.Release) error {
int(time.Now().Unix()),
).ToSql()
if err != nil {
- s.Log("failed to build insert query: %v", err)
+ slog.Debug("failed to build insert query", slog.Any("error", err))
return err
}
@@ -501,17 +502,17 @@ func (s *SQL) Create(key string, rls *rspb.Release) error {
Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}).
ToSql()
if buildErr != nil {
- s.Log("failed to build select query: %v", buildErr)
+ slog.Debug("failed to build select query", "error", buildErr)
return err
}
var record SQLReleaseWrapper
if err := transaction.Get(&record, selectQuery, args...); err == nil {
- s.Log("release %s already exists", key)
+ slog.Debug("release already exists", "key", key)
return ErrReleaseExists
}
- s.Log("failed to store release %s in SQL database: %v", key, err)
+ slog.Debug("failed to store release in SQL database", "key", key, slog.Any("error", err))
return err
}
@@ -534,13 +535,13 @@ func (s *SQL) Create(key string, rls *rspb.Release) error {
if err != nil {
defer transaction.Rollback()
- s.Log("failed to build insert query: %v", err)
+ slog.Debug("failed to build insert query", slog.Any("error", err))
return err
}
if _, err := transaction.Exec(insertLabelsQuery, args...); err != nil {
defer transaction.Rollback()
- s.Log("failed to write Labels: %v", err)
+ slog.Debug("failed to write Labels", slog.Any("error", err))
return err
}
}
@@ -559,7 +560,7 @@ func (s *SQL) Update(key string, rls *rspb.Release) error {
body, err := encodeRelease(rls)
if err != nil {
- s.Log("failed to encode release: %v", err)
+ slog.Debug("failed to encode release", slog.Any("error", err))
return err
}
@@ -576,12 +577,12 @@ func (s *SQL) Update(key string, rls *rspb.Release) error {
ToSql()
if err != nil {
- s.Log("failed to build update query: %v", err)
+ slog.Debug("failed to build update query", slog.Any("error", err))
return err
}
if _, err := s.db.Exec(query, args...); err != nil {
- s.Log("failed to update release %s in SQL database: %v", key, err)
+ slog.Debug("failed to update release in SQL database", "key", key, slog.Any("error", err))
return err
}
@@ -592,7 +593,7 @@ func (s *SQL) Update(key string, rls *rspb.Release) error {
func (s *SQL) Delete(key string) (*rspb.Release, error) {
transaction, err := s.db.Beginx()
if err != nil {
- s.Log("failed to start SQL transaction: %v", err)
+ slog.Debug("failed to start SQL transaction", slog.Any("error", err))
return nil, fmt.Errorf("error beginning transaction: %v", err)
}
@@ -603,20 +604,20 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) {
Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}).
ToSql()
if err != nil {
- s.Log("failed to build select query: %v", err)
+ slog.Debug("failed to build select query", slog.Any("error", err))
return nil, err
}
var record SQLReleaseWrapper
err = transaction.Get(&record, selectQuery, args...)
if err != nil {
- s.Log("release %s not found: %v", key, err)
+ slog.Debug("release not found", "key", key, slog.Any("error", err))
return nil, ErrReleaseNotFound
}
release, err := decodeRelease(record.Body)
if err != nil {
- s.Log("failed to decode release %s: %v", key, err)
+ slog.Debug("failed to decode release", "key", key, slog.Any("error", err))
transaction.Rollback()
return nil, err
}
@@ -628,18 +629,18 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) {
Where(sq.Eq{sqlReleaseTableNamespaceColumn: s.namespace}).
ToSql()
if err != nil {
- s.Log("failed to build delete query: %v", err)
+ slog.Debug("failed to build delete query", slog.Any("error", err))
return nil, err
}
_, err = transaction.Exec(deleteQuery, args...)
if err != nil {
- s.Log("failed perform delete query: %v", err)
+ slog.Debug("failed perform delete query", slog.Any("error", err))
return release, err
}
if release.Labels, err = s.getReleaseCustomLabels(key, s.namespace); err != nil {
- s.Log("failed to get release %s/%s custom labels: %v", s.namespace, key, err)
+ slog.Debug("failed to get release custom labels", "namespace", s.namespace, "key", key, slog.Any("error", err))
return nil, err
}
@@ -650,7 +651,7 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) {
ToSql()
if err != nil {
- s.Log("failed to build delete Labels query: %v", err)
+ slog.Debug("failed to build delete Labels query", slog.Any("error", err))
return nil, err
}
_, err = transaction.Exec(deleteCustomLabelsQuery, args...)
@@ -658,7 +659,7 @@ func (s *SQL) Delete(key string) (*rspb.Release, error) {
}
// Get release custom labels from database
-func (s *SQL) getReleaseCustomLabels(key string, namespace string) (map[string]string, error) {
+func (s *SQL) getReleaseCustomLabels(key string, _ string) (map[string]string, error) {
query, args, err := s.statementBuilder.
Select(sqlCustomLabelsTableKeyColumn, sqlCustomLabelsTableValueColumn).
From(sqlCustomLabelsTableName).
@@ -681,3 +682,13 @@ func (s *SQL) getReleaseCustomLabels(key string, namespace string) (map[string]s
return filterSystemLabels(labelsMap), nil
}
+
+// Rebuild system labels from release object
+func getReleaseSystemLabels(rls *rspb.Release) map[string]string {
+ return map[string]string{
+ "name": rls.Name,
+ "owner": sqlReleaseDefaultOwner,
+ "status": rls.Info.Status.String(),
+ "version": strconv.Itoa(rls.Version),
+ }
+}
diff --git a/pkg/storage/driver/sql_test.go b/pkg/storage/driver/sql_test.go
index 067b3cf47..bd2918aad 100644
--- a/pkg/storage/driver/sql_test.go
+++ b/pkg/storage/driver/sql_test.go
@@ -23,7 +23,7 @@ import (
sqlmock "github.com/DATA-DOG/go-sqlmock"
migrate "github.com/rubenv/sql-migrate"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
func TestSQLName(t *testing.T) {
@@ -157,6 +157,17 @@ func TestSQLList(t *testing.T) {
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sql expectations weren't met: %v", err)
}
+
+ // Check if release having both system and custom labels, this is needed to ensure that selector filtering would work.
+ rls := ssd[0]
+ _, ok := rls.Labels["name"]
+ if !ok {
+ t.Fatalf("Expected 'name' label in results, actual %v", rls.Labels)
+ }
+ _, ok = rls.Labels["key1"]
+ if !ok {
+ t.Fatalf("Expected 'key1' label in results, actual %v", rls.Labels)
+ }
}
func TestSqlCreate(t *testing.T) {
@@ -532,34 +543,34 @@ func mockGetReleaseCustomLabels(mock sqlmock.Sqlmock, key string, namespace stri
eq.WillReturnRows(returnRows).RowsWillBeClosed()
}
-func TestSqlChechkAppliedMigrations(t *testing.T) {
+func TestSqlCheckAppliedMigrations(t *testing.T) {
cases := []struct {
migrationsToApply []*migrate.Migration
- appliedMigrationsIds []string
+ appliedMigrationsIDs []string
expectedResult bool
errorExplanation string
}{
{
migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}},
- appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "5"},
+ appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "5"},
expectedResult: false,
errorExplanation: "Has found one migration id \"init3\" as applied, that was not applied",
},
{
migrationsToApply: []*migrate.Migration{{Id: "init1"}, {Id: "init2"}, {Id: "init3"}},
- appliedMigrationsIds: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"},
+ appliedMigrationsIDs: []string{"1", "2", "init1", "3", "init2", "4", "init3", "5"},
expectedResult: true,
errorExplanation: "Has not found one or more migration ids, that was applied",
},
{
migrationsToApply: []*migrate.Migration{{Id: "init"}},
- appliedMigrationsIds: []string{"1", "2", "3", "inits", "4", "tinit", "5"},
+ appliedMigrationsIDs: []string{"1", "2", "3", "inits", "4", "tinit", "5"},
expectedResult: false,
errorExplanation: "Has found single \"init\", that was not applied",
},
{
migrationsToApply: []*migrate.Migration{{Id: "init"}},
- appliedMigrationsIds: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"},
+ appliedMigrationsIDs: []string{"1", "2", "init", "3", "init2", "4", "init3", "5"},
expectedResult: true,
errorExplanation: "Has not found single migration id \"init\", that was applied",
},
@@ -567,7 +578,7 @@ func TestSqlChechkAppliedMigrations(t *testing.T) {
for i, c := range cases {
sqlDriver, mock := newTestFixtureSQL(t)
rows := sqlmock.NewRows([]string{"id", "applied_at"})
- for _, id := range c.appliedMigrationsIds {
+ for _, id := range c.appliedMigrationsIDs {
rows.AddRow(id, time.Time{})
}
mock.
diff --git a/pkg/storage/driver/util.go b/pkg/storage/driver/util.go
index 7bda5ec96..ca8e23cc2 100644
--- a/pkg/storage/driver/util.go
+++ b/pkg/storage/driver/util.go
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package driver // import "helm.sh/helm/v3/pkg/storage/driver"
+package driver // import "helm.sh/helm/v4/pkg/storage/driver"
import (
"bytes"
@@ -22,8 +22,9 @@ import (
"encoding/base64"
"encoding/json"
"io"
+ "slices"
- rspb "helm.sh/helm/v3/pkg/release"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
)
var b64 = base64.StdEncoding
@@ -88,12 +89,7 @@ func decodeRelease(data string) (*rspb.Release, error) {
// Checks if label is system
func isSystemLabel(key string) bool {
- for _, v := range GetSystemLabels() {
- if key == v {
- return true
- }
- }
- return false
+ return slices.Contains(GetSystemLabels(), key)
}
// Removes system labels from labels map
diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go
index 0a18b34a0..b43f7c0f2 100644
--- a/pkg/storage/storage.go
+++ b/pkg/storage/storage.go
@@ -14,17 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package storage // import "helm.sh/helm/v3/pkg/storage"
+package storage // import "helm.sh/helm/v4/pkg/storage"
import (
+ "errors"
"fmt"
+ "log/slog"
"strings"
- "github.com/pkg/errors"
-
- rspb "helm.sh/helm/v3/pkg/release"
- relutil "helm.sh/helm/v3/pkg/releaseutil"
- "helm.sh/helm/v3/pkg/storage/driver"
+ relutil "helm.sh/helm/v4/pkg/release/util"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
// HelmStorageType is the type field of the Kubernetes storage object which stores the Helm release
@@ -42,15 +42,13 @@ type Storage struct {
// be retained, including the most recent release. Values of 0 or less are
// ignored (meaning no limits are imposed).
MaxHistory int
-
- Log func(string, ...interface{})
}
// Get retrieves the release from storage. An error is returned
// if the storage driver failed to fetch the release, or the
// release identified by the key, version pair does not exist.
func (s *Storage) Get(name string, version int) (*rspb.Release, error) {
- s.Log("getting release %q", makeKey(name, version))
+ slog.Debug("getting release", "key", makeKey(name, version))
return s.Driver.Get(makeKey(name, version))
}
@@ -58,7 +56,7 @@ func (s *Storage) Get(name string, version int) (*rspb.Release, error) {
// error is returned if the storage driver fails to store the
// release, or a release with an identical key already exists.
func (s *Storage) Create(rls *rspb.Release) error {
- s.Log("creating release %q", makeKey(rls.Name, rls.Version))
+ slog.Debug("creating release", "key", makeKey(rls.Name, rls.Version))
if s.MaxHistory > 0 {
// Want to make space for one more release.
if err := s.removeLeastRecent(rls.Name, s.MaxHistory-1); err != nil &&
@@ -73,7 +71,7 @@ func (s *Storage) Create(rls *rspb.Release) error {
// storage backend fails to update the release or if the release
// does not exist.
func (s *Storage) Update(rls *rspb.Release) error {
- s.Log("updating release %q", makeKey(rls.Name, rls.Version))
+ slog.Debug("updating release", "key", makeKey(rls.Name, rls.Version))
return s.Driver.Update(makeKey(rls.Name, rls.Version), rls)
}
@@ -81,22 +79,22 @@ func (s *Storage) Update(rls *rspb.Release) error {
// the storage backend fails to delete the release or if the release
// does not exist.
func (s *Storage) Delete(name string, version int) (*rspb.Release, error) {
- s.Log("deleting release %q", makeKey(name, version))
+ slog.Debug("deleting release", "key", makeKey(name, version))
return s.Driver.Delete(makeKey(name, version))
}
// ListReleases returns all releases from storage. An error is returned if the
// storage backend fails to retrieve the releases.
func (s *Storage) ListReleases() ([]*rspb.Release, error) {
- s.Log("listing all releases in storage")
- return s.Driver.List(func(_ *rspb.Release) bool { return true })
+ slog.Debug("listing all releases in storage")
+ return s.List(func(_ *rspb.Release) bool { return true })
}
// ListUninstalled returns all releases with Status == UNINSTALLED. An error is returned
// if the storage backend fails to retrieve the releases.
func (s *Storage) ListUninstalled() ([]*rspb.Release, error) {
- s.Log("listing uninstalled releases in storage")
- return s.Driver.List(func(rls *rspb.Release) bool {
+ slog.Debug("listing uninstalled releases in storage")
+ return s.List(func(rls *rspb.Release) bool {
return relutil.StatusFilter(rspb.StatusUninstalled).Check(rls)
})
}
@@ -104,14 +102,14 @@ func (s *Storage) ListUninstalled() ([]*rspb.Release, error) {
// ListDeployed returns all releases with Status == DEPLOYED. An error is returned
// if the storage backend fails to retrieve the releases.
func (s *Storage) ListDeployed() ([]*rspb.Release, error) {
- s.Log("listing all deployed releases in storage")
- return s.Driver.List(func(rls *rspb.Release) bool {
+ slog.Debug("listing all deployed releases in storage")
+ return s.List(func(rls *rspb.Release) bool {
return relutil.StatusFilter(rspb.StatusDeployed).Check(rls)
})
}
// Deployed returns the last deployed release with the provided release name, or
-// returns ErrReleaseNotFound if not found.
+// returns driver.NewErrNoDeployedReleases if not found.
func (s *Storage) Deployed(name string) (*rspb.Release, error) {
ls, err := s.DeployedAll(name)
if err != nil {
@@ -130,11 +128,11 @@ func (s *Storage) Deployed(name string) (*rspb.Release, error) {
}
// DeployedAll returns all deployed releases with the provided name, or
-// returns ErrReleaseNotFound if not found.
+// returns driver.NewErrNoDeployedReleases if not found.
func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) {
- s.Log("getting deployed releases from %q history", name)
+ slog.Debug("getting deployed releases", "name", name)
- ls, err := s.Driver.Query(map[string]string{
+ ls, err := s.Query(map[string]string{
"name": name,
"owner": "helm",
"status": "deployed",
@@ -149,11 +147,11 @@ func (s *Storage) DeployedAll(name string) ([]*rspb.Release, error) {
}
// History returns the revision history for the release with the provided name, or
-// returns ErrReleaseNotFound if no such release name exists.
+// returns driver.ErrReleaseNotFound if no such release name exists.
func (s *Storage) History(name string) ([]*rspb.Release, error) {
- s.Log("getting release history for %q", name)
+ slog.Debug("getting release history", "name", name)
- return s.Driver.Query(map[string]string{"name": name, "owner": "helm"})
+ return s.Query(map[string]string{"name": name, "owner": "helm"})
}
// removeLeastRecent removes items from history until the length number of releases
@@ -161,15 +159,15 @@ func (s *Storage) History(name string) ([]*rspb.Release, error) {
//
// We allow max to be set explicitly so that calling functions can "make space"
// for the new records they are going to write.
-func (s *Storage) removeLeastRecent(name string, max int) error {
- if max < 0 {
+func (s *Storage) removeLeastRecent(name string, maximum int) error {
+ if maximum < 0 {
return nil
}
h, err := s.History(name)
if err != nil {
return err
}
- if len(h) <= max {
+ if len(h) <= maximum {
return nil
}
@@ -183,8 +181,8 @@ func (s *Storage) removeLeastRecent(name string, max int) error {
var toDelete []*rspb.Release
for _, rel := range h {
- // once we have enough releases to delete to reach the max, stop
- if len(h)-len(toDelete) == max {
+ // once we have enough releases to delete to reach the maximum, stop
+ if len(h)-len(toDelete) == maximum {
break
}
if lastDeployed != nil {
@@ -206,14 +204,14 @@ func (s *Storage) removeLeastRecent(name string, max int) error {
}
}
- s.Log("Pruned %d record(s) from %s with %d error(s)", len(toDelete), name, len(errs))
+ slog.Debug("pruned records", "count", len(toDelete), "release", name, "errors", len(errs))
switch c := len(errs); c {
case 0:
return nil
case 1:
return errs[0]
default:
- return errors.Errorf("encountered %d deletion errors. First is: %s", c, errs[0])
+ return fmt.Errorf("encountered %d deletion errors. First is: %w", c, errs[0])
}
}
@@ -221,7 +219,7 @@ func (s *Storage) deleteReleaseVersion(name string, version int) error {
key := makeKey(name, version)
_, err := s.Delete(name, version)
if err != nil {
- s.Log("error pruning %s from release history: %s", key, err)
+ slog.Debug("error pruning release", "key", key, slog.Any("error", err))
return err
}
return nil
@@ -229,13 +227,13 @@ func (s *Storage) deleteReleaseVersion(name string, version int) error {
// Last fetches the last revision of the named release.
func (s *Storage) Last(name string) (*rspb.Release, error) {
- s.Log("getting last revision of %q", name)
+ slog.Debug("getting last revision", "name", name)
h, err := s.History(name)
if err != nil {
return nil, err
}
if len(h) == 0 {
- return nil, errors.Errorf("no revision for release %q", name)
+ return nil, fmt.Errorf("no revision for release %q", name)
}
relutil.Reverse(h, relutil.SortByRevision)
@@ -261,6 +259,5 @@ func Init(d driver.Driver) *Storage {
}
return &Storage{
Driver: d,
- Log: func(_ string, _ ...interface{}) {},
}
}
diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go
index 058b077e8..d3025eca3 100644
--- a/pkg/storage/storage_test.go
+++ b/pkg/storage/storage_test.go
@@ -14,17 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package storage // import "helm.sh/helm/v3/pkg/storage"
+package storage // import "helm.sh/helm/v4/pkg/storage"
import (
+ "errors"
"fmt"
"reflect"
"testing"
- "github.com/pkg/errors"
-
- rspb "helm.sh/helm/v3/pkg/release"
- "helm.sh/helm/v3/pkg/storage/driver"
+ rspb "helm.sh/helm/v4/pkg/release/v1"
+ "helm.sh/helm/v4/pkg/storage/driver"
)
func TestStorageCreate(t *testing.T) {
@@ -293,7 +292,7 @@ func (d *MaxHistoryMockDriver) Create(key string, rls *rspb.Release) error {
func (d *MaxHistoryMockDriver) Update(key string, rls *rspb.Release) error {
return d.Driver.Update(key, rls)
}
-func (d *MaxHistoryMockDriver) Delete(key string) (*rspb.Release, error) {
+func (d *MaxHistoryMockDriver) Delete(_ string) (*rspb.Release, error) {
return nil, errMaxHistoryMockDriverSomethingHappened
}
func (d *MaxHistoryMockDriver) Get(key string) (*rspb.Release, error) {
@@ -312,7 +311,6 @@ func (d *MaxHistoryMockDriver) Name() string {
func TestMaxHistoryErrorHandling(t *testing.T) {
//func TestStorageRemoveLeastRecentWithError(t *testing.T) {
storage := Init(NewMaxHistoryMockDriver(driver.NewMemory()))
- storage.Log = t.Logf
storage.MaxHistory = 1
@@ -338,7 +336,6 @@ func TestMaxHistoryErrorHandling(t *testing.T) {
func TestStorageRemoveLeastRecent(t *testing.T) {
storage := Init(driver.NewMemory())
- storage.Log = t.Logf
// Make sure that specifying this at the outset doesn't cause any bugs.
storage.MaxHistory = 10
@@ -395,7 +392,6 @@ func TestStorageRemoveLeastRecent(t *testing.T) {
func TestStorageDoNotDeleteDeployed(t *testing.T) {
storage := Init(driver.NewMemory())
- storage.Log = t.Logf
storage.MaxHistory = 3
const name = "angry-bird"
@@ -476,7 +472,7 @@ func TestStorageLast(t *testing.T) {
}
}
-// TestUpgradeInitiallyFailedRelease tests a case when there are no deployed release yet, but history limit has been
+// TestUpgradeInitiallyFailedReleaseWithHistoryLimit tests a case when there are no deployed release yet, but history limit has been
// reached: the has-no-deployed-releases error should not occur in such case.
func TestUpgradeInitiallyFailedReleaseWithHistoryLimit(t *testing.T) {
storage := Init(driver.NewMemory())
diff --git a/cmd/helm/root_windows.go b/pkg/strvals/fuzz_test.go
similarity index 71%
rename from cmd/helm/root_windows.go
rename to pkg/strvals/fuzz_test.go
index 7b5000f4f..68b43c8ec 100644
--- a/cmd/helm/root_windows.go
+++ b/pkg/strvals/fuzz_test.go
@@ -1,11 +1,10 @@
/*
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
+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,
@@ -14,9 +13,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-package main
+package strvals
+
+import (
+ "testing"
+)
-func checkPerms() {
- // Not yet implemented on Windows. If you know how to do a comprehensive perms
- // check on Windows, contributions welcomed!
+func FuzzParse(f *testing.F) {
+ f.Fuzz(func(_ *testing.T, data string) {
+ _, _ = Parse(data)
+ })
}
diff --git a/pkg/strvals/literal_parser.go b/pkg/strvals/literal_parser.go
index f75655811..d34e5e854 100644
--- a/pkg/strvals/literal_parser.go
+++ b/pkg/strvals/literal_parser.go
@@ -20,8 +20,6 @@ import (
"fmt"
"io"
"strconv"
-
- "github.com/pkg/errors"
)
// ParseLiteral parses a set line interpreting the value as a literal string.
@@ -102,7 +100,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r
if len(key) == 0 {
return err
}
- return errors.Errorf("key %q has no value", string(key))
+ return fmt.Errorf("key %q has no value", string(key))
case lastRune == '=':
// found end of key: swallow the '=' and get the value
@@ -129,7 +127,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r
// recurse on sub-tree with remaining data
err := t.key(inner, nestedNameLevel)
if err == nil && len(inner) == 0 {
- return errors.Errorf("key map %q has no value", string(key))
+ return fmt.Errorf("key map %q has no value", string(key))
}
if len(inner) != 0 {
set(data, string(key), inner)
@@ -140,7 +138,7 @@ func (t *literalParser) key(data map[string]interface{}, nestedNameLevel int) (r
// We are in a list index context, so we need to set an index.
i, err := t.keyIndex()
if err != nil {
- return errors.Wrap(err, "error parsing index")
+ return fmt.Errorf("error parsing index: %w", err)
}
kk := string(key)
@@ -178,7 +176,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]
switch key, lastRune, err := runesUntilLiteral(t.sc, stop); {
case len(key) > 0:
- return list, errors.Errorf("unexpected data at end of array index: %q", key)
+ return list, fmt.Errorf("unexpected data at end of array index: %q", key)
case err != nil:
return list, err
@@ -214,7 +212,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]
// now we have a nested list. Read the index and handle.
nextI, err := t.keyIndex()
if err != nil {
- return list, errors.Wrap(err, "error parsing index")
+ return list, fmt.Errorf("error parsing index: %w", err)
}
var crtList []interface{}
if len(list) > i {
@@ -233,7 +231,7 @@ func (t *literalParser) listItem(list []interface{}, i, nestedNameLevel int) ([]
return setIndex(list, i, list2)
default:
- return nil, errors.Errorf("parse error: unexpected token %v", lastRune)
+ return nil, fmt.Errorf("parse error: unexpected token %v", lastRune)
}
}
diff --git a/pkg/strvals/parser.go b/pkg/strvals/parser.go
index 2828f20c0..c65e98c84 100644
--- a/pkg/strvals/parser.go
+++ b/pkg/strvals/parser.go
@@ -18,13 +18,13 @@ package strvals
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
"strconv"
"strings"
"unicode"
- "github.com/pkg/errors"
"sigs.k8s.io/yaml"
)
@@ -189,14 +189,14 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e
if len(k) == 0 {
return err
}
- return errors.Errorf("key %q has no value", string(k))
+ return fmt.Errorf("key %q has no value", string(k))
//set(data, string(k), "")
//return err
case last == '[':
// We are in a list index context, so we need to set an index.
i, err := t.keyIndex()
if err != nil {
- return errors.Wrap(err, "error parsing index")
+ return fmt.Errorf("error parsing index: %w", err)
}
kk := string(k)
// Find or create target list
@@ -261,7 +261,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e
case last == ',':
// No value given. Set the value to empty string. Return error.
set(data, string(k), "")
- return errors.Errorf("key %q has no value (cannot end with ,)", string(k))
+ return fmt.Errorf("key %q has no value (cannot end with ,)", string(k))
case last == '.':
// Check value name is within the maximum nested name level
nestedNameLevel++
@@ -278,7 +278,7 @@ func (t *parser) key(data map[string]interface{}, nestedNameLevel int) (reterr e
// Recurse
e := t.key(inner, nestedNameLevel)
if e == nil && len(inner) == 0 {
- return errors.Errorf("key map %q has no value", string(k))
+ return fmt.Errorf("key map %q has no value", string(k))
}
if len(inner) != 0 {
set(data, string(k), inner)
@@ -332,6 +332,7 @@ func (t *parser) keyIndex() (int, error) {
return strconv.Atoi(string(v))
}
+
func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interface{}, error) {
if i < 0 {
return list, fmt.Errorf("negative %d index not allowed", i)
@@ -339,7 +340,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa
stop := runeSet([]rune{'[', '.', '='})
switch k, last, err := runesUntil(t.sc, stop); {
case len(k) > 0:
- return list, errors.Errorf("unexpected data at end of array index: %q", k)
+ return list, fmt.Errorf("unexpected data at end of array index: %q", k)
case err != nil:
return list, err
case last == '=':
@@ -394,7 +395,7 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa
// now we have a nested list. Read the index and handle.
nextI, err := t.keyIndex()
if err != nil {
- return list, errors.Wrap(err, "error parsing index")
+ return list, fmt.Errorf("error parsing index: %w", err)
}
var crtList []interface{}
if len(list) > i {
@@ -430,13 +431,13 @@ func (t *parser) listItem(list []interface{}, i, nestedNameLevel int) ([]interfa
}
return setIndex(list, i, inner)
default:
- return nil, errors.Errorf("parse error: unexpected token %v", last)
+ return nil, fmt.Errorf("parse error: unexpected token %v", last)
}
}
// check for an empty value
// read and consume optional spaces until comma or EOF (empty val) or any other char (not empty val)
-// comma and spaces are consumed, while any other char is not cosumed
+// comma and spaces are consumed, while any other char is not consumed
func (t *parser) emptyVal() (bool, error) {
for {
r, _, e := t.sc.ReadRune()
diff --git a/pkg/strvals/parser_test.go b/pkg/strvals/parser_test.go
index 925aa97c6..a0c67b791 100644
--- a/pkg/strvals/parser_test.go
+++ b/pkg/strvals/parser_test.go
@@ -626,7 +626,7 @@ func TestParseJSON(t *testing.T) {
},
err: false,
},
- { // null assigment, and no value assigned (equivalent to null)
+ { // null assignment, and no value assigned (equivalent to null)
input: "outer.inner1=,outer.inner3={\"aa\":\"1\",\"bb\":2,\"cc\":[1,2,3]},outer.inner3.cc[1]=null",
got: map[string]interface{}{
"outer": map[string]interface{}{
diff --git a/pkg/time/ctime/ctime.go b/pkg/time/ctime/ctime.go
new file mode 100644
index 000000000..63a41c0bf
--- /dev/null
+++ b/pkg/time/ctime/ctime.go
@@ -0,0 +1,29 @@
+/*
+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 ctime
+
+import (
+ "os"
+ "time"
+)
+
+func Created(fi os.FileInfo) time.Time {
+ return modified(fi)
+}
+
+func Modified(fi os.FileInfo) time.Time {
+ return modified(fi)
+}
diff --git a/pkg/time/ctime/ctime_linux.go b/pkg/time/ctime/ctime_linux.go
new file mode 100644
index 000000000..d8a6ea1a1
--- /dev/null
+++ b/pkg/time/ctime/ctime_linux.go
@@ -0,0 +1,30 @@
+//go:build linux
+
+/*
+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 ctime
+
+import (
+ "os"
+ "syscall"
+ "time"
+)
+
+func modified(fi os.FileInfo) time.Time {
+ st := fi.Sys().(*syscall.Stat_t)
+ //nolint
+ return time.Unix(int64(st.Mtim.Sec), int64(st.Mtim.Nsec))
+}
diff --git a/pkg/time/ctime/ctime_other.go b/pkg/time/ctime/ctime_other.go
new file mode 100644
index 000000000..12afc6df2
--- /dev/null
+++ b/pkg/time/ctime/ctime_other.go
@@ -0,0 +1,27 @@
+//go:build !linux
+
+/*
+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 ctime
+
+import (
+ "os"
+ "time"
+)
+
+func modified(fi os.FileInfo) time.Time {
+ return fi.ModTime()
+}
diff --git a/pkg/time/time.go b/pkg/time/time.go
index 44f3fedfb..16973b455 100644
--- a/pkg/time/time.go
+++ b/pkg/time/time.go
@@ -15,7 +15,7 @@ limitations under the License.
*/
// Package time contains a wrapper for time.Time in the standard library and
-// associated methods. This package mainly exists to workaround an issue in Go
+// associated methods. This package mainly exists to work around an issue in Go
// where the serializer doesn't omit an empty value for time:
// https://github.com/golang/go/issues/11939. As such, this can be removed if a
// proposal is ever accepted for Go
@@ -30,7 +30,7 @@ import (
var emptyString = `""`
// Time is a convenience wrapper around stdlib time, but with different
-// marshalling and unmarshaling for zero values
+// marshalling and unmarshalling for zero values
type Time struct {
time.Time
}
@@ -41,7 +41,7 @@ func Now() Time {
}
func (t Time) MarshalJSON() ([]byte, error) {
- if t.Time.IsZero() {
+ if t.IsZero() {
return []byte(emptyString), nil
}
@@ -65,13 +65,14 @@ func Parse(layout, value string) (Time, error) {
t, err := time.Parse(layout, value)
return Time{Time: t}, err
}
+
func ParseInLocation(layout, value string, loc *time.Location) (Time, error) {
t, err := time.ParseInLocation(layout, value, loc)
return Time{Time: t}, err
}
-func Date(year int, month time.Month, day, hour, min, sec, nsec int, loc *time.Location) Time {
- return Time{Time: time.Date(year, month, day, hour, min, sec, nsec, loc)}
+func Date(year int, month time.Month, day, hour, minute, second, nanoSecond int, loc *time.Location) Time {
+ return Time{Time: time.Date(year, month, day, hour, minute, second, nanoSecond, loc)}
}
func Unix(sec int64, nsec int64) Time { return Time{Time: time.Unix(sec, nsec)} }
diff --git a/pkg/time/time_test.go b/pkg/time/time_test.go
index 20f0f8e29..342ca4a10 100644
--- a/pkg/time/time_test.go
+++ b/pkg/time/time_test.go
@@ -20,64 +20,134 @@ import (
"encoding/json"
"testing"
"time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
var (
- testingTime, _ = Parse(time.RFC3339, "1977-09-02T22:04:05Z")
- testingTimeString = `"1977-09-02T22:04:05Z"`
+ timeParseString = `"1977-09-02T22:04:05Z"`
+ timeString = "1977-09-02 22:04:05 +0000 UTC"
)
-func TestNonZeroValueMarshal(t *testing.T) {
+func givenTime(t *testing.T) Time {
+ t.Helper()
+ result, err := Parse(time.RFC3339, "1977-09-02T22:04:05Z")
+ require.NoError(t, err)
+ return result
+}
+
+func TestDate(t *testing.T) {
+ testingTime := givenTime(t)
+ got := Date(1977, 9, 2, 22, 04, 05, 0, time.UTC)
+ assert.Equal(t, timeString, got.String())
+ assert.True(t, testingTime.Equal(got))
+ assert.True(t, got.Equal(testingTime))
+}
+
+func TestNow(t *testing.T) {
+ testingTime := givenTime(t)
+ got := Now()
+ assert.True(t, testingTime.Before(got))
+ assert.True(t, got.After(testingTime))
+}
+
+func TestTime_Add(t *testing.T) {
+ testingTime := givenTime(t)
+ got := testingTime.Add(time.Hour)
+ assert.Equal(t, timeString, testingTime.String())
+ assert.Equal(t, "1977-09-02 23:04:05 +0000 UTC", got.String())
+}
+
+func TestTime_AddDate(t *testing.T) {
+ testingTime := givenTime(t)
+ got := testingTime.AddDate(1, 1, 1)
+ assert.Equal(t, "1978-10-03 22:04:05 +0000 UTC", got.String())
+}
+
+func TestTime_In(t *testing.T) {
+ testingTime := givenTime(t)
+ edt, err := time.LoadLocation("America/New_York")
+ assert.NoError(t, err)
+ got := testingTime.In(edt)
+ assert.Equal(t, "America/New_York", got.Location().String())
+}
+
+func TestTime_MarshalJSONNonZero(t *testing.T) {
+ testingTime := givenTime(t)
res, err := json.Marshal(testingTime)
- if err != nil {
- t.Fatal(err)
- }
- if testingTimeString != string(res) {
- t.Errorf("expected a marshaled value of %s, got %s", testingTimeString, res)
- }
+ assert.NoError(t, err)
+ assert.Equal(t, timeParseString, string(res))
}
-func TestZeroValueMarshal(t *testing.T) {
+func TestTime_MarshalJSONZeroValue(t *testing.T) {
res, err := json.Marshal(Time{})
- if err != nil {
- t.Fatal(err)
- }
- if string(res) != emptyString {
- t.Errorf("expected zero value to marshal to empty string, got %s", res)
- }
+ assert.NoError(t, err)
+ assert.Equal(t, `""`, string(res))
}
-func TestNonZeroValueUnmarshal(t *testing.T) {
+func TestTime_Round(t *testing.T) {
+ testingTime := givenTime(t)
+ got := testingTime.Round(time.Hour)
+ assert.Equal(t, timeString, testingTime.String())
+ assert.Equal(t, "1977-09-02 22:00:00 +0000 UTC", got.String())
+}
+
+func TestTime_Sub(t *testing.T) {
+ testingTime := givenTime(t)
+ before, err := Parse(time.RFC3339, "1977-09-01T22:04:05Z")
+ require.NoError(t, err)
+ got := testingTime.Sub(before)
+ assert.Equal(t, "24h0m0s", got.String())
+}
+
+func TestTime_Truncate(t *testing.T) {
+ testingTime := givenTime(t)
+ got := testingTime.Truncate(time.Hour)
+ assert.Equal(t, timeString, testingTime.String())
+ assert.Equal(t, "1977-09-02 22:00:00 +0000 UTC", got.String())
+}
+
+func TestTime_UTC(t *testing.T) {
+ edtTime, err := Parse(time.RFC3339, "1977-09-03T05:04:05+07:00")
+ require.NoError(t, err)
+ got := edtTime.UTC()
+ assert.Equal(t, timeString, got.String())
+}
+
+func TestTime_UnmarshalJSONNonZeroValue(t *testing.T) {
+ testingTime := givenTime(t)
var myTime Time
- err := json.Unmarshal([]byte(testingTimeString), &myTime)
- if err != nil {
- t.Fatal(err)
- }
- if !myTime.Equal(testingTime) {
- t.Errorf("expected time to be equal to %v, got %v", testingTime, myTime)
- }
+ err := json.Unmarshal([]byte(timeParseString), &myTime)
+ assert.NoError(t, err)
+ assert.True(t, testingTime.Equal(myTime))
}
-func TestEmptyStringUnmarshal(t *testing.T) {
+func TestTime_UnmarshalJSONEmptyString(t *testing.T) {
var myTime Time
err := json.Unmarshal([]byte(emptyString), &myTime)
- if err != nil {
- t.Fatal(err)
- }
- if !myTime.IsZero() {
- t.Errorf("expected time to be equal to zero value, got %v", myTime)
- }
+ assert.NoError(t, err)
+ assert.True(t, myTime.IsZero())
+}
+
+func TestTime_UnmarshalJSONNullString(t *testing.T) {
+ var myTime Time
+ err := json.Unmarshal([]byte("null"), &myTime)
+ assert.NoError(t, err)
+ assert.True(t, myTime.IsZero())
}
-func TestZeroValueUnmarshal(t *testing.T) {
+func TestTime_UnmarshalJSONZeroValue(t *testing.T) {
// This test ensures that we can unmarshal any time value that was output
// with the current go default value of "0001-01-01T00:00:00Z"
var myTime Time
err := json.Unmarshal([]byte(`"0001-01-01T00:00:00Z"`), &myTime)
- if err != nil {
- t.Fatal(err)
- }
- if !myTime.IsZero() {
- t.Errorf("expected time to be equal to zero value, got %v", myTime)
- }
+ assert.NoError(t, err)
+ assert.True(t, myTime.IsZero())
+}
+
+func TestUnix(t *testing.T) {
+ got := Unix(242085845, 0)
+ assert.Equal(t, int64(242085845), got.Unix())
+ assert.Equal(t, timeString, got.UTC().String())
}
diff --git a/pkg/uploader/chart_uploader.go b/pkg/uploader/chart_uploader.go
index d7e940406..b3d612e38 100644
--- a/pkg/uploader/chart_uploader.go
+++ b/pkg/uploader/chart_uploader.go
@@ -20,10 +20,8 @@ import (
"io"
"net/url"
- "github.com/pkg/errors"
-
- "helm.sh/helm/v3/pkg/pusher"
- "helm.sh/helm/v3/pkg/registry"
+ "helm.sh/helm/v4/pkg/pusher"
+ "helm.sh/helm/v4/pkg/registry"
)
// ChartUploader handles uploading a chart.
@@ -42,7 +40,7 @@ type ChartUploader struct {
func (c *ChartUploader) UploadTo(ref, remote string) error {
u, err := url.Parse(remote)
if err != nil {
- return errors.Errorf("invalid chart URL format: %s", remote)
+ return fmt.Errorf("invalid chart URL format: %s", remote)
}
if u.Scheme == "" {
diff --git a/scripts/coverage.sh b/scripts/coverage.sh
index 2d8258866..2164d94da 100755
--- a/scripts/coverage.sh
+++ b/scripts/coverage.sh
@@ -20,10 +20,6 @@ covermode=${COVERMODE:-atomic}
coverdir=$(mktemp -d /tmp/coverage.XXXXXXXXXX)
profile="${coverdir}/cover.out"
-pushd /
-hash goveralls 2>/dev/null || go install github.com/mattn/goveralls@v0.0.11
-popd
-
generate_cover_data() {
for d in $(go list ./...) ; do
(
@@ -36,10 +32,6 @@ generate_cover_data() {
grep -h -v "^mode:" "$coverdir"/*.cover >>"$profile"
}
-push_to_coveralls() {
- goveralls -coverprofile="${profile}" -service=github
-}
-
generate_cover_data
go tool cover -func "${profile}"
@@ -47,8 +39,5 @@ case "${1-}" in
--html)
go tool cover -html "${profile}"
;;
- --coveralls)
- push_to_coveralls
- ;;
esac
diff --git a/scripts/get b/scripts/get
index 594fd92c3..45ae3275b 100755
--- a/scripts/get
+++ b/scripts/get
@@ -60,7 +60,7 @@ runAsRoot() {
# verifySupported checks that the os/arch combination is supported for
# binary builds.
verifySupported() {
- local supported="darwin-amd64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nwindows-amd64"
+ local supported="darwin-amd64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64"
if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then
echo "No prebuilt binary for ${OS}-${ARCH}."
echo "To build from source, go to https://github.com/helm/helm"
diff --git a/scripts/get-helm-3 b/scripts/get-helm-3
index b5e53bafc..3aa44daee 100755
--- a/scripts/get-helm-3
+++ b/scripts/get-helm-3
@@ -30,6 +30,7 @@ HAS_WGET="$(type "wget" &> /dev/null && echo true || echo false)"
HAS_OPENSSL="$(type "openssl" &> /dev/null && echo true || echo false)"
HAS_GPG="$(type "gpg" &> /dev/null && echo true || echo false)"
HAS_GIT="$(type "git" &> /dev/null && echo true || echo false)"
+HAS_TAR="$(type "tar" &> /dev/null && echo true || echo false)"
# initArch discovers the architecture for this system.
initArch() {
@@ -68,7 +69,7 @@ runAsRoot() {
# verifySupported checks that the os/arch combination is supported for
# binary builds, as well whether or not necessary tools are present.
verifySupported() {
- local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nwindows-amd64"
+ local supported="darwin-amd64\ndarwin-arm64\nlinux-386\nlinux-amd64\nlinux-arm\nlinux-arm64\nlinux-ppc64le\nlinux-s390x\nlinux-riscv64\nwindows-amd64\nwindows-arm64"
if ! echo "${supported}" | grep -q "${OS}-${ARCH}"; then
echo "No prebuilt binary for ${OS}-${ARCH}."
echo "To build from source, go to https://github.com/helm/helm"
@@ -102,20 +103,25 @@ verifySupported() {
if [ "${HAS_GIT}" != "true" ]; then
echo "[WARNING] Could not find git. It is required for plugin installation."
fi
+
+ if [ "${HAS_TAR}" != "true" ]; then
+ echo "[ERROR] Could not find tar. It is required to extract the helm binary archive."
+ exit 1
+ fi
}
# checkDesiredVersion checks if the desired version is available.
checkDesiredVersion() {
if [ "x$DESIRED_VERSION" == "x" ]; then
# Get tag from release URL
- local latest_release_url="https://api.github.com/repos/helm/helm/releases/latest"
+ local latest_release_url="https://get.helm.sh/helm-latest-version"
local latest_release_response=""
if [ "${HAS_CURL}" == "true" ]; then
latest_release_response=$( curl -L --silent --show-error --fail "$latest_release_url" 2>&1 || true )
elif [ "${HAS_WGET}" == "true" ]; then
- latest_release_response=$( wget "$latest_release_url" -O - 2>&1 || true )
+ latest_release_response=$( wget "$latest_release_url" -q -O - 2>&1 || true )
fi
- TAG=$( echo "$latest_release_response" | grep '"tag_name"' | sed -E 's/.*"(v[0-9\.]+)".*/\1/g' )
+ TAG=$( echo "$latest_release_response" | grep '^v[0-9]' )
if [ "x$TAG" == "x" ]; then
printf "Could not retrieve the latest release tag information from %s: %s\n" "${latest_release_url}" "${latest_release_response}"
exit 1
@@ -273,7 +279,7 @@ testVersion() {
help () {
echo "Accepted cli arguments are:"
echo -e "\t[--help|-h ] ->> prints this help"
- echo -e "\t[--version|-v ] . When not defined it fetches the latest release from GitHub"
+ echo -e "\t[--version|-v ] . When not defined it fetches the latest release tag from the Helm CDN"
echo -e "\te.g. --version v3.0.0 or -v canary"
echo -e "\t[--no-sudo] ->> install without sudo"
}
diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh
index d0dcca8ca..cea9bf4dc 100755
--- a/scripts/release-notes.sh
+++ b/scripts/release-notes.sh
@@ -89,7 +89,9 @@ Download Helm ${RELEASE}. The common platform binaries are here:
- [Linux i386](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-386.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-386.tar.gz.sha256))
- [Linux ppc64le](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-ppc64le.tar.gz.sha256))
- [Linux s390x](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-s390x.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-s390x.tar.gz.sha256))
+- [Linux riscv64](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz) ([checksum](https://get.helm.sh/helm-${RELEASE}-linux-riscv64.tar.gz.sha256sum) / $(cat _dist/helm-${RELEASE}-linux-riscv64.tar.gz.sha256))
- [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\`.
diff --git a/scripts/validate-license.sh b/scripts/validate-license.sh
index dc247436f..f67812ca5 100755
--- a/scripts/validate-license.sh
+++ b/scripts/validate-license.sh
@@ -19,7 +19,7 @@ IFS=$'\n\t'
find_files() {
find . -not \( \
\( \
- -wholename './vendor' \
+ -wholename './.git' \
-o -wholename '*testdata*' \
-o -wholename '*third_party*' \
\) -prune \
diff --git a/testdata/localhost-crt.pem b/testdata/localhost-crt.pem
new file mode 100644
index 000000000..70fa0a429
--- /dev/null
+++ b/testdata/localhost-crt.pem
@@ -0,0 +1,73 @@
+Certificate:
+ Data:
+ Version: 3 (0x2)
+ Serial Number:
+ 7f:5e:fa:21:fa:ee:e4:6a:be:9b:c2:80:bf:ed:42:f3:2d:47:f5:d2
+ Signature Algorithm: sha256WithRSAEncryption
+ Issuer: C=US, ST=CO, L=Boulder, O=Helm, CN=helm.sh
+ Validity
+ Not Before: Nov 6 21:59:18 2023 GMT
+ Not After : Nov 3 21:59:18 2033 GMT
+ Subject: C=CA, ST=ON, L=Kitchener, O=Helm, CN=localhost
+ Subject Public Key Info:
+ Public Key Algorithm: rsaEncryption
+ RSA Public-Key: (2048 bit)
+ Modulus:
+ 00:c8:89:55:0d:0b:f1:da:e6:c0:70:7d:d3:27:cd:
+ b8:a8:81:8b:7c:a4:89:e5:d1:b1:78:01:1d:df:44:
+ 88:0b:fc:d6:81:35:3d:d1:3b:5e:8f:bb:93:b3:7e:
+ 28:db:ed:ff:a0:13:3a:70:a3:fe:94:6b:0b:fe:fb:
+ 63:00:b0:cb:dc:81:cd:80:dc:d0:2f:bf:b2:4f:9a:
+ 81:d4:22:dc:97:c8:8f:27:86:59:91:fa:92:05:75:
+ c4:cc:6b:f5:a9:6b:74:1e:f5:db:a9:f8:bf:8c:a2:
+ 25:fd:a0:cc:79:f4:25:57:74:a9:23:9b:e2:b7:22:
+ 7a:14:7a:3d:ea:f1:7e:32:6b:57:6c:2e:c6:4f:75:
+ 54:f9:6b:54:d2:ca:eb:54:1c:af:39:15:9b:d0:7c:
+ 0f:f8:55:51:04:ea:da:fa:7b:8b:63:0f:ac:39:b1:
+ f6:4b:8e:4e:f6:ea:e9:7b:e6:ba:5e:5a:8e:91:ef:
+ dc:b1:7d:52:3f:73:83:52:46:83:48:49:ff:f2:2d:
+ ca:54:f2:36:bb:49:cc:59:99:c0:9e:cf:8e:78:55:
+ 6c:ed:7d:7e:83:b8:59:2c:7d:f8:1a:81:f0:7d:f5:
+ 27:f2:db:ae:d4:31:54:38:fe:47:b2:ee:16:20:0f:
+ f1:db:2d:28:bf:6f:38:eb:11:bb:9a:d4:b2:5a:3a:
+ 4a:7f
+ Exponent: 65537 (0x10001)
+ X509v3 extensions:
+ X509v3 Subject Alternative Name:
+ DNS:localhost
+ Signature Algorithm: sha256WithRSAEncryption
+ 47:47:fe:29:ca:94:28:75:59:ba:ab:67:ab:c6:a6:0b:0a:f2:
+ 0f:26:d9:1d:35:db:68:a5:d8:f5:1f:d1:87:e7:a7:74:fd:c0:
+ 22:aa:c8:ec:6c:d3:ac:8a:0b:ed:59:3a:a0:12:77:7c:53:74:
+ fd:30:59:34:8f:a4:ef:5b:98:3f:ff:cf:89:87:ed:d3:7f:41:
+ 2f:b1:9a:12:71:bb:fe:3a:cf:77:16:32:bc:83:90:cc:52:2f:
+ 3b:f4:ae:db:b1:bb:f0:dd:30:d4:03:17:5e:47:b7:06:86:7a:
+ 16:b1:72:2f:80:5d:d4:c0:f9:6c:91:df:5a:c5:15:86:66:68:
+ c8:90:8e:f1:a2:bb:40:0f:ef:26:1b:02:c4:42:de:8c:69:ec:
+ ad:27:d0:bc:da:7c:76:33:86:de:b7:c4:04:64:e6:f6:dc:44:
+ 89:7b:b8:2f:c7:28:7a:4c:a6:01:ad:a5:17:64:3a:23:da:aa:
+ db:ce:3f:86:e9:92:dc:0d:c4:5a:b4:52:a8:8a:ee:3d:62:7d:
+ b1:c8:fa:ef:96:2b:ab:f1:e1:6d:6f:7d:1e:ce:bc:7a:d0:92:
+ 02:1b:c8:55:36:77:bf:d4:42:d3:fc:57:ca:b7:cc:95:be:ce:
+ f8:6e:b2:28:ca:4d:9a:00:7d:78:c8:56:04:2e:b3:ac:03:fa:
+ 05:d8:42:bd
+-----BEGIN CERTIFICATE-----
+MIIDRDCCAiygAwIBAgIUf176Ifru5Gq+m8KAv+1C8y1H9dIwDQYJKoZIhvcNAQEL
+BQAwTTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNPMRAwDgYDVQQHDAdCb3VsZGVy
+MQ0wCwYDVQQKDARIZWxtMRAwDgYDVQQDDAdoZWxtLnNoMB4XDTIzMTEwNjIxNTkx
+OFoXDTMzMTEwMzIxNTkxOFowUTELMAkGA1UEBhMCQ0ExCzAJBgNVBAgMAk9OMRIw
+EAYDVQQHDAlLaXRjaGVuZXIxDTALBgNVBAoMBEhlbG0xEjAQBgNVBAMMCWxvY2Fs
+aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMiJVQ0L8drmwHB9
+0yfNuKiBi3ykieXRsXgBHd9EiAv81oE1PdE7Xo+7k7N+KNvt/6ATOnCj/pRrC/77
+YwCwy9yBzYDc0C+/sk+agdQi3JfIjyeGWZH6kgV1xMxr9alrdB7126n4v4yiJf2g
+zHn0JVd0qSOb4rciehR6PerxfjJrV2wuxk91VPlrVNLK61QcrzkVm9B8D/hVUQTq
+2vp7i2MPrDmx9kuOTvbq6Xvmul5ajpHv3LF9Uj9zg1JGg0hJ//ItylTyNrtJzFmZ
+wJ7PjnhVbO19foO4WSx9+BqB8H31J/LbrtQxVDj+R7LuFiAP8dstKL9vOOsRu5rU
+slo6Sn8CAwEAAaMYMBYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0GCSqGSIb3DQEB
+CwUAA4IBAQBHR/4pypQodVm6q2erxqYLCvIPJtkdNdtopdj1H9GH56d0/cAiqsjs
+bNOsigvtWTqgEnd8U3T9MFk0j6TvW5g//8+Jh+3Tf0EvsZoScbv+Os93FjK8g5DM
+Ui879K7bsbvw3TDUAxdeR7cGhnoWsXIvgF3UwPlskd9axRWGZmjIkI7xortAD+8m
+GwLEQt6MaeytJ9C82nx2M4bet8QEZOb23ESJe7gvxyh6TKYBraUXZDoj2qrbzj+G
+6ZLcDcRatFKoiu49Yn2xyPrvliur8eFtb30ezrx60JICG8hVNne/1ELT/FfKt8yV
+vs74brIoyk2aAH14yFYELrOsA/oF2EK9
+-----END CERTIFICATE-----
diff --git a/testdata/openssl.conf b/testdata/openssl.conf
index 9b27e445b..be5ff04b7 100644
--- a/testdata/openssl.conf
+++ b/testdata/openssl.conf
@@ -40,3 +40,7 @@ subjectAltName = @alternate_names
[alternate_names]
DNS.1 = helm.sh
IP.1 = 127.0.0.1
+
+# # Used to generate localhost-crt.pem
+# [alternate_names]
+# DNS.1 = localhost