Merge remote-tracking branch 'origin/main' into em/check-go-modules

Signed-off-by: Evans Mungai <mbuevans@gmail.com>
pull/31116/head
Evans Mungai 3 days ago
commit 5dabfdfb3f
No known key found for this signature in database
GPG Key ID: BBEB812143DD14E1

@ -2,8 +2,6 @@ name: "Close stale issues"
on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
stale:
@ -13,7 +11,8 @@ jobs:
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

@ -13,15 +13,15 @@ GOX = $(GOBIN)/gox
GOIMPORTS = $(GOBIN)/goimports
ARCH = $(shell go env GOARCH)
ACCEPTANCE_DIR:=../acceptance-testing
ACCEPTANCE_DIR := ../acceptance-testing
# To specify the subset of acceptance tests to run. '.' means all tests
ACCEPTANCE_RUN_TESTS=.
ACCEPTANCE_RUN_TESTS = .
# go option
PKG := ./...
TAGS :=
TESTS := .
TESTFLAGS :=
TESTFLAGS := -shuffle=on -count=1
LDFLAGS := -w -s
GOFLAGS :=
CGO_ENABLED ?= 0
@ -227,25 +227,22 @@ clean:
.PHONY: release-notes
release-notes:
@if [ ! -d "./_dist" ]; then \
echo "please run 'make fetch-dist' first" && \
exit 1; \
fi
@if [ -z "${PREVIOUS_RELEASE}" ]; then \
echo "please set PREVIOUS_RELEASE environment variable" \
&& exit 1; \
fi
@./scripts/release-notes.sh ${PREVIOUS_RELEASE} ${VERSION}
@if [ ! -d "./_dist" ]; then \
echo "please run 'make fetch-dist' first" && \
exit 1; \
fi
@if [ -z "${PREVIOUS_RELEASE}" ]; then \
echo "please set PREVIOUS_RELEASE environment variable" && \
exit 1; \
fi
@./scripts/release-notes.sh ${PREVIOUS_RELEASE} ${VERSION}
.PHONY: info
info:
@echo "Version: ${VERSION}"
@echo "Git Tag: ${GIT_TAG}"
@echo "Git Commit: ${GIT_COMMIT}"
@echo "Git Tree State: ${GIT_DIRTY}"
@echo "Version: ${VERSION}"
@echo "Git Tag: ${GIT_TAG}"
@echo "Git Commit: ${GIT_COMMIT}"
@echo "Git Tree State: ${GIT_DIRTY}"
.PHONY: tidy
tidy:

@ -14,6 +14,7 @@ require (
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/extism/go-sdk v1.7.1
github.com/fatih/color v1.18.0
github.com/fluxcd/cli-utils v0.36.0-flux.14
github.com/foxcpp/go-mockdns v1.1.0
@ -25,24 +26,27 @@ require (
github.com/mattn/go-shellwords v1.0.12
github.com/mitchellh/copystructure v1.2.0
github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/rubenv/sql-migrate v1.8.0
github.com/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
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.41.0
golang.org/x/term v0.34.0
golang.org/x/text v0.28.0
k8s.io/api v0.33.3
k8s.io/apiextensions-apiserver v0.33.3
k8s.io/apimachinery v0.33.3
k8s.io/apiserver v0.33.3
k8s.io/cli-runtime v0.33.3
k8s.io/client-go v0.33.3
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.3
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
@ -69,6 +73,7 @@ require (
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/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // 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
@ -93,6 +98,7 @@ require (
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/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // 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
@ -114,7 +120,6 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/onsi/gomega v1.37.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
@ -129,6 +134,7 @@ require (
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/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
@ -169,8 +175,7 @@ require (
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.33.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

@ -77,12 +77,16 @@ github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ
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/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@ -164,6 +168,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvH
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/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
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.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
@ -309,8 +315,12 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
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=
@ -500,26 +510,26 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8=
k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE=
k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs=
k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8=
k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4=
k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E=
k8s.io/cli-runtime v0.33.3 h1:Dgy4vPjNIu8LMJBSvs8W0LcdV0PX/8aGG1DA1W8lklA=
k8s.io/cli-runtime v0.33.3/go.mod h1:yklhLklD4vLS8HNGgC9wGiuHWze4g7x6XQZ+8edsKEo=
k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA=
k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg=
k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA=
k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4=
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.3 h1:r/phHvH1iU7gO/l7tTjQk2K01ER7/OAJi8uFHHyWSac=
k8s.io/kubectl v0.33.3/go.mod h1:euj2bG56L6kUGOE/ckZbCoudPwuj4Kud7BR0GzyNiT0=
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=

@ -733,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

@ -16,6 +16,7 @@ limitations under the License.
package util
import (
"fmt"
"log/slog"
"strings"
@ -265,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,

@ -64,7 +64,7 @@ func (h *DebugCheckHandler) WithGroup(name string) slog.Handler {
// 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{
baseHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
// Always use LevelDebug here to allow all messages through
// Our custom handler will do the filtering
Level: slog.LevelDebug,

@ -14,7 +14,7 @@ limitations under the License.
*/
// Package cache provides a key generator for vcs urls.
package cache // import "helm.sh/helm/v4/pkg/plugin/cache"
package cache // import "helm.sh/helm/v4/internal/plugin/cache"
import (
"net/url"

@ -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 plugin
import (
"fmt"
"go.yaml.in/yaml/v3"
)
// Config interface defines the methods that all plugin type configurations must implement
type Config interface {
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 <this-command>' 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"`
}
// ConfigPostrenderer represents the configuration for postrenderer plugins
// there are no runtime-independent configurations for postrenderer/v1 plugin type
type ConfigPostrenderer struct{}
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 (c *ConfigPostrenderer) Validate() error {
// Config validation for postrenderer plugins
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
}

@ -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
}

@ -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

@ -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()
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"path/filepath"

@ -11,7 +11,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"testing"

@ -14,4 +14,4 @@ limitations under the License.
*/
// Package installer provides an interface for installing Helm plugins.
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
@ -22,7 +22,6 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"os"
"path"
"path/filepath"
@ -31,23 +30,8 @@ import (
"strings"
securejoin "github.com/cyphar/filepath-securejoin"
"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/plugin/cache"
)
// HTTPInstaller installs plugins from an archive served by a web server.
type HTTPInstaller struct {
CacheDir string
PluginName string
base
extractor Extractor
getter getter.Getter
}
// TarGzExtractor extracts gzip compressed tar archives
type TarGzExtractor struct{}
@ -69,6 +53,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
}
@ -84,87 +71,6 @@ func NewExtractor(source string) (Extractor, error) {
return nil, fmt.Errorf("no extractor implemented yet for %s", source)
}
// NewHTTPInstaller creates a new HttpInstaller.
func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
key, err := cache.Key(source)
if err != nil {
return nil, err
}
extractor, err := NewExtractor(source)
if err != nil {
return nil, err
}
get, err := getter.All(new(cli.EnvSettings)).ByScheme("http")
if err != nil {
return nil, err
}
i := &HTTPInstaller{
CacheDir: helmpath.CachePath("plugins", key),
PluginName: stripPluginName(filepath.Base(source)),
base: newBase(source),
extractor: extractor,
getter: get,
}
return i, nil
}
// helper that relies on some sort of convention for plugin name (plugin-name-<version>)
func stripPluginName(name string) string {
var strippedName string
for suffix := range Extractors {
if strings.HasSuffix(name, suffix) {
strippedName = strings.TrimSuffix(name, suffix)
break
}
}
re := regexp.MustCompile(`(.*)-[0-9]+\..*`)
return re.ReplaceAllString(strippedName, `$1`)
}
// Install downloads and extracts the tarball into the cache directory
// and installs into the plugin directory.
//
// Implements Installer.
func (i *HTTPInstaller) Install() error {
pluginData, err := i.getter.Get(i.Source)
if err != nil {
return err
}
if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil {
return fmt.Errorf("extracting files from archive: %w", err)
}
if !isPlugin(i.CacheDir) {
return ErrMissingMetadata
}
src, err := filepath.Abs(i.CacheDir)
if err != nil {
return err
}
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 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.Source == "" {
return ""
}
return helmpath.DataPath("plugins", i.PluginName)
}
// cleanJoin resolves dest as a subpath of root.
//
// This function runs several security checks on the path, generating an error if
@ -248,10 +154,14 @@ 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
@ -270,3 +180,16 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
}
return nil
}
// stripPluginName is a helper that relies on some sort of convention for plugin name (plugin-name-<version>)
func stripPluginName(name string) string {
var strippedName string
for suffix := range Extractors {
if strings.HasSuffix(name, suffix) {
strippedName = strings.TrimSuffix(name, suffix)
break
}
}
re := regexp.MustCompile(`(.*)-[0-9]+\..*`)
return re.ReplaceAllString(strippedName, `$1`)
}

@ -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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugin"
"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.
type HTTPInstaller struct {
CacheDir string
PluginName string
base
extractor Extractor
getter getter.Getter
// Cached data to avoid duplicate downloads
pluginData []byte
provData []byte
}
// NewHTTPInstaller creates a new HttpInstaller.
func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
key, err := cache.Key(source)
if err != nil {
return nil, err
}
extractor, err := NewExtractor(source)
if err != nil {
return nil, err
}
get, err := getter.All(new(cli.EnvSettings)).ByScheme("http")
if err != nil {
return nil, err
}
i := &HTTPInstaller{
CacheDir: helmpath.CachePath("plugins", key),
PluginName: stripPluginName(filepath.Base(source)),
base: newBase(source),
extractor: extractor,
getter: get,
}
return i, nil
}
// Install downloads and extracts the tarball into the cache directory
// and installs into the plugin directory.
//
// Implements Installer.
func (i *HTTPInstaller) Install() error {
// Ensure plugin data is cached
if i.pluginData == nil {
pluginData, err := i.getter.Get(i.Source)
if err != nil {
return err
}
i.pluginData = pluginData.Bytes()
}
// Save the original tarball to plugins directory for verification
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Ensure prov data is cached if available
if i.provData == nil {
// Try to download .prov file if it exists
provURL := i.Source + ".prov"
if provData, err := i.getter.Get(provURL); err == nil {
i.provData = provData.Bytes()
}
}
// Save prov file if we have the data
if i.provData != nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
slog.Debug("failed to save provenance file", "error", err)
}
}
if err := i.extractor.Extract(bytes.NewBuffer(i.pluginData), i.CacheDir); err != nil {
return fmt.Errorf("extracting files from archive: %w", err)
}
// Detect where the plugin.yaml actually is
pluginRoot, err := detectPluginRoot(i.CacheDir)
if err != nil {
return err
}
// 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
}
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 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.Source == "" {
return ""
}
return helmpath.DataPath("plugins", i.PluginName)
}
// SupportsVerification returns true if the HTTP installer can verify plugins
func (i *HTTPInstaller) SupportsVerification() bool {
// Only support verification for tarball URLs
return strings.HasSuffix(i.Source, ".tgz") || strings.HasSuffix(i.Source, ".tar.gz")
}
// GetVerificationData returns cached plugin and provenance data for verification
func (i *HTTPInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
if !i.SupportsVerification() {
return nil, nil, "", fmt.Errorf("verification not supported for this source")
}
// Download plugin data once and cache it
if i.pluginData == nil {
data, err := i.getter.Get(i.Source)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to download plugin: %w", err)
}
i.pluginData = data.Bytes()
}
// Download prov data once and cache it if available
if i.provData == nil {
provData, err := i.getter.Get(i.Source + ".prov")
if err != nil {
// If provenance file doesn't exist, set provData to nil
// The verification logic will handle this gracefully
i.provData = nil
} else {
i.provData = provData.Bytes()
}
}
return i.pluginData, i.provData, filepath.Base(i.Source), nil
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
@ -49,7 +49,7 @@ func (t *TestHTTPGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error
}
// Fake plugin tarball data
var fakePluginB64 = "H4sIAKRj51kAA+3UX0vCUBgGcC9jn+Iwuk3Peza3GeyiUlJQkcogCOzgli7dJm4TvYk+a5+k479UqquUCJ/fLs549sLO2TnvWnJa9aXnjwujYdYLovxMhsPcfnHOLdNkOXthM/IVQQYjg2yyLLJ4kXGhLp5j0z3P41tZksqxmspL3B/O+j/XtZu1y8rdYzkOZRCxduKPk53ny6Wwz/GfIIf1As8lxzGJSmoHNLJZphKHG4YpTCE0wVk3DULfpSJ3DMMqkj3P5JfMYLdX1Vr9Ie/5E5cstcdC8K04iGLX5HaJuKpWL17F0TCIBi5pf/0pjtLhun5j3f9v6r7wfnI/H0eNp9d1/5P6Gez0vzo7wsoxfrAZbTny/o9k6J8z/VkO/LPlWdC1iVpbEEcq5nmeJ13LEtmbV0k2r2PrOs9PuuNglC5rL1Y5S/syXRQmutaNw1BGnnp8Wq3UG51WvX1da3bKtZtCN/R09DwAAAAAAAAAAAAAAAAAAADAb30AoMczDwAoAAA="
var fakePluginB64 = "H4sIAAAAAAAAA+3SQUvDMBgG4Jz7K0LwapdvSxrwJig6mCKC5xHabBaXdDSt4L+3cQ56mV42ZPg+lw+SF5LwZmXf3OV206/rMGEnIgdG6zTJaDmee4y01FOlZpqGHJGZSsb1qS401sfOtpyz0FTup9xv+2dqNep/N/IP6zdHPSMVXCh1sH8yhtGMDBUFFTL1r4iIcXnUWxzwz/sP1rsrLkbfQGTvro11E4ZlmcucRNZHu04py1OO73OVi2Vbb7td9vp7nXevtvsKRpGVjfc2VMP2xf3t4mH5tHi5mz8ub+bPk9JXIvvr5wMAAAAAAAAAAAAAAAAAAAAAnLVPqwHcXQAoAAA="
func TestStripName(t *testing.T) {
if stripPluginName("fake-plugin-0.0.1.tar.gz") != "fake-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,14 +272,19 @@ 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 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")
@ -290,8 +293,9 @@ func TestExtract(t *testing.T) {
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,250 @@ 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) {
ensure.HelmHome(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)
}
}

@ -17,12 +17,14 @@ package installer
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/pkg/plugin"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/registry"
)
// ErrMissingMetadata indicates that plugin.yaml is missing.
@ -31,6 +33,14 @@ var ErrMissingMetadata = errors.New("plugin metadata (plugin.yaml) missing")
// Debug enables verbose output.
var Debug bool
// Options contains options for plugin installation.
type Options struct {
// Verify enables signature verification before installation
Verify bool
// Keyring is the path to the keyring for verification
Keyring string
}
// Installer provides an interface for installing helm client plugins.
type Installer interface {
// Install adds a plugin.
@ -41,15 +51,80 @@ type Installer interface {
Update() error
}
// Verifier provides an interface for installers that support verification.
type Verifier interface {
// SupportsVerification returns true if this installer can verify plugins
SupportsVerification() bool
// GetVerificationData returns plugin and provenance data for verification
GetVerificationData() (archiveData, provData []byte, filename string, err error)
}
// Install installs a plugin.
func Install(i Installer) error {
_, err := InstallWithOptions(i, Options{})
return err
}
// VerificationResult contains the result of plugin verification
type VerificationResult struct {
SignedBy []string
Fingerprint string
FileHash string
}
// InstallWithOptions installs a plugin with options.
func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) {
if err := os.MkdirAll(filepath.Dir(i.Path()), 0755); err != nil {
return err
return nil, err
}
if _, pathErr := os.Stat(i.Path()); !os.IsNotExist(pathErr) {
return errors.New("plugin already exists")
return nil, errors.New("plugin already exists")
}
return i.Install()
var result *VerificationResult
// If verification is requested, check if installer supports it
if opts.Verify {
verifier, ok := i.(Verifier)
if !ok || !verifier.SupportsVerification() {
return nil, fmt.Errorf("--verify is only supported for plugin tarballs (.tgz files)")
}
// Get verification data (works for both memory and file-based installers)
archiveData, provData, filename, err := verifier.GetVerificationData()
if err != nil {
return nil, fmt.Errorf("failed to get verification data: %w", err)
}
// Check if provenance data exists
if len(provData) == 0 {
// No .prov file found - emit warning but continue installation
fmt.Fprintf(os.Stderr, "WARNING: No provenance file found for plugin. Plugin is not signed and cannot be verified.\n")
} else {
// Provenance data exists - verify the plugin
verification, err := plugin.VerifyPlugin(archiveData, provData, filename, opts.Keyring)
if err != nil {
return nil, fmt.Errorf("plugin verification failed: %w", err)
}
// Collect verification info
result = &VerificationResult{
SignedBy: make([]string, 0),
Fingerprint: fmt.Sprintf("%X", verification.SignedBy.PrimaryKey.Fingerprint),
FileHash: verification.FileHash,
}
for name := range verification.SignedBy.Identities {
result.SignedBy = append(result.SignedBy, name)
}
}
}
if err := i.Install(); err != nil {
return nil, err
}
return result, nil
}
// Update updates a plugin.
@ -62,6 +137,10 @@ func Update(i Installer) error {
// NewForSource determines the correct Installer for the given source.
func NewForSource(source, version string) (Installer, error) {
// Check if source is an OCI registry reference
if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) {
return NewOCIInstaller(source)
}
// Check if source is a local directory
if isLocalReference(source) {
return NewLocalInstaller(source)
@ -92,6 +171,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

@ -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) {

@ -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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"bytes"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/helmpath"
)
// 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
pluginData []byte // Cached plugin data
provData []byte // Cached provenance data
}
// 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)
}
// Copy the original tarball to plugins directory for verification
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, data, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Check for and copy .prov file if it exists
provSource := i.Source + ".prov"
if provData, err := os.ReadFile(provSource); err == nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, provData, 0644); err != nil {
slog.Debug("failed to save provenance file", "error", 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)
}
// Plugin directory should be named after the plugin at the archive root
pluginName := stripPluginName(filepath.Base(i.Source))
pluginDir := filepath.Join(tempDir, pluginName)
if _, err = os.Stat(filepath.Join(pluginDir, "plugin.yaml")); err != nil {
return fmt.Errorf("plugin.yaml not found in expected directory %s: %w", pluginDir, err)
}
// Copy to the final destination
slog.Debug("copying", "source", pluginDir, "path", i.Path())
return fs.CopyDir(pluginDir, i.Path())
}
// Update updates a local repository
func (i *LocalInstaller) Update() error {
slog.Debug("local repository is auto-updated")
return nil
}
// Path is overridden to handle archive plugin names properly
func (i *LocalInstaller) Path() string {
if i.Source == "" {
return ""
}
pluginName := filepath.Base(i.Source)
if i.isArchive {
// Strip archive extension to get plugin name
pluginName = stripPluginName(pluginName)
}
return helmpath.DataPath("plugins", pluginName)
}
// SupportsVerification returns true if the local installer can verify plugins
func (i *LocalInstaller) SupportsVerification() bool {
// Only support verification for local tarball files
return i.isArchive
}
// GetVerificationData loads plugin and provenance data from local files for verification
func (i *LocalInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
if !i.SupportsVerification() {
return nil, nil, "", fmt.Errorf("verification not supported for directories")
}
// Read and cache the plugin archive file
if i.pluginData == nil {
i.pluginData, err = os.ReadFile(i.Source)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to read plugin file: %w", err)
}
}
// Read and cache the provenance file if it exists
if i.provData == nil {
provFile := i.Source + ".prov"
i.provData, err = os.ReadFile(provFile)
if err != nil {
if os.IsNotExist(err) {
// If provenance file doesn't exist, set provData to nil
// The verification logic will handle this gracefully
i.provData = nil
} else {
// If file exists but can't be read (permissions, etc), return error
return nil, nil, "", fmt.Errorf("failed to access provenance file %s: %w", provFile, err)
}
}
}
return i.pluginData, i.provData, filepath.Base(i.Source), nil
}

@ -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 (
"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
}{
{"test-plugin/plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644},
{"test-plugin/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)
}
}

@ -0,0 +1,301 @@
/*
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"
"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"
)
// Ensure OCIInstaller implements Verifier
var _ Verifier = (*OCIInstaller)(nil)
// OCIInstaller installs plugins from OCI registries
type OCIInstaller struct {
CacheDir string
PluginName string
base
settings *cli.EnvSettings
getter getter.Getter
// Cached data to avoid duplicate downloads
pluginData []byte
provData []byte
}
// 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)
// Ensure plugin data is cached
if i.pluginData == nil {
pluginData, err := i.getter.Get(i.Source)
if err != nil {
return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
}
i.pluginData = pluginData.Bytes()
}
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Ensure prov data is cached if available
if i.provData == nil {
// Try to download .prov file if it exists
provSource := i.Source + ".prov"
if provData, err := i.getter.Get(provSource); err == nil {
i.provData = provData.Bytes()
}
}
// Save prov file if we have the data
if i.provData != nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
slog.Debug("failed to save provenance file", "error", err)
}
}
// Check if this is a gzip compressed file
if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b {
return fmt.Errorf("plugin data is not a gzip compressed archive")
}
// Create cache directory
if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
// Extract as gzipped tar
if err := extractTarGz(bytes.NewReader(i.pluginData), 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
}
// SupportsVerification returns true since OCI plugins can be verified
func (i *OCIInstaller) SupportsVerification() bool {
return true
}
// GetVerificationData downloads and caches plugin and provenance data from OCI registry for verification
func (i *OCIInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
slog.Debug("getting verification data for OCI plugin", "source", i.Source)
// Download plugin data once and cache it
if i.pluginData == nil {
pluginDataBuffer, err := i.getter.Get(i.Source)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
}
i.pluginData = pluginDataBuffer.Bytes()
}
// Download prov data once and cache it if available
if i.provData == nil {
provSource := i.Source + ".prov"
// Calling getter.Get again is reasonable because: 1. The OCI registry client already optimizes the underlying network calls
// 2. Both calls use the same underlying manifest and memory store 3. The second .prov call is very fast since the data is already pulled
provDataBuffer, err := i.getter.Get(provSource)
if err != nil {
// If provenance file doesn't exist, set provData to nil
// The verification logic will handle this gracefully
i.provData = nil
} else {
i.provData = provDataBuffer.Bytes()
}
}
// Extract metadata to get the filename
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return nil, nil, "", fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename = fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
slog.Debug("got verification data for OCI plugin", "filename", filename)
return i.pluginData, i.provData, filename, nil
}

@ -0,0 +1,806 @@
/*
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/internal/test/ensure"
"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 + "-1.0.0.tgz", // Layer named with version
},
},
},
}
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
ensure.HelmHome(t)
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 is isolated by ensure.HelmHome(t)
actualPath := installer.Path()
t.Logf("Installer will use path: %s", actualPath)
// 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
ensure.HelmHome(t)
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
ensure.HelmHome(t)
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
ensure.HelmHome(t)
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'")
}
}
})
}
}

@ -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
}

@ -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)
}
}
})
}
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"errors"
@ -26,9 +26,9 @@ import (
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/plugin/cache"
)
// VCSInstaller installs plugins from remote a repository.

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package installer // import "helm.sh/helm/v4/pkg/plugin/installer"
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"fmt"
@ -57,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"},
@ -83,8 +83,9 @@ func TestVCSInstaller(t *testing.T) {
if repo.current != "0.1.1" {
t.Fatalf("expected version '0.1.1', got %q", repo.current)
}
if i.Path() != helmpath.DataPath("plugins", "helm-env") {
t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path())
expectedPath := helmpath.DataPath("plugins", "helm-env")
if i.Path() != expectedPath {
t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
}
// Install again to test plugin exists error

@ -0,0 +1,421 @@
/*
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 (
"bytes"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/test/ensure"
)
func TestInstallWithOptions_VerifyMissingProvenance(t *testing.T) {
ensure.HelmHome(t)
// Create a temporary plugin tarball without .prov file
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
defer os.Remove(pluginTgz)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Capture stderr to check warning message
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
// Install with verification enabled (should warn but succeed)
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
// Restore stderr and read captured output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Should succeed with nil result (no verification performed)
if err != nil {
t.Fatalf("Expected installation to succeed despite missing .prov file, got error: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when .prov file is missing, got: %+v", result)
}
// Should contain warning message
expectedWarning := "WARNING: No provenance file found for plugin"
if !strings.Contains(output, expectedWarning) {
t.Errorf("Expected warning message '%s' in output, got: %s", expectedWarning, output)
}
// Plugin should be installed
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
t.Errorf("Plugin should be installed at %s", installer.Path())
}
}
func TestInstallWithOptions_VerifyWithValidProvenance(t *testing.T) {
ensure.HelmHome(t)
// Create a temporary plugin tarball with valid .prov file
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
provFile := pluginTgz + ".prov"
createProvFile(t, provFile, pluginTgz, "")
defer os.Remove(provFile)
// Create keyring with test key (empty for testing)
keyring := createTestKeyring(t)
defer os.Remove(keyring)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install with verification enabled
// This will fail signature verification but pass hash validation
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
// Should fail due to invalid signature (empty keyring) but we test that it gets past the hash check
if err == nil {
t.Fatalf("Expected installation to fail with empty keyring")
}
if !strings.Contains(err.Error(), "plugin verification failed") {
t.Errorf("Expected plugin verification failed error, got: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
}
// Plugin should not be installed due to verification failure
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
t.Errorf("Plugin should not be installed when verification fails")
}
}
func TestInstallWithOptions_VerifyWithInvalidProvenance(t *testing.T) {
ensure.HelmHome(t)
// Create a temporary plugin tarball with invalid .prov file
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
defer os.Remove(pluginTgz)
provFile := pluginTgz + ".prov"
createProvFileInvalidFormat(t, provFile)
defer os.Remove(provFile)
// Create keyring with test key
keyring := createTestKeyring(t)
defer os.Remove(keyring)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install with verification enabled (should fail)
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
// Should fail with verification error
if err == nil {
t.Fatalf("Expected installation with invalid .prov file to fail")
}
if result != nil {
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
}
// Should contain verification failure message
expectedError := "plugin verification failed"
if !strings.Contains(err.Error(), expectedError) {
t.Errorf("Expected error message '%s', got: %s", expectedError, err.Error())
}
// Plugin should not be installed
if _, err := os.Stat(installer.Path()); !os.IsNotExist(err) {
t.Errorf("Plugin should not be installed when verification fails")
}
}
func TestInstallWithOptions_NoVerifyRequested(t *testing.T) {
ensure.HelmHome(t)
// Create a temporary plugin tarball without .prov file
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
defer os.Remove(pluginTgz)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install without verification (should succeed without any verification)
result, err := InstallWithOptions(installer, Options{Verify: false})
// Should succeed with no verification
if err != nil {
t.Fatalf("Expected installation without verification to succeed, got error: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when verification is disabled, got: %+v", result)
}
// Plugin should be installed
if _, err := os.Stat(installer.Path()); os.IsNotExist(err) {
t.Errorf("Plugin should be installed at %s", installer.Path())
}
}
func TestInstallWithOptions_VerifyDirectoryNotSupported(t *testing.T) {
ensure.HelmHome(t)
// Create a directory-based plugin (not an archive)
pluginDir := createTestPluginDir(t)
// Create local installer for directory
installer, err := NewLocalInstaller(pluginDir)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install with verification should fail (directories don't support verification)
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: "dummy"})
// Should fail with verification not supported error
if err == nil {
t.Fatalf("Expected installation to fail with verification not supported error")
}
if !strings.Contains(err.Error(), "--verify is only supported for plugin tarballs") {
t.Errorf("Expected verification not supported error, got: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
}
}
func TestInstallWithOptions_VerifyMismatchedProvenance(t *testing.T) {
ensure.HelmHome(t)
// Create plugin tarball
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
defer os.Remove(pluginTgz)
provFile := pluginTgz + ".prov"
// Create provenance file with wrong hash (for a different file)
createProvFile(t, provFile, pluginTgz, "sha256:wronghash")
defer os.Remove(provFile)
// Create keyring with test key
keyring := createTestKeyring(t)
defer os.Remove(keyring)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install with verification should fail due to hash mismatch
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
// Should fail with verification error
if err == nil {
t.Fatalf("Expected installation to fail with hash mismatch")
}
if !strings.Contains(err.Error(), "plugin verification failed") {
t.Errorf("Expected plugin verification failed error, got: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
}
}
func TestInstallWithOptions_VerifyProvenanceAccessError(t *testing.T) {
ensure.HelmHome(t)
// Create plugin tarball
pluginDir := createTestPluginDir(t)
pluginTgz := createTarballFromPluginDir(t, pluginDir)
defer os.Remove(pluginTgz)
// Create a .prov file but make it inaccessible (simulate permission error)
provFile := pluginTgz + ".prov"
if err := os.WriteFile(provFile, []byte("test"), 0000); err != nil {
t.Fatalf("Failed to create inaccessible provenance file: %v", err)
}
defer os.Remove(provFile)
// Create keyring
keyring := createTestKeyring(t)
defer os.Remove(keyring)
// Create local installer
installer, err := NewLocalInstaller(pluginTgz)
if err != nil {
t.Fatalf("Failed to create installer: %v", err)
}
defer os.RemoveAll(installer.Path())
// Install with verification should fail due to access error
result, err := InstallWithOptions(installer, Options{Verify: true, Keyring: keyring})
// Should fail with access error (either at stat level or during verification)
if err == nil {
t.Fatalf("Expected installation to fail with provenance file access error")
}
// The error could be either "failed to access provenance file" or "plugin verification failed"
// depending on when the permission error occurs
if !strings.Contains(err.Error(), "failed to access provenance file") &&
!strings.Contains(err.Error(), "plugin verification failed") {
t.Errorf("Expected provenance file access or verification error, got: %v", err)
}
if result != nil {
t.Errorf("Expected nil verification result when verification fails, got: %+v", result)
}
}
// Helper functions for test setup
func createTestPluginDir(t *testing.T) string {
t.Helper()
// Create temporary directory with plugin structure
tmpDir := t.TempDir()
pluginDir := filepath.Join(tmpDir, "test-plugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatalf("Failed to create plugin directory: %v", err)
}
// Create plugin.yaml using the standardized v1 format
pluginYaml := `apiVersion: v1
name: test-plugin
type: cli/v1
runtime: subprocess
version: 1.0.0
runtimeConfig:
platformCommand:
- command: echo`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYaml), 0644); err != nil {
t.Fatalf("Failed to create plugin.yaml: %v", err)
}
return pluginDir
}
func createTarballFromPluginDir(t *testing.T, pluginDir string) string {
t.Helper()
// Create tarball using the plugin package helper
tmpDir := filepath.Dir(pluginDir)
tgzPath := filepath.Join(tmpDir, "test-plugin-1.0.0.tgz")
tarFile, err := os.Create(tgzPath)
if err != nil {
t.Fatalf("Failed to create tarball file: %v", err)
}
defer tarFile.Close()
if err := plugin.CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
t.Fatalf("Failed to create tarball: %v", err)
}
return tgzPath
}
func createProvFile(t *testing.T, provFile, pluginTgz, hash string) {
t.Helper()
var hashStr string
if hash == "" {
// Calculate actual hash of the tarball for realistic testing
data, err := os.ReadFile(pluginTgz)
if err != nil {
t.Fatalf("Failed to read tarball for hashing: %v", err)
}
hashSum := sha256.Sum256(data)
hashStr = fmt.Sprintf("sha256:%x", hashSum)
} else {
// Use provided hash (could be wrong for testing)
hashStr = hash
}
// Create properly formatted provenance file with specified hash
provContent := fmt.Sprintf(`-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
name: test-plugin
version: 1.0.0
description: Test plugin for verification
files:
test-plugin-1.0.0.tgz: %s
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1
iQEcBAEBCAAGBQJktest...
-----END PGP SIGNATURE-----
`, hashStr)
if err := os.WriteFile(provFile, []byte(provContent), 0644); err != nil {
t.Fatalf("Failed to create provenance file: %v", err)
}
}
func createProvFileInvalidFormat(t *testing.T, provFile string) {
t.Helper()
// Create an invalid provenance file (not PGP signed format)
invalidProv := "This is not a valid PGP signed message"
if err := os.WriteFile(provFile, []byte(invalidProv), 0644); err != nil {
t.Fatalf("Failed to create invalid provenance file: %v", err)
}
}
func createTestKeyring(t *testing.T) string {
t.Helper()
// Create a temporary keyring file
tmpDir := t.TempDir()
keyringPath := filepath.Join(tmpDir, "pubring.gpg")
// Create empty keyring for testing
if err := os.WriteFile(keyringPath, []byte{}, 0644); err != nil {
t.Fatalf("Failed to create test keyring: %v", err)
}
return keyringPath
}

@ -0,0 +1,266 @@
/*
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"
extism "github.com/extism/go-sdk"
"github.com/tetratelabs/wazero"
"go.yaml.in/yaml/v3"
"helm.sh/helm/v4/pkg/helmpath"
)
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, error) {
cc, err := wazero.NewCompilationCacheWithDir(helmpath.CachePath("wazero-build"))
if err != nil {
return nil, fmt.Errorf("failed to create wazero compilation cache: %w", err)
}
return &prototypePluginManager{
runtimes: map[string]Runtime{
"subprocess": &RuntimeSubprocess{},
"extism/v1": &RuntimeExtismV1{
HostFunctions: map[string]extism.HostFunction{},
CompilationCache: cc,
},
},
}, nil
}
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, err := newPrototypePluginManager()
if err != nil {
return nil, fmt.Errorf("failed to create plugin manager: %w", err)
}
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
}

@ -0,0 +1,267 @@
/*
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 TestPostRenderer(t *testing.T) {
dirname := "testdata/plugdir/good/postrenderer-v1"
expect := Metadata{
Name: "postrenderer-v1",
Version: "1.2.3",
Type: "postrenderer/v1",
APIVersion: "v1",
Runtime: "subprocess",
Config: &ConfigPostrenderer{},
RuntimeConfig: &RuntimeConfigSubprocess{
PlatformCommands: []PlatformCommand{
{
Command: "${HELM_PLUGIN_DIR}/sed-test.sh",
},
},
},
}
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, 7)
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")
assert.Contains(t, plugsMap, "postrenderer-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: 7,
},
}
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))
})
}
}

@ -0,0 +1,224 @@
/*
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"
"helm.sh/helm/v4/internal/plugin/schema"
)
// 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, postrenderer/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 "test/v1":
config, err = remarshalConfig[*schema.ConfigTestV1](configRaw)
case "cli/v1":
config, err = remarshalConfig[*ConfigCLI](configRaw)
case "getter/v1":
config, err = remarshalConfig[*ConfigGetter](configRaw)
case "postrenderer/v1":
config, err = remarshalConfig[*ConfigPostrenderer](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)
case "extism/v1":
runtimeConfig, err = remarshalRuntimeConfig[*RuntimeConfigExtismV1](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
}

@ -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)
}

@ -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)
}
}

@ -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, postrenderer/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
}

@ -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_-]+$")

@ -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,
}
}

@ -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.
*/
/*
This file contains a "registry" of supported plugin types.
It enables "dyanmic" operations on the go type associated with a given plugin type (see: `helm.sh/helm/v4/internal/plugin/schema` package)
Examples:
```
// Create a new instance of the output message type for a given plugin type:
pluginType := "cli/v1" // for example
ptm, ok := pluginTypesIndex[pluginType]
if !ok {
return fmt.Errorf("unknown plugin type %q", pluginType)
}
outputMessageType := reflect.Zero(ptm.outputType).Interface()
```
```
// Create a new instance of the config type for a given plugin type
pluginType := "cli/v1" // for example
ptm, ok := pluginTypesIndex[pluginType]
if !ok {
return nil
}
config := reflect.New(ptm.configType).Interface().(Config) // `config` is variable of type `Config`, with
// validate
err := config.Validate()
if err != nil { // handle error }
// assert to concrete type if needed
cliConfig := config.(*schema.ConfigCLIV1)
```
*/
package plugin
import (
"reflect"
"helm.sh/helm/v4/internal/plugin/schema"
)
type pluginTypeMeta struct {
pluginType string
inputType reflect.Type
outputType reflect.Type
configType reflect.Type
}
var pluginTypes = []pluginTypeMeta{
{
pluginType: "test/v1",
inputType: reflect.TypeOf(schema.InputMessageTestV1{}),
outputType: reflect.TypeOf(schema.OutputMessageTestV1{}),
configType: reflect.TypeOf(schema.ConfigTestV1{}),
},
{
pluginType: "cli/v1",
inputType: reflect.TypeOf(schema.InputMessageCLIV1{}),
outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}),
configType: reflect.TypeOf(ConfigCLI{}),
},
{
pluginType: "getter/v1",
inputType: reflect.TypeOf(schema.InputMessageGetterV1{}),
outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}),
configType: reflect.TypeOf(ConfigGetter{}),
},
}
var pluginTypesIndex = func() map[string]*pluginTypeMeta {
result := make(map[string]*pluginTypeMeta, len(pluginTypes))
for _, m := range pluginTypes {
result[m.pluginType] = &m
}
return result
}()

@ -0,0 +1,38 @@
/*
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"
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/internal/plugin/schema"
)
func TestMakeOutputMessage(t *testing.T) {
ptm := pluginTypesIndex["getter/v1"]
outputType := reflect.Zero(ptm.outputType).Interface()
assert.IsType(t, schema.OutputMessageGetterV1{}, outputType)
}
func TestMakeConfig(t *testing.T) {
ptm := pluginTypesIndex["getter/v1"]
config := reflect.New(ptm.configType).Interface().(Config)
assert.IsType(t, &ConfigGetter{}, config)
}

@ -0,0 +1,75 @@
/*
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"
"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
}
// parseEnv takes a list of "KEY=value" environment variable strings
// and transforms the result into a map[KEY]=value
//
// - empty input strings are ignored
// - input strings with no value are stored as empty strings
// - duplicate keys overwrite earlier values
func parseEnv(env []string) map[string]string {
result := make(map[string]string, len(env))
for _, envVar := range env {
parts := strings.SplitN(envVar, "=", 2)
if len(parts) > 0 && parts[0] != "" {
key := parts[0]
var value string
if len(parts) > 1 {
value = parts[1]
}
result[key] = value
}
}
return result
}

@ -0,0 +1,292 @@
/*
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"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
extism "github.com/extism/go-sdk"
"github.com/tetratelabs/wazero"
)
const ExtismV1WasmBinaryFilename = "plugin.wasm"
// RuntimeConfigExtismV1Memory exposes the Wasm/Extism memory options for the plugin
type RuntimeConfigExtismV1Memory struct {
// The max amount of pages the plugin can allocate
// One page is 64Kib. e.g. 16 pages would require 1MiB.
// Default is 4 pages (256KiB)
MaxPages uint32 `yaml:"maxPages,omitempty"`
// The max size of an Extism HTTP response in bytes
// Default is 4096 bytes (4KiB)
MaxHTTPResponseBytes int64 `yaml:"maxHttpResponseBytes,omitempty"`
// The max size of all Extism vars in bytes
// Default is 4096 bytes (4KiB)
MaxVarBytes int64 `yaml:"maxVarBytes,omitempty"`
}
// RuntimeConfigExtismV1FileSystem exposes filesystem options for the configuration
// TODO: should Helm expose AllowedPaths?
type RuntimeConfigExtismV1FileSystem struct {
// If specified, a temporary directory will be created and mapped to /tmp in the plugin's filesystem.
// Data written to the directory will be visible on the host filesystem.
// The directory will be removed when the plugin invocation completes.
CreateTempDir bool `yaml:"createTempDir,omitempty"`
}
// RuntimeConfigExtismV1 defines the user-configurable options the plugin's Extism runtime
// The format loosely follows the Extism Manifest format: https://extism.org/docs/concepts/manifest/
type RuntimeConfigExtismV1 struct {
// Describes the limits on the memory the plugin may be allocated.
Memory RuntimeConfigExtismV1Memory `yaml:"memory"`
// The "config" key is a free-form map that can be passed to the plugin.
// The plugin must interpret arbitrary data this map may contain
Config map[string]string `yaml:"config,omitempty"`
// An optional set of hosts this plugin can communicate with.
// This only has an effect if the plugin makes HTTP requests.
// If not specified, then no hosts are allowed.
AllowedHosts []string `yaml:"allowedHosts,omitempty"`
FileSystem RuntimeConfigExtismV1FileSystem `yaml:"fileSystem,omitempty"`
// The timeout in milliseconds for the plugin to execute
Timeout uint64 `yaml:"timeout,omitempty"`
// HostFunction names exposed in Helm the plugin may access
// see: https://extism.org/docs/concepts/host-functions/
HostFunctions []string `yaml:"hostFunctions,omitempty"`
// The name of entry function name to call in the plugin
// Defaults to "helm_plugin_main".
EntryFuncName string `yaml:"entryFuncName,omitempty"`
}
var _ RuntimeConfig = (*RuntimeConfigExtismV1)(nil)
func (r *RuntimeConfigExtismV1) Validate() error {
// TODO
return nil
}
type RuntimeExtismV1 struct {
HostFunctions map[string]extism.HostFunction
CompilationCache wazero.CompilationCache
}
var _ Runtime = (*RuntimeExtismV1)(nil)
func (r *RuntimeExtismV1) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
rc, ok := metadata.RuntimeConfig.(*RuntimeConfigExtismV1)
if !ok {
return nil, fmt.Errorf("invalid extism/v1 plugin runtime config type: %T", metadata.RuntimeConfig)
}
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
if _, err := os.Stat(wasmFile); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("wasm binary missing for extism/v1 plugin: %q", wasmFile)
}
return nil, fmt.Errorf("failed to stat extism/v1 plugin wasm binary %q: %w", wasmFile, err)
}
return &ExtismV1PluginRuntime{
metadata: *metadata,
dir: pluginDir,
rc: rc,
r: r,
}, nil
}
type ExtismV1PluginRuntime struct {
metadata Metadata
dir string
rc *RuntimeConfigExtismV1
r *RuntimeExtismV1
}
var _ Plugin = (*ExtismV1PluginRuntime)(nil)
func (p *ExtismV1PluginRuntime) Metadata() Metadata {
return p.metadata
}
func (p *ExtismV1PluginRuntime) Dir() string {
return p.dir
}
func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Output, error) {
var tmpDir string
if p.rc.FileSystem.CreateTempDir {
tmpDirInner, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*")
slog.Debug("created plugin temp dir", slog.String("dir", tmpDirInner), slog.String("plugin", p.metadata.Name))
if err != nil {
return nil, fmt.Errorf("failed to create temp dir for extism compilation cache: %w", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
slog.Warn("failed to remove plugin temp dir", slog.String("dir", tmpDir), slog.String("plugin", p.metadata.Name), slog.String("error", err.Error()))
}
}()
tmpDir = tmpDirInner
}
manifest, err := buildManifest(p.dir, tmpDir, p.rc)
if err != nil {
return nil, err
}
config := buildPluginConfig(input, p.r)
hostFunctions, err := buildHostFunctions(p.r.HostFunctions, p.rc)
if err != nil {
return nil, err
}
pe, err := extism.NewPlugin(ctx, manifest, config, hostFunctions)
if err != nil {
return nil, fmt.Errorf("failed to create existing plugin: %w", err)
}
pe.SetLogger(func(logLevel extism.LogLevel, s string) {
slog.Debug(s, slog.String("level", logLevel.String()), slog.String("plugin", p.metadata.Name))
})
inputData, err := json.Marshal(input.Message)
if err != nil {
return nil, fmt.Errorf("failed to json marshal plugin input message: %T: %w", input.Message, err)
}
slog.Debug("plugin input", slog.String("plugin", p.metadata.Name), slog.String("inputData", string(inputData)))
entryFuncName := p.rc.EntryFuncName
if entryFuncName == "" {
entryFuncName = "helm_plugin_main"
}
exitCode, outputData, err := pe.Call(entryFuncName, inputData)
if err != nil {
return nil, fmt.Errorf("plugin error: %w", err)
}
if exitCode != 0 {
return nil, &InvokeExecError{
Code: int(exitCode),
}
}
slog.Debug("plugin output", slog.String("plugin", p.metadata.Name), slog.Int("exitCode", int(exitCode)), slog.String("outputData", string(outputData)))
outputMessage := reflect.New(pluginTypesIndex[p.metadata.Type].outputType)
if err := json.Unmarshal(outputData, outputMessage.Interface()); err != nil {
return nil, fmt.Errorf("failed to json marshal plugin output message: %T: %w", outputMessage, err)
}
output := &Output{
Message: outputMessage.Elem().Interface(),
}
return output, nil
}
func buildManifest(pluginDir string, tmpDir string, rc *RuntimeConfigExtismV1) (extism.Manifest, error) {
wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename)
allowedHosts := rc.AllowedHosts
if allowedHosts == nil {
allowedHosts = []string{}
}
allowedPaths := map[string]string{}
if tmpDir != "" {
allowedPaths[tmpDir] = "/tmp"
}
return extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: wasmFile,
Name: wasmFile,
},
},
Memory: &extism.ManifestMemory{
MaxPages: rc.Memory.MaxPages,
MaxHttpResponseBytes: rc.Memory.MaxHTTPResponseBytes,
MaxVarBytes: rc.Memory.MaxVarBytes,
},
Config: rc.Config,
AllowedHosts: allowedHosts,
AllowedPaths: allowedPaths,
Timeout: rc.Timeout,
}, nil
}
func buildPluginConfig(input *Input, r *RuntimeExtismV1) extism.PluginConfig {
mc := wazero.NewModuleConfig().
WithSysWalltime()
if input.Stdin != nil {
mc = mc.WithStdin(input.Stdin)
}
if input.Stdout != nil {
mc = mc.WithStdout(input.Stdout)
}
if input.Stderr != nil {
mc = mc.WithStderr(input.Stderr)
}
if len(input.Env) > 0 {
env := parseEnv(input.Env)
for k, v := range env {
mc = mc.WithEnv(k, v)
}
}
config := extism.PluginConfig{
ModuleConfig: mc,
RuntimeConfig: wazero.NewRuntimeConfigCompiler().
WithCloseOnContextDone(true).
WithCompilationCache(r.CompilationCache),
EnableWasi: true,
EnableHttpResponseHeaders: true,
}
return config
}
func buildHostFunctions(hostFunctions map[string]extism.HostFunction, rc *RuntimeConfigExtismV1) ([]extism.HostFunction, error) {
result := make([]extism.HostFunction, len(rc.HostFunctions))
for _, fnName := range rc.HostFunctions {
fn, ok := hostFunctions[fnName]
if !ok {
return nil, fmt.Errorf("plugin requested host function %q not found", fnName)
}
result = append(result, fn)
}
return result, nil
}

@ -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 plugin
import (
"os"
"os/exec"
"path/filepath"
"testing"
extism "github.com/extism/go-sdk"
"helm.sh/helm/v4/internal/plugin/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type pluginRaw struct {
Metadata Metadata
Dir string
}
func buildLoadExtismPlugin(t *testing.T, dir string) pluginRaw {
t.Helper()
pluginFile := filepath.Join(dir, PluginFileName)
metadataData, err := os.ReadFile(pluginFile)
require.NoError(t, err)
m, err := loadMetadata(metadataData)
require.NoError(t, err)
require.Equal(t, "extism/v1", m.Runtime, "expected plugin runtime to be extism/v1")
cmd := exec.Command("make", "-C", dir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
require.NoError(t, cmd.Run(), "failed to build plugin in %q", dir)
return pluginRaw{
Metadata: *m,
Dir: dir,
}
}
func TestRuntimeConfigExtismV1Validate(t *testing.T) {
rc := RuntimeConfigExtismV1{}
err := rc.Validate()
assert.NoError(t, err, "expected no error for empty RuntimeConfigExtismV1")
}
func TestRuntimeExtismV1InvokePlugin(t *testing.T) {
r := RuntimeExtismV1{}
pr := buildLoadExtismPlugin(t, "testdata/src/extismv1-test")
require.Equal(t, "test/v1", pr.Metadata.Type)
p, err := r.CreatePlugin(pr.Dir, &pr.Metadata)
assert.NoError(t, err, "expected no error creating plugin")
assert.NotNil(t, p, "expected plugin to be created")
output, err := p.Invoke(t.Context(), &Input{
Message: schema.InputMessageTestV1{
Name: "Phippy",
},
})
require.Nil(t, err)
msg := output.Message.(schema.OutputMessageTestV1)
assert.Equal(t, "Hello, Phippy! (6)", msg.Greeting)
}
func TestBuildManifest(t *testing.T) {
rc := &RuntimeConfigExtismV1{
Memory: RuntimeConfigExtismV1Memory{
MaxPages: 8,
MaxHTTPResponseBytes: 81920,
MaxVarBytes: 8192,
},
FileSystem: RuntimeConfigExtismV1FileSystem{
CreateTempDir: true,
},
Config: map[string]string{"CONFIG_KEY": "config_value"},
AllowedHosts: []string{"example.com", "api.example.com"},
Timeout: 5000,
}
expected := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: "/path/to/plugin/plugin.wasm",
Name: "/path/to/plugin/plugin.wasm",
},
},
Memory: &extism.ManifestMemory{
MaxPages: 8,
MaxHttpResponseBytes: 81920,
MaxVarBytes: 8192,
},
Config: map[string]string{"CONFIG_KEY": "config_value"},
AllowedHosts: []string{"example.com", "api.example.com"},
AllowedPaths: map[string]string{"/tmp/foo": "/tmp"},
Timeout: 5000,
}
manifest, err := buildManifest("/path/to/plugin", "/tmp/foo", rc)
require.NoError(t, err)
assert.Equal(t, expected, manifest)
}

@ -0,0 +1,289 @@
/*
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"
"context"
"fmt"
"io"
"log/slog"
"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 implements RuntimeConfig for RuntimeSubprocess
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)
// CreatePlugin implementation for Runtime
func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) {
return &SubprocessPluginRuntime{
metadata: *metadata,
pluginDir: pluginDir,
RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)),
}, nil
}
// SubprocessPluginRuntime implements the Plugin 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)
case schema.InputMessagePostRendererV1:
return r.runPostrenderer(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
}
func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) {
if _, ok := input.Message.(schema.InputMessagePostRendererV1); !ok {
return nil, fmt.Errorf("plugin %q input message does not implement InputMessagePostRendererV1", r.metadata.Name)
}
msg := input.Message.(schema.InputMessagePostRendererV1)
extraArgs := msg.ExtraArgs
settings := msg.Settings
// Setup plugin environment
SetupPluginEnv(settings, r.metadata.Name, r.pluginDir)
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)
}
// TODO de-duplicate code here by calling RuntimeSubprocess.invokeWithEnv()
cmd := exec.Command(
command,
args...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
go func() {
defer stdin.Close()
io.Copy(stdin, msg.Manifests)
}()
postRendered := &bytes.Buffer{}
stderr := &bytes.Buffer{}
//cmd.Env = pluginExec.env
cmd.Stdout = postRendered
cmd.Stderr = stderr
if err := executeCmd(cmd, r.metadata.Name); err != nil {
slog.Info("plugin execution failed", slog.String("stderr", stderr.String()))
return nil, err
}
return &Output{
Message: &schema.OutputMessagePostRendererV1{
Manifests: postRendered,
},
}, 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)
}
}

@ -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
}

@ -13,7 +13,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin // import "helm.sh/helm/v4/pkg/plugin"
package plugin // import "helm.sh/helm/v4/internal/plugin"
// Types of hooks
const (

@ -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)
}
}
}

@ -0,0 +1,63 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package plugin
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseEnv(t *testing.T) {
type testCase struct {
env []string
expected map[string]string
}
testCases := map[string]testCase{
"empty": {
env: []string{},
expected: map[string]string{},
},
"single": {
env: []string{"KEY=value"},
expected: map[string]string{"KEY": "value"},
},
"multiple": {
env: []string{"KEY1=value1", "KEY2=value2"},
expected: map[string]string{"KEY1": "value1", "KEY2": "value2"},
},
"no_value": {
env: []string{"KEY1=value1", "KEY2="},
expected: map[string]string{"KEY1": "value1", "KEY2": ""},
},
"duplicate_keys": {
env: []string{"KEY=value1", "KEY=value2"},
expected: map[string]string{"KEY": "value2"}, // last value should overwrite
},
"empty_strings": {
env: []string{"", "KEY=value", ""},
expected: map[string]string{"KEY": "value"},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
result := parseEnv(tc.env)
assert.Equal(t, tc.expected, result)
})
}
}

@ -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"`
}

@ -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"`
}

@ -14,16 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// Package postrender contains an interface that can be implemented for custom
// post-renderers and an exec implementation that can be used for arbitrary
// binaries and scripts
package postrender
import "bytes"
type PostRenderer interface {
// Run expects a single buffer filled with Helm rendered manifests. It
// expects the modified results to be returned on a separate buffer or an
// error if there was an issue or failure while running the post render step
Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error)
package schema
import (
"bytes"
"helm.sh/helm/v4/pkg/cli"
)
// InputMessagePostRendererV1 implements Input.Message
type InputMessagePostRendererV1 struct {
Manifests *bytes.Buffer `json:"manifests"`
// from CLI --post-renderer-args
ExtraArgs []string `json:"extraArgs"`
Settings *cli.EnvSettings `json:"settings"`
}
type OutputMessagePostRendererV1 struct {
Manifests *bytes.Buffer `json:"manifests"`
}

@ -0,0 +1,28 @@
/*
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
type InputMessageTestV1 struct {
Name string
}
type OutputMessageTestV1 struct {
Greeting string
}
type ConfigTestV1 struct{}
func (c *ConfigTestV1) Validate() error {
return nil
}

@ -0,0 +1,156 @@
/*
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 (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/provenance"
)
// SignPlugin signs a plugin using the SHA256 hash of the tarball data.
//
// This is used when packaging and signing a plugin from tarball data.
// It creates a signature that includes the tarball hash and plugin metadata,
// allowing verification of the original tarball later.
func SignPlugin(tarballData []byte, filename string, signer *provenance.Signatory) (string, error) {
// Extract plugin metadata from tarball data
pluginMeta, err := ExtractTgzPluginMetadata(bytes.NewReader(tarballData))
if err != nil {
return "", fmt.Errorf("failed to extract plugin metadata: %w", err)
}
// Marshal plugin metadata to YAML bytes
metadataBytes, err := yaml.Marshal(pluginMeta)
if err != nil {
return "", fmt.Errorf("failed to marshal plugin metadata: %w", err)
}
// Use the generic provenance signing function
return signer.ClearSign(tarballData, filename, metadataBytes)
}
// ExtractTgzPluginMetadata extracts plugin metadata from a gzipped tarball reader
func ExtractTgzPluginMetadata(r io.Reader) (*Metadata, error) {
gzr, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
// Look for plugin.yaml file
if filepath.Base(header.Name) == "plugin.yaml" {
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
// Parse the plugin metadata
metadata, err := loadMetadata(data)
if err != nil {
return nil, err
}
return metadata, nil
}
}
return nil, errors.New("plugin.yaml not found in tarball")
}
// parsePluginMessageBlock parses a signed message block to extract plugin metadata and checksums
func parsePluginMessageBlock(data []byte) (*Metadata, *provenance.SumCollection, error) {
sc := &provenance.SumCollection{}
// We only need the checksums for verification, not the full metadata
if err := provenance.ParseMessageBlock(data, nil, sc); err != nil {
return nil, sc, err
}
return nil, sc, nil
}
// CreatePluginTarball creates a gzipped tarball from a plugin directory
func CreatePluginTarball(sourceDir, pluginName string, w io.Writer) error {
gzw := gzip.NewWriter(w)
defer gzw.Close()
tw := tar.NewWriter(gzw)
defer tw.Close()
// Use the plugin name as the base directory in the tarball
baseDir := pluginName
// Walk the directory tree
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Create header
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
// Update the name to be relative to the source directory
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
// Include the base directory name in the tarball
header.Name = filepath.Join(baseDir, relPath)
// Write header
if err := tw.WriteHeader(header); err != nil {
return err
}
// If it's a regular file, write its content
if info.Mode().IsRegular() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return err
}
}
return nil
})
}

@ -0,0 +1,98 @@
/*
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"
"strings"
"testing"
"helm.sh/helm/v4/pkg/provenance"
)
func TestSignPlugin(t *testing.T) {
// Create a test plugin directory
tempDir := t.TempDir()
pluginDir := filepath.Join(tempDir, "test-plugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Create a plugin.yaml file
pluginYAML := `apiVersion: v1
name: test-plugin
type: cli/v1
runtime: subprocess
version: 1.0.0
runtimeConfig:
platformCommand:
- command: echo`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
t.Fatal(err)
}
// Create a tarball
tarballPath := filepath.Join(tempDir, "test-plugin.tgz")
tarFile, err := os.Create(tarballPath)
if err != nil {
t.Fatal(err)
}
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
tarFile.Close()
t.Fatal(err)
}
tarFile.Close()
// Create a test key for signing
keyring := "../../pkg/cmd/testdata/helm-test-key.secret"
signer, err := provenance.NewFromKeyring(keyring, "helm-test")
if err != nil {
t.Fatal(err)
}
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
return []byte(""), nil
}); err != nil {
t.Fatal(err)
}
// Read the tarball data
tarballData, err := os.ReadFile(tarballPath)
if err != nil {
t.Fatalf("failed to read tarball: %v", err)
}
// Sign the plugin tarball
sig, err := SignPlugin(tarballData, filepath.Base(tarballPath), signer)
if err != nil {
t.Fatalf("failed to sign plugin: %v", err)
}
// Verify the signature contains the expected content
if !strings.Contains(sig, "-----BEGIN PGP SIGNED MESSAGE-----") {
t.Error("signature does not contain PGP header")
}
// Verify the tarball hash is in the signature
expectedHash, err := provenance.DigestFile(tarballPath)
if err != nil {
t.Fatal(err)
}
// The signature should contain the tarball hash
if !strings.Contains(sig, "sha256:"+expectedHash) {
t.Errorf("signature does not contain expected tarball hash: sha256:%s", expectedHash)
}
}

@ -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 plugin
import (
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/openpgp/clearsign" //nolint
"helm.sh/helm/v4/pkg/helmpath"
)
// SigningInfo contains information about a plugin's signing status
type SigningInfo struct {
// Status can be:
// - "local dev": Plugin is a symlink (development mode)
// - "unsigned": No provenance file found
// - "invalid provenance": Provenance file is malformed
// - "mismatched provenance": Provenance file does not match the installed tarball
// - "signed": Valid signature exists for the installed tarball
Status string
IsSigned bool // True if plugin has a valid signature (even if not verified against keyring)
}
// GetPluginSigningInfo returns signing information for an installed plugin
func GetPluginSigningInfo(metadata Metadata) (*SigningInfo, error) {
pluginName := metadata.Name
pluginDir := helmpath.DataPath("plugins", pluginName)
// Check if plugin directory exists
fi, err := os.Lstat(pluginDir)
if err != nil {
return nil, fmt.Errorf("plugin %s not found: %w", pluginName, err)
}
// Check if it's a symlink (local development)
if fi.Mode()&os.ModeSymlink != 0 {
return &SigningInfo{
Status: "local dev",
IsSigned: false,
}, nil
}
// Find the exact tarball file for this plugin
pluginsDir := helmpath.DataPath("plugins")
tarballPath := filepath.Join(pluginsDir, fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
if _, err := os.Stat(tarballPath); err != nil {
return &SigningInfo{
Status: "unsigned",
IsSigned: false,
}, nil
}
// Check for .prov file associated with the tarball
provFile := tarballPath + ".prov"
provData, err := os.ReadFile(provFile)
if err != nil {
if os.IsNotExist(err) {
return &SigningInfo{
Status: "unsigned",
IsSigned: false,
}, nil
}
return nil, fmt.Errorf("failed to read provenance file: %w", err)
}
// Parse the provenance file to check validity
block, _ := clearsign.Decode(provData)
if block == nil {
return &SigningInfo{
Status: "invalid provenance",
IsSigned: false,
}, nil
}
// Check if provenance matches the actual tarball
blockContent := string(block.Plaintext)
if !validateProvenanceHash(blockContent, tarballPath) {
return &SigningInfo{
Status: "mismatched provenance",
IsSigned: false,
}, nil
}
// We have a provenance file that is valid for this plugin
// Without a keyring, we can't verify the signature, but we know:
// 1. A .prov file exists
// 2. It's a valid clearsigned document (cryptographically signed)
// 3. The provenance contains valid checksums
return &SigningInfo{
Status: "signed",
IsSigned: true,
}, nil
}
func validateProvenanceHash(blockContent string, tarballPath string) bool {
// Parse provenance to get the expected hash
_, sums, err := parsePluginMessageBlock([]byte(blockContent))
if err != nil {
return false
}
// Must have file checksums
if len(sums.Files) == 0 {
return false
}
// Calculate actual hash of the tarball
actualHash, err := calculateFileHash(tarballPath)
if err != nil {
return false
}
// Check if the actual hash matches the expected hash in the provenance
for filename, expectedHash := range sums.Files {
if strings.Contains(filename, filepath.Base(tarballPath)) && expectedHash == actualHash {
return true
}
}
return false
}
// calculateFileHash calculates the SHA256 hash of a file
func calculateFileHash(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
return fmt.Sprintf("sha256:%x", hasher.Sum(nil)), nil
}
// GetSigningInfoForPlugins returns signing info for multiple plugins
func GetSigningInfoForPlugins(plugins []Plugin) map[string]*SigningInfo {
result := make(map[string]*SigningInfo)
for _, p := range plugins {
m := p.Metadata()
info, err := GetPluginSigningInfo(m)
if err != nil {
// If there's an error, treat as unsigned
result[m.Name] = &SigningInfo{
Status: "unknown",
IsSigned: false,
}
} else {
result[m.Name] = info
}
}
return result
}

@ -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
}

@ -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)
}
}

@ -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"

@ -1,4 +1,5 @@
name: "echo"
---
name: "echo-legacy"
version: "1.2.3"
usage: "echo something"
description: |-

@ -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"

@ -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"

@ -1,25 +1,22 @@
name: "hello"
---
name: "hello-legacy"
version: "0.1.0"
usage: "usage"
usage: "echo hello message"
description: |-
description
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"]
ignoreFlags: true
platformHooks:
install:
- os: linux
arch: ""
command: "sh"
args: ["-c", 'echo "installing..."']
- os: windows
arch: ""
command: "pwsh"
args: ["-c", 'echo "installing..."']

@ -0,0 +1,3 @@
#!/usr/bin/env pwsh
Write-Host "Hello, world!"

@ -0,0 +1,9 @@
#!/bin/bash
echo "Hello from a Helm plugin"
echo "PARAMS"
echo $*
$HELM_BIN ls --all

@ -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..."']

@ -0,0 +1,8 @@
name: "postrenderer-v1"
version: "1.2.3"
type: postrenderer/v1
apiVersion: v1
runtime: subprocess
runtimeConfig:
platformCommand:
- command: "${HELM_PLUGIN_DIR}/sed-test.sh"

@ -0,0 +1,6 @@
#!/bin/sh
if [ $# -eq 0 ]; then
sed s/FOOTEST/BARTEST/g <&0
else
sed s/FOOTEST/"$*"/g <&0
fi

@ -0,0 +1,12 @@
.DEFAULT: build
.PHONY: build test vet
.PHONY: plugin.wasm
plugin.wasm:
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
build: plugin.wasm
vet:
GOOS=wasip1 GOARCH=wasm go vet ./...

@ -0,0 +1,5 @@
module helm.sh/helm/v4/internal/plugin/src/extismv1-test
go 1.25.0
require github.com/extism/go-pdk v1.1.3

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

@ -0,0 +1,68 @@
package main
import (
_ "embed"
"fmt"
"os"
pdk "github.com/extism/go-pdk"
)
type InputMessageTestV1 struct {
Name string
}
type OutputMessageTestV1 struct {
Greeting string
}
type ConfigTestV1 struct{}
func runGetterPluginImpl(input InputMessageTestV1) (*OutputMessageTestV1, error) {
name := input.Name
greeting := fmt.Sprintf("Hello, %s! (%d)", name, len(name))
err := os.WriteFile("/tmp/greeting.txt", []byte(greeting), 0o600)
if err != nil {
return nil, fmt.Errorf("failed to write temp file: %w", err)
}
return &OutputMessageTestV1{
Greeting: greeting,
}, nil
}
func RunGetterPlugin() error {
var input InputMessageTestV1
if err := pdk.InputJSON(&input); err != nil {
return fmt.Errorf("failed to parse input json: %w", err)
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received input: %+v", input))
output, err := runGetterPluginImpl(input)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed: %s", err.Error()))
return err
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending output: %+v", output))
if err := pdk.OutputJSON(output); err != nil {
return fmt.Errorf("failed to write output json: %w", err)
}
return nil
}
//go:wasmexport helm_plugin_main
func HelmPlugin() uint32 {
pdk.Log(pdk.LogDebug, "running example-extism-getter plugin")
if err := RunGetterPlugin(); err != nil {
pdk.Log(pdk.LogError, err.Error())
pdk.SetError(err)
return 1
}
return 0
}
func main() {}

@ -0,0 +1,9 @@
---
apiVersion: v1
type: test/v1
name: extismv1-test
version: 0.1.0
runtime: extism/v1
runtimeConfig:
fileSystem:
createTempDir: true

@ -0,0 +1,39 @@
/*
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 (
"path/filepath"
"helm.sh/helm/v4/pkg/provenance"
)
// VerifyPlugin verifies plugin data against a signature using data in memory.
func VerifyPlugin(archiveData, provData []byte, filename, keyring string) (*provenance.Verification, error) {
// Create signatory from keyring
sig, err := provenance.NewFromKeyring(keyring, "")
if err != nil {
return nil, err
}
// Use the new VerifyData method directly
return sig.Verify(archiveData, provData, filename)
}
// isTarball checks if a file has a tarball extension
func IsTarball(filename string) bool {
return filepath.Ext(filename) == ".gz" || filepath.Ext(filename) == ".tgz"
}

@ -0,0 +1,214 @@
/*
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/provenance"
)
const testKeyFile = "../../pkg/cmd/testdata/helm-test-key.secret"
const testPubFile = "../../pkg/cmd/testdata/helm-test-key.pub"
const testPluginYAML = `apiVersion: v1
name: test-plugin
type: cli/v1
runtime: subprocess
version: 1.0.0
runtimeConfig:
platformCommand:
- command: echo`
func TestVerifyPlugin(t *testing.T) {
// Create a test plugin and sign it
tempDir := t.TempDir()
// Create plugin directory
pluginDir := filepath.Join(tempDir, "verify-test-plugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
t.Fatal(err)
}
// Create tarball
tarballPath := filepath.Join(tempDir, "verify-test-plugin.tar.gz")
tarFile, err := os.Create(tarballPath)
if err != nil {
t.Fatal(err)
}
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
tarFile.Close()
t.Fatal(err)
}
tarFile.Close()
// Sign the plugin with source directory
signer, err := provenance.NewFromKeyring(testKeyFile, "helm-test")
if err != nil {
t.Fatal(err)
}
if err := signer.DecryptKey(func(_ string) ([]byte, error) {
return []byte(""), nil
}); err != nil {
t.Fatal(err)
}
// Read the tarball data
tarballData, err := os.ReadFile(tarballPath)
if err != nil {
t.Fatal(err)
}
sig, err := SignPlugin(tarballData, filepath.Base(tarballPath), signer)
if err != nil {
t.Fatal(err)
}
// Write the signature to .prov file
provFile := tarballPath + ".prov"
if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil {
t.Fatal(err)
}
// Read the files for verification
archiveData, err := os.ReadFile(tarballPath)
if err != nil {
t.Fatal(err)
}
provData, err := os.ReadFile(provFile)
if err != nil {
t.Fatal(err)
}
// Now verify the plugin
verification, err := VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile)
if err != nil {
t.Fatalf("Failed to verify plugin: %v", err)
}
// Check verification results
if verification.SignedBy == nil {
t.Error("SignedBy is nil")
}
if verification.FileName != "verify-test-plugin.tar.gz" {
t.Errorf("Expected filename 'verify-test-plugin.tar.gz', got %s", verification.FileName)
}
if verification.FileHash == "" {
t.Error("FileHash is empty")
}
}
func TestVerifyPluginBadSignature(t *testing.T) {
tempDir := t.TempDir()
// Create a plugin tarball
pluginDir := filepath.Join(tempDir, "bad-plugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil {
t.Fatal(err)
}
tarballPath := filepath.Join(tempDir, "bad-plugin.tar.gz")
tarFile, err := os.Create(tarballPath)
if err != nil {
t.Fatal(err)
}
if err := CreatePluginTarball(pluginDir, "test-plugin", tarFile); err != nil {
tarFile.Close()
t.Fatal(err)
}
tarFile.Close()
// Create a bad signature (just some text)
badSig := `-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
This is not a real signature
-----BEGIN PGP SIGNATURE-----
InvalidSignatureData
-----END PGP SIGNATURE-----`
provFile := tarballPath + ".prov"
if err := os.WriteFile(provFile, []byte(badSig), 0644); err != nil {
t.Fatal(err)
}
// Read the files
archiveData, err := os.ReadFile(tarballPath)
if err != nil {
t.Fatal(err)
}
provData, err := os.ReadFile(provFile)
if err != nil {
t.Fatal(err)
}
// Try to verify - should fail
_, err = VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile)
if err == nil {
t.Error("Expected verification to fail with bad signature")
}
}
func TestVerifyPluginMissingProvenance(t *testing.T) {
tempDir := t.TempDir()
tarballPath := filepath.Join(tempDir, "no-prov.tar.gz")
// Create a minimal tarball
if err := os.WriteFile(tarballPath, []byte("dummy"), 0644); err != nil {
t.Fatal(err)
}
// Read the tarball data
archiveData, err := os.ReadFile(tarballPath)
if err != nil {
t.Fatal(err)
}
// Try to verify with empty provenance data
_, err = VerifyPlugin(archiveData, nil, filepath.Base(tarballPath), testPubFile)
if err == nil {
t.Error("Expected verification to fail with empty provenance data")
}
}
func TestVerifyPluginMalformedData(t *testing.T) {
// Test with malformed tarball data - should fail
malformedData := []byte("not a tarball")
provData := []byte("fake provenance")
_, err := VerifyPlugin(malformedData, provData, "malformed.tar.gz", testPubFile)
if err == nil {
t.Error("Expected malformed data verification to fail, but it succeeded")
}
}

@ -73,7 +73,7 @@ func renameByCopy(src, dst string) error {
cerr = fmt.Errorf("copying directory failed: %w", cerr)
}
} else {
cerr = copyFile(src, dst)
cerr = CopyFile(src, dst)
if cerr != nil {
cerr = fmt.Errorf("copying file failed: %w", cerr)
}
@ -139,7 +139,7 @@ func CopyDir(src, dst string) error {
} else {
// This will include symlinks, which is what we want when
// copying things.
if err = copyFile(srcPath, dstPath); err != nil {
if err = CopyFile(srcPath, dstPath); err != nil {
return fmt.Errorf("copying file failed: %w", err)
}
}
@ -148,11 +148,11 @@ 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 fmt.Errorf("symlink check failed: %w", err)
} else if sym {

@ -326,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)
}
@ -366,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)
}
@ -438,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)
}
}

@ -43,7 +43,7 @@ import (
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/postrenderer"
"helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1"
@ -176,8 +176,8 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) {
// TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed
//
// This code has to do with writing files to disk.
func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS, hideSecret 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 postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) {
var hs []*release.Hook
b := bytes.NewBuffer(nil)
caps, err := cfg.getCapabilities()
@ -520,3 +520,10 @@ func (cfg *Configuration) Init(getter genericclioptions.RESTClientGetter, namesp
func (cfg *Configuration) SetHookOutputFunc(hookOutputFunc func(_, _, _ string) io.Writer) {
cfg.HookOutputFunc = hookOutputFunc
}
func determineReleaseSSApplyMethod(serverSideApply bool) release.ApplyMethod {
if serverSideApply {
return release.ApplyMethodServerSideApply
}
return release.ApplyMethodClientSideApply
}

@ -946,3 +946,8 @@ func TestRenderResources_NoPostRenderer(t *testing.T) {
assert.NotNil(t, buf)
assert.Equal(t, "", notes)
}
func TestDetermineReleaseSSAApplyMethod(t *testing.T) {
assert.Equal(t, release.ApplyMethodClientSideApply, determineReleaseSSApplyMethod(false))
assert.Equal(t, release.ApplyMethodServerSideApply, determineReleaseSSApplyMethod(true))
}

@ -47,6 +47,7 @@ type Metadata struct {
Revision int `json:"revision" yaml:"revision"`
Status string `json:"status" yaml:"status"`
DeployedAt string `json:"deployedAt" yaml:"deployedAt"`
ApplyMethod string `json:"applyMethod,omitempty" yaml:"applyMethod,omitempty"`
}
// NewGetMetadata creates a new GetMetadata object with the given configuration.
@ -79,6 +80,7 @@ func (g *GetMetadata) Run(name string) (*Metadata, error) {
Revision: rel.Version,
Status: rel.Info.Status.String(),
DeployedAt: rel.Info.LastDeployed.Format(time.RFC3339),
ApplyMethod: rel.ApplyMethod,
}, nil
}

@ -33,7 +33,7 @@ import (
)
// execHook executes all of the hooks for the given hook event.
func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, timeout time.Duration) error {
func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, waitStrategy kube.WaitStrategy, timeout time.Duration, serverSideApply bool) error {
executingHooks := []*release.Hook{}
for _, h := range rl.Hooks {
@ -73,7 +73,9 @@ 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(serverSideApply, false)); err != nil {
h.LastRun.CompletedAt = helmtime.Now()
h.LastRun.Phase = release.HookPhaseFailed
return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err)

@ -385,7 +385,8 @@ data:
Capabilities: chartutil.DefaultCapabilities,
}
err := configuration.execHook(&tc.inputRelease, hookEvent, kube.StatusWatcherStrategy, 600)
serverSideApply := true
err := configuration.execHook(&tc.inputRelease, hookEvent, kube.StatusWatcherStrategy, 600, serverSideApply)
if !reflect.DeepEqual(kubeClient.deleteRecord, tc.expectedDeleteRecord) {
t.Fatalf("Got unexpected delete record, expected: %#v, but got: %#v", kubeClient.deleteRecord, tc.expectedDeleteRecord)

@ -48,7 +48,7 @@ import (
"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/postrenderer"
"helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1"
@ -75,27 +75,34 @@ type Install struct {
// ForceReplace will, if set to `true`, ignore certain warnings and perform the install anyway.
//
// This should be used with caution.
ForceReplace bool
ForceReplace bool
// ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager")
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
ForceConflicts bool
// ServerSideApply when true (default) will enable changes to be applied via Kubernetes server-side apply
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/
ServerSideApply 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
Atomic bool
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
@ -117,7 +124,7 @@ type Install struct {
UseReleaseName bool
// TakeOwnership will ignore the check for helm annotations and take ownership of the resources.
TakeOwnership bool
PostRenderer postrender.PostRenderer
PostRenderer postrenderer.PostRenderer
// Lock to control raceconditions when the process receives a SIGTERM
Lock sync.Mutex
}
@ -145,7 +152,8 @@ type ChartPathOptions struct {
// NewInstall creates a new Install object with the given configuration.
func NewInstall(cfg *Configuration) *Install {
in := &Install{
cfg: cfg,
cfg: cfg,
ServerSideApply: true,
}
in.registryClient = cfg.RegistryClient
@ -173,7 +181,9 @@ func (i *Install) installCRDs(crds []chart.CRD) error {
}
// Send them to Kube
if _, err := i.cfg.KubeClient.Create(res); err != nil {
if _, err := i.cfg.KubeClient.Create(
res,
kube.ClientCreateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts)); err != nil {
// If the error is CRD already exists, continue.
if apierrors.IsAlreadyExists(err) {
crdName := res[0].Name
@ -234,7 +244,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.
@ -296,9 +306,9 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
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
if i.WaitStrategy == kube.HookOnlyStrategy && i.Atomic {
if i.WaitStrategy == kube.HookOnlyStrategy && i.RollbackOnFailure {
i.WaitStrategy = kube.StatusWatcherStrategy
}
@ -399,7 +409,9 @@ 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(i.ServerSideApply, false)); err != nil && !apierrors.IsAlreadyExists(err) {
return nil, err
}
}
@ -411,8 +423,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma
}
}
// Store the release in history before continuing (new in Helm 3). We always know
// that this is a create operation.
// Store the release in history before continuing. We always know that this is a create operation
if err := i.cfg.Releases.Create(rel); err != nil {
// We could try to recover gracefully here, but since nothing has been installed
// yet, this is probably safer than trying to continue when we know storage is
@ -459,7 +470,7 @@ 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.WaitStrategy, i.Timeout); err != nil {
if err := i.cfg.execHook(rel, release.HookPreInstall, i.WaitStrategy, i.Timeout, i.ServerSideApply); err != nil {
return rel, fmt.Errorf("failed pre-install: %s", err)
}
}
@ -468,13 +479,18 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
// 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(i.ServerSideApply, false))
} else if len(resources) > 0 {
if i.TakeOwnership {
_, err = i.cfg.KubeClient.(kube.InterfaceThreeWayMerge).UpdateThreeWayMerge(toBeAdopted, resources, i.ForceReplace)
} else {
_, err = i.cfg.KubeClient.Update(toBeAdopted, resources, i.ForceReplace)
}
updateThreeWayMergeForUnstructured := i.TakeOwnership && !i.ServerSideApply // Use three-way merge when taking ownership (and not using server-side apply)
_, err = i.cfg.KubeClient.Update(
toBeAdopted,
resources,
kube.ClientUpdateOptionForceReplace(i.ForceReplace),
kube.ClientUpdateOptionServerSideApply(i.ServerSideApply, i.ForceConflicts),
kube.ClientUpdateOptionThreeWayMergeForUnstructured(updateThreeWayMergeForUnstructured),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(true))
}
if err != nil {
return rel, err
@ -495,7 +511,7 @@ func (i *Install) performInstall(rel *release.Release, toBeAdopted kube.Resource
}
if !i.DisableHooks {
if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.Timeout); err != nil {
if err := i.cfg.execHook(rel, release.HookPostInstall, i.WaitStrategy, i.Timeout, i.ServerSideApply); err != nil {
return rel, fmt.Errorf("failed post-install: %s", err)
}
}
@ -522,8 +538,8 @@ 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 {
slog.Debug("install failed, uninstalling release", "release", i.ReleaseName)
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
@ -531,7 +547,7 @@ func (i *Install) failRelease(rel *release.Release, err error) (*release.Release
if _, uninstallErr := uninstall.Run(i.ReleaseName); uninstallErr != nil {
return rel, fmt.Errorf("an error occurred while uninstalling the release. original install error: %w: %w", err, uninstallErr)
}
return rel, fmt.Errorf("release %s failed, and has been uninstalled due to atomic being set: %w", i.ReleaseName, err)
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
@ -572,7 +588,8 @@ func (i *Install) availableName() error {
// createRelease creates a new release object
func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}, labels map[string]string) *release.Release {
ts := i.cfg.Now()
return &release.Release{
r := &release.Release{
Name: i.ReleaseName,
Namespace: i.Namespace,
Chart: chrt,
@ -582,9 +599,12 @@ func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{
LastDeployed: ts,
Status: release.StatusUnknown,
},
Version: 1,
Labels: labels,
Version: 1,
Labels: labels,
ApplyMethod: string(determineReleaseSSApplyMethod(i.ServerSideApply)),
}
return r
}
// recordRelease with an update operation in case reuse has been set.
@ -792,7 +812,7 @@ 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
}
}
@ -815,6 +835,7 @@ func (c *ChartPathOptions) LocateChart(name string, settings *cli.EnvSettings) (
},
RepositoryConfig: settings.RepositoryConfig,
RepositoryCache: settings.RepositoryCache,
ContentCache: settings.ContentCache,
RegistryClient: c.registryClient,
}
@ -868,7 +889,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
}

@ -590,16 +590,16 @@ 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
@ -608,7 +608,7 @@ func TestInstallRelease_Atomic(t *testing.T) {
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 anymore
_, err = instAction.cfg.Releases.Get(res.Name, res.Version)
@ -616,14 +616,14 @@ func TestInstallRelease_Atomic(t *testing.T) {
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)
@ -633,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)
@ -641,7 +641,7 @@ 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, cancel := context.WithCancel(t.Context())
@ -652,7 +652,7 @@ func TestInstallRelease_Atomic_Interrupted(t *testing.T) {
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 anymore
@ -890,7 +890,6 @@ func TestNameAndChartGenerateName(t *testing.T) {
}
for _, tc := range tests {
tc := tc
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()

@ -21,10 +21,12 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"github.com/Masterminds/semver/v3"
"golang.org/x/term"
"sigs.k8s.io/yaml"
"helm.sh/helm/v4/pkg/chart/v2/loader"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
@ -143,7 +145,26 @@ func (p *Package) Clearsign(filename string) error {
return err
}
sig, err := signer.ClearSign(filename)
// Load the chart archive to extract metadata
chart, err := loader.LoadFile(filename)
if err != nil {
return fmt.Errorf("failed to load chart for signing: %w", err)
}
// Marshal chart metadata to YAML bytes
metadataBytes, err := yaml.Marshal(chart.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal chart metadata: %w", err)
}
// Read the chart archive file
archiveData, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("failed to read chart archive: %w", err)
}
// Use the generic provenance signing function
sig, err := signer.ClearSign(archiveData, filepath.Base(filename), metadataBytes)
if err != nil {
return err
}

@ -88,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) {

@ -96,7 +96,8 @@ func (r *ReleaseTesting) Run(name string) (*release.Release, error) {
rel.Hooks = executingHooks
}
if err := r.cfg.execHook(rel, release.HookTest, kube.StatusWatcherStrategy, r.Timeout); err != nil {
serverSideApply := rel.ApplyMethod == string(release.ApplyMethodServerSideApply)
if err := r.cfg.execHook(rel, release.HookTest, kube.StatusWatcherStrategy, r.Timeout, serverSideApply); err != nil {
rel.Hooks = append(skippedHooks, rel.Hooks...)
r.cfg.Releases.Update(rel)
return rel, err

@ -44,9 +44,17 @@ type Rollback struct {
// 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
ForceReplace bool
// ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager")
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
ForceConflicts bool
// ServerSideApply enables changes to be applied via Kubernetes server-side apply
// Can be the string: "true", "false" or "auto"
// When "auto", sever-side usage will be based upon the releases previous usage
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/
ServerSideApply string
CleanupOnFail bool
MaxHistory int // MaxHistory limits the maximum number of revisions saved per release
}
// NewRollback creates a new Rollback object with the given configuration.
@ -65,7 +73,7 @@ func (r *Rollback) Run(name string) error {
r.cfg.Releases.MaxHistory = r.MaxHistory
slog.Debug("preparing rollback", "name", name)
currentRelease, targetRelease, err := r.prepareRollback(name)
currentRelease, targetRelease, serverSideApply, err := r.prepareRollback(name)
if err != nil {
return err
}
@ -78,7 +86,7 @@ func (r *Rollback) Run(name string) error {
}
slog.Debug("performing rollback", "name", name)
if _, err := r.performRollback(currentRelease, targetRelease); err != nil {
if _, err := r.performRollback(currentRelease, targetRelease, serverSideApply); err != nil {
return err
}
@ -93,18 +101,18 @@ func (r *Rollback) Run(name string) error {
// prepareRollback finds the previous release and prepares a new release object with
// the previous release's configuration
func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, error) {
func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Release, bool, error) {
if err := chartutil.ValidateReleaseName(name); err != nil {
return nil, nil, fmt.Errorf("prepareRollback: Release name is invalid: %s", name)
return nil, nil, false, fmt.Errorf("prepareRollback: Release name is invalid: %s", name)
}
if r.Version < 0 {
return nil, nil, errInvalidRevision
return nil, nil, false, errInvalidRevision
}
currentRelease, err := r.cfg.Releases.Last(name)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
previousVersion := r.Version
@ -114,7 +122,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
historyReleases, err := r.cfg.Releases.History(name)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
// Check if the history version to be rolled back exists
@ -127,14 +135,19 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
}
}
if !previousVersionExist {
return nil, nil, fmt.Errorf("release has no %d version", previousVersion)
return nil, nil, false, fmt.Errorf("release has no %d version", previousVersion)
}
slog.Debug("rolling back", "name", name, "currentVersion", currentRelease.Version, "targetVersion", previousVersion)
previousRelease, err := r.cfg.Releases.Get(name, previousVersion)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
serverSideApply, err := getUpgradeServerSideValue(r.ServerSideApply, previousRelease.ApplyMethod)
if err != nil {
return nil, nil, false, err
}
// Store a new release object with previous release's configuration
@ -152,16 +165,17 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele
// message here, and only override it later if we experience failure.
Description: fmt.Sprintf("Rollback to %d", previousVersion),
},
Version: currentRelease.Version + 1,
Labels: previousRelease.Labels,
Manifest: previousRelease.Manifest,
Hooks: previousRelease.Hooks,
Version: currentRelease.Version + 1,
Labels: previousRelease.Labels,
Manifest: previousRelease.Manifest,
Hooks: previousRelease.Hooks,
ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)),
}
return currentRelease, targetRelease, nil
return currentRelease, targetRelease, serverSideApply, nil
}
func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release) (*release.Release, error) {
func (r *Rollback) performRollback(currentRelease, targetRelease *release.Release, serverSideApply bool) (*release.Release, error) {
if r.DryRun {
slog.Debug("dry run", "name", targetRelease.Name)
return targetRelease, nil
@ -177,20 +191,27 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
}
// pre-rollback hooks
if !r.DisableHooks {
if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.Timeout); err != nil {
if err := r.cfg.execHook(targetRelease, release.HookPreRollback, r.WaitStrategy, r.Timeout, serverSideApply); err != nil {
return targetRelease, err
}
} else {
slog.Debug("rollback hooks disabled", "name", targetRelease.Name)
}
// 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 = target.Visit(setMetadataVisitor(targetRelease.Name, targetRelease.Namespace, true))
if err != nil {
return targetRelease, fmt.Errorf("unable to set metadata visitor from target release: %w", err)
}
results, err := r.cfg.KubeClient.Update(current, target, r.ForceReplace)
results, err := r.cfg.KubeClient.Update(
current,
target,
kube.ClientUpdateOptionForceReplace(r.ForceReplace),
kube.ClientUpdateOptionServerSideApply(serverSideApply, r.ForceConflicts),
kube.ClientUpdateOptionThreeWayMergeForUnstructured(false),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(true))
if err != nil {
msg := fmt.Sprintf("Rollback %q failed: %s", targetRelease.Name, err)
@ -235,7 +256,7 @@ func (r *Rollback) performRollback(currentRelease, targetRelease *release.Releas
// post-rollback hooks
if !r.DisableHooks {
if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.Timeout); err != nil {
if err := r.cfg.execHook(targetRelease, release.HookPostRollback, r.WaitStrategy, r.Timeout, serverSideApply); err != nil {
return targetRelease, err
}
}

@ -129,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))
}
}

@ -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)
@ -105,6 +109,7 @@ func TestShowCRDs(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")},
},
}
@ -119,6 +124,9 @@ foo
---
bar
---
baz
`
if output != expect {
t.Errorf("Expected\n%q\nGot\n%q\n", expect, output)

@ -17,6 +17,7 @@ limitations under the License.
package action
import (
"errors"
"fmt"
"log/slog"
"strings"
@ -28,6 +29,7 @@ import (
"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"
)
@ -66,9 +68,11 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
}
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
@ -111,7 +115,8 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
res := &release.UninstallReleaseResponse{Release: rel}
if !u.DisableHooks {
if err := u.cfg.execHook(rel, release.HookPreDelete, u.WaitStrategy, u.Timeout); err != nil {
serverSideApply := true
if err := u.cfg.execHook(rel, release.HookPreDelete, u.WaitStrategy, u.Timeout, serverSideApply); err != nil {
return res, err
}
} else {
@ -140,7 +145,8 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error)
}
if !u.DisableHooks {
if err := u.cfg.execHook(rel, release.HookPostDelete, u.WaitStrategy, u.Timeout); err != nil {
serverSideApply := true
if err := u.cfg.execHook(rel, release.HookPostDelete, u.WaitStrategy, u.Timeout, serverSideApply); err != nil {
errs = append(errs, err)
}
}

@ -21,6 +21,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/pkg/kube"
kubefake "helm.sh/helm/v4/pkg/kube/fake"
@ -34,6 +35,17 @@ func uninstallAction(t *testing.T) *Uninstall {
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
@ -44,7 +56,6 @@ func TestUninstallRelease_ignoreNotFound(t *testing.T) {
is.Nil(res)
is.NoError(err)
}
func TestUninstallRelease_deleteRelease(t *testing.T) {
is := assert.New(t)
@ -137,6 +148,6 @@ func TestUninstallRelease_Cascade(t *testing.T) {
failer.BuildDummy = true
unAction.cfg.KubeClient = failer
_, err := unAction.Run(rel.Name)
is.Error(err)
require.Error(t, err)
is.Contains(err.Error(), "failed to delete release: come-fail-away")
}

@ -31,7 +31,7 @@ import (
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/postrenderer"
"helm.sh/helm/v4/pkg/registry"
releaseutil "helm.sh/helm/v4/pkg/release/util"
release "helm.sh/helm/v4/pkg/release/v1"
@ -81,6 +81,14 @@ type Upgrade struct {
//
// This should be used with caution.
ForceReplace bool
// ForceConflicts causes server-side apply to force conflicts ("Overwrite value, become sole manager")
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
ForceConflicts bool
// ServerSideApply enables changes to be applied via Kubernetes server-side apply
// Can be the string: "true", "false" or "auto"
// When "auto", sever-side usage will be based upon the releases previous usage
// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/
ServerSideApply string
// ResetValues will reset the values to the chart's built-ins rather than merging with existing.
ResetValues bool
// ReuseValues will reuse the user's last supplied values.
@ -89,8 +97,8 @@ type Upgrade struct {
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.
@ -106,7 +114,7 @@ type Upgrade struct {
//
// If this is non-nil, then after templates are rendered, they will be sent to the
// post renderer before sending to the Kubernetes API server.
PostRenderer postrender.PostRenderer
PostRenderer postrenderer.PostRenderer
// DisableOpenAPIValidation controls whether OpenAPI validation is enforced.
DisableOpenAPIValidation bool
// Get missing dependencies
@ -127,7 +135,8 @@ type resultMessage struct {
// NewUpgrade creates a new Upgrade object with the given configuration.
func NewUpgrade(cfg *Configuration) *Upgrade {
up := &Upgrade{
cfg: cfg,
cfg: cfg,
ServerSideApply: "auto",
}
up.registryClient = cfg.RegistryClient
@ -151,9 +160,9 @@ 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
if u.WaitStrategy == kube.HookOnlyStrategy && u.Atomic {
if u.WaitStrategy == kube.HookOnlyStrategy && u.RollbackOnFailure {
u.WaitStrategy = kube.StatusWatcherStrategy
}
@ -162,7 +171,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
}
slog.Debug("preparing upgrade", "name", name)
currentRelease, upgradedRelease, err := u.prepareUpgrade(name, chart, vals)
currentRelease, upgradedRelease, serverSideApply, err := u.prepareUpgrade(name, chart, vals)
if err != nil {
return nil, err
}
@ -170,7 +179,7 @@ func (u *Upgrade) RunWithContext(ctx context.Context, name string, chart *chart.
u.cfg.Releases.MaxHistory = u.MaxHistory
slog.Debug("performing update", "name", name)
res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease)
res, err := u.performUpgrade(ctx, currentRelease, upgradedRelease, serverSideApply)
if err != nil {
return res, err
}
@ -195,14 +204,14 @@ func (u *Upgrade) isDryRun() bool {
}
// prepareUpgrade builds an upgraded release for an upgrade operation.
func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, error) {
func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[string]interface{}) (*release.Release, *release.Release, bool, error) {
if chart == nil {
return nil, nil, errMissingChart
return nil, nil, false, 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")
return nil, nil, false, errors.New("hiding Kubernetes secrets requires a dry-run mode")
}
// finds the last non-deleted release with the given name
@ -210,14 +219,14 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
if err != nil {
// to keep existing behavior of returning the "%q has no deployed releases" error when an existing release does not exist
if errors.Is(err, driver.ErrReleaseNotFound) {
return nil, nil, driver.NewErrNoDeployedReleases(name)
return nil, nil, false, driver.NewErrNoDeployedReleases(name)
}
return nil, nil, err
return nil, nil, false, err
}
// Concurrent `helm upgrade`s will either fail here with `errPending` or when creating the release with "already exists". This should act as a pessimistic lock.
if lastRelease.Info.Status.IsPending() {
return nil, nil, errPending
return nil, nil, false, errPending
}
var currentRelease *release.Release
@ -232,7 +241,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
(lastRelease.Info.Status == release.StatusFailed || lastRelease.Info.Status == release.StatusSuperseded) {
currentRelease = lastRelease
} else {
return nil, nil, err
return nil, nil, false, err
}
}
}
@ -240,11 +249,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
// determine if values will be reused
vals, err = u.reuseValues(chart, currentRelease, vals)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
if err := chartutil.ProcessDependencies(chart, vals); err != nil {
return nil, nil, err
return nil, nil, false, err
}
// Increment revision count. This is passed to templates, and also stored on
@ -260,11 +269,11 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
caps, err := u.cfg.getCapabilities()
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation)
if err != nil {
return nil, nil, err
return nil, nil, false, err
}
// Determine whether or not to interact with remote
@ -275,13 +284,20 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
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
return nil, nil, false, err
}
if driver.ContainsSystemLabels(u.Labels) {
return nil, nil, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
return nil, nil, false, fmt.Errorf("user supplied labels contains system reserved label name. System labels: %+v", driver.GetSystemLabels())
}
serverSideApply, err := getUpgradeServerSideValue(u.ServerSideApply, lastRelease.ApplyMethod)
if err != nil {
return nil, nil, false, err
}
slog.Debug("determined release apply method", slog.Bool("server_side_apply", serverSideApply), slog.String("previous_release_apply_method", lastRelease.ApplyMethod))
// Store an upgraded release.
upgradedRelease := &release.Release{
Name: name,
@ -294,20 +310,21 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin
Status: release.StatusPendingUpgrade,
Description: "Preparing upgrade", // This should be overwritten later.
},
Version: revision,
Manifest: manifestDoc.String(),
Hooks: hooks,
Labels: mergeCustomLabels(lastRelease.Labels, u.Labels),
Version: revision,
Manifest: manifestDoc.String(),
Hooks: hooks,
Labels: mergeCustomLabels(lastRelease.Labels, u.Labels),
ApplyMethod: string(determineReleaseSSApplyMethod(serverSideApply)),
}
if len(notesTxt) > 0 {
upgradedRelease.Info.Notes = notesTxt
}
err = validateManifest(u.cfg.KubeClient, manifestDoc.Bytes(), !u.DisableOpenAPIValidation)
return currentRelease, upgradedRelease, err
return currentRelease, upgradedRelease, serverSideApply, err
}
func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release) (*release.Release, error) {
func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedRelease *release.Release, serverSideApply bool) (*release.Release, error) {
current, err := u.cfg.KubeClient.Build(bytes.NewBufferString(originalRelease.Manifest), false)
if err != nil {
// Checking for removed Kubernetes API error so can provide a more informative error message to the user
@ -380,8 +397,9 @@ func (u *Upgrade) performUpgrade(ctx context.Context, originalRelease, upgradedR
ctxChan := make(chan resultMessage)
doneChan := make(chan interface{})
defer close(doneChan)
go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease)
go u.releasingUpgrade(rChan, upgradedRelease, current, target, originalRelease, serverSideApply)
go u.handleContext(ctx, doneChan, ctxChan, upgradedRelease)
select {
case result := <-rChan:
return result.r, result.e
@ -390,7 +408,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) {
@ -408,17 +426,22 @@ 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
}
}
func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release) {
func isReleaseApplyMethodClientSideApply(applyMethod string) bool {
return applyMethod == "" || applyMethod == string(release.ApplyMethodClientSideApply)
}
func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *release.Release, current kube.ResourceList, target kube.ResourceList, originalRelease *release.Release, serverSideApply bool) {
// pre-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.Timeout); err != nil {
if err := u.cfg.execHook(upgradedRelease, release.HookPreUpgrade, u.WaitStrategy, u.Timeout, serverSideApply); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, kube.ResourceList{}, fmt.Errorf("pre-upgrade hooks failed: %s", err))
return
}
@ -426,7 +449,13 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
slog.Debug("upgrade hooks disabled", "name", upgradedRelease.Name)
}
results, err := u.cfg.KubeClient.Update(current, target, u.ForceReplace)
upgradeClientSideFieldManager := isReleaseApplyMethodClientSideApply(originalRelease.ApplyMethod) && serverSideApply // Update client-side field manager if transitioning from client-side to server-side apply
results, err := u.cfg.KubeClient.Update(
current,
target,
kube.ClientUpdateOptionForceReplace(u.ForceReplace),
kube.ClientUpdateOptionServerSideApply(serverSideApply, u.ForceConflicts),
kube.ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager))
if err != nil {
u.cfg.recordRelease(originalRelease)
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, err)
@ -455,7 +484,7 @@ func (u *Upgrade) releasingUpgrade(c chan<- resultMessage, upgradedRelease *rele
// post-upgrade hooks
if !u.DisableHooks {
if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.Timeout); err != nil {
if err := u.cfg.execHook(upgradedRelease, release.HookPostUpgrade, u.WaitStrategy, u.Timeout, serverSideApply); err != nil {
u.reportToPerformUpgrade(c, upgradedRelease, results.Created, fmt.Errorf("post-upgrade hooks failed: %s", err))
return
}
@ -495,8 +524,9 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
}
slog.Debug("resource cleanup complete")
}
if u.Atomic {
slog.Debug("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
@ -526,11 +556,13 @@ func (u *Upgrade) failRelease(rel *release.Release, created kube.ResourceList, e
rollin.WaitForJobs = u.WaitForJobs
rollin.DisableHooks = u.DisableHooks
rollin.ForceReplace = u.ForceReplace
rollin.ForceConflicts = u.ForceConflicts
rollin.ServerSideApply = u.ServerSideApply
rollin.Timeout = u.Timeout
if rollErr := rollin.Run(rel.Name); rollErr != nil {
return rel, fmt.Errorf("an error occurred while rolling back the release. original upgrade error: %w: %w", err, rollErr)
}
return rel, fmt.Errorf("release %s failed, and has been rolled back due to atomic being set: %w", rel.Name, err)
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
@ -603,3 +635,16 @@ func mergeCustomLabels(current, desired map[string]string) map[string]string {
}
return labels
}
func getUpgradeServerSideValue(serverSideOption string, releaseApplyMethod string) (bool, error) {
switch serverSideOption {
case "auto":
return releaseApplyMethod == "ssa", nil
case "false":
return false, nil
case "true":
return true, nil
default:
return false, fmt.Errorf("invalid/unknown release server-side apply method: %s", serverSideOption)
}
}

@ -141,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()
@ -157,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)
@ -172,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"
@ -182,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)
@ -409,7 +409,8 @@ func TestUpgradeRelease_Interrupted_Wait(t *testing.T) {
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)
@ -422,7 +423,7 @@ 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, cancel := context.WithCancel(t.Context())
@ -431,7 +432,7 @@ func TestUpgradeRelease_Interrupted_Atomic(t *testing.T) {
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)
@ -583,3 +584,109 @@ func TestUpgradeRelease_DryRun(t *testing.T) {
done()
req.Error(err)
}
func TestGetUpgradeServerSideValue(t *testing.T) {
tests := []struct {
name string
actionServerSideOption string
releaseApplyMethod string
expectedServerSideApply bool
}{
{
name: "action ssa auto / release csa",
actionServerSideOption: "auto",
releaseApplyMethod: "csa",
expectedServerSideApply: false,
},
{
name: "action ssa auto / release ssa",
actionServerSideOption: "auto",
releaseApplyMethod: "ssa",
expectedServerSideApply: true,
},
{
name: "action ssa auto / release empty",
actionServerSideOption: "auto",
releaseApplyMethod: "",
expectedServerSideApply: false,
},
{
name: "action ssa true / release csa",
actionServerSideOption: "true",
releaseApplyMethod: "csa",
expectedServerSideApply: true,
},
{
name: "action ssa true / release ssa",
actionServerSideOption: "true",
releaseApplyMethod: "ssa",
expectedServerSideApply: true,
},
{
name: "action ssa true / release 'unknown'",
actionServerSideOption: "true",
releaseApplyMethod: "foo",
expectedServerSideApply: true,
},
{
name: "action ssa true / release empty",
actionServerSideOption: "true",
releaseApplyMethod: "",
expectedServerSideApply: true,
},
{
name: "action ssa false / release csa",
actionServerSideOption: "false",
releaseApplyMethod: "ssa",
expectedServerSideApply: false,
},
{
name: "action ssa false / release ssa",
actionServerSideOption: "false",
releaseApplyMethod: "ssa",
expectedServerSideApply: false,
},
{
name: "action ssa false / release 'unknown'",
actionServerSideOption: "false",
releaseApplyMethod: "foo",
expectedServerSideApply: false,
},
{
name: "action ssa false / release empty",
actionServerSideOption: "false",
releaseApplyMethod: "ssa",
expectedServerSideApply: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serverSideApply, err := getUpgradeServerSideValue(tt.actionServerSideOption, tt.releaseApplyMethod)
assert.Nil(t, err)
assert.Equal(t, tt.expectedServerSideApply, serverSideApply)
})
}
testsError := []struct {
name string
actionServerSideOption string
releaseApplyMethod string
expectedErrorMsg string
}{
{
name: "action invalid option",
actionServerSideOption: "invalid",
releaseApplyMethod: "ssa",
expectedErrorMsg: "invalid/unknown release server-side apply method: invalid",
},
}
for _, tt := range testsError {
t.Run(tt.name, func(t *testing.T) {
_, err := getUpgradeServerSideValue(tt.actionServerSideOption, tt.releaseApplyMethod)
assert.ErrorContains(t, err, tt.expectedErrorMsg)
})
}
}

@ -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
}

@ -126,14 +126,14 @@ 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
# 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 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
# 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.
@ -174,9 +174,9 @@ ingress:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
# - 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
@ -248,16 +248,16 @@ autoscaling:
# Additional volumes on the output Deployment definition.
volumes: []
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# - name: foo
# secret:
# secretName: mysecret
# optional: false
# Additional volumeMounts on the output Deployment definition.
volumeMounts: []
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
# - name: foo
# mountPath: "/etc/foo"
# readOnly: true
nodeSelector: {}
@ -733,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

@ -16,6 +16,7 @@ limitations under the License.
package util
import (
"fmt"
"log/slog"
"strings"
@ -265,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,

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

Loading…
Cancel
Save