diff --git a/.github/workflows/stale-issue-bot.yaml b/.github/workflows/stale.yaml similarity index 70% rename from .github/workflows/stale-issue-bot.yaml rename to .github/workflows/stale.yaml index 613d2900c..3417e1734 100644 --- a/.github/workflows/stale-issue-bot.yaml +++ b/.github/workflows/stale.yaml @@ -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 diff --git a/Makefile b/Makefile index d1d6d0982..5e424bf05 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/go.mod b/go.mod index 688094670..7099e9d46 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5ac66f328..1a1601366 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/chart/v3/util/create.go b/internal/chart/v3/util/create.go index 72fed5955..6a28f99d4 100644 --- a/internal/chart/v3/util/create.go +++ b/internal/chart/v3/util/create.go @@ -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 diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index bd5032ce4..129c46372 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -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, diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 946a211ef..2e8208d08 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -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, diff --git a/pkg/plugin/cache/cache.go b/internal/plugin/cache/cache.go similarity index 96% rename from pkg/plugin/cache/cache.go rename to internal/plugin/cache/cache.go index f3e847374..f3b737477 100644 --- a/pkg/plugin/cache/cache.go +++ b/internal/plugin/cache/cache.go @@ -14,7 +14,7 @@ limitations under the License. */ // Package cache provides a key generator for vcs urls. -package cache // import "helm.sh/helm/v4/pkg/plugin/cache" +package cache // import "helm.sh/helm/v4/internal/plugin/cache" import ( "net/url" diff --git a/internal/plugin/config.go b/internal/plugin/config.go new file mode 100644 index 000000000..e8bf4e356 --- /dev/null +++ b/internal/plugin/config.go @@ -0,0 +1,87 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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 ' 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 +} diff --git a/internal/plugin/descriptor.go b/internal/plugin/descriptor.go new file mode 100644 index 000000000..ba92b3c55 --- /dev/null +++ b/internal/plugin/descriptor.go @@ -0,0 +1,24 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +// Descriptor describes a plugin to find +type Descriptor struct { + // Name is the name of the plugin + Name string + // Type is the type of the plugin (cli, getter, postrenderer) + Type string +} diff --git a/internal/plugin/doc.go b/internal/plugin/doc.go new file mode 100644 index 000000000..39ba6300b --- /dev/null +++ b/internal/plugin/doc.go @@ -0,0 +1,89 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +--- +TODO: move this section to public plugin package + +Package plugin provides the implementation of the Helm plugin system. + +Conceptually, "plugins" enable extending Helm's functionality external to Helm's core codebase. The plugin system allows +code to fetch plugins by type, then invoke the plugin with an input as required by that plugin type. The plugin +returning an output for the caller to consume. + +An example of a plugin invocation: +``` +d := plugin.Descriptor{ + Type: "example/v1", // +} +plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) + +for _, plg := range plgs { + input := &plugin.Input{ + Message: schema.InputMessageExampleV1{ // The type of the input message is defined by the plugin's "type" (example/v1 here) + ... + }, + } + output, err := plg.Invoke(context.Background(), input) + if err != nil { + ... + } + + // consume the output, using type assertion to convert to the expected output type (as defined by the plugin's "type") + outputMessage, ok := output.Message.(schema.OutputMessageExampleV1) +} + +--- + +Package `plugin` provides the implementation of the Helm plugin system. + +Helm plugins are exposed to uses as the "Plugin" type, the basic interface that primarily support the "Invoke" method. + +# Plugin Runtimes +Internally, plugins must be implemented by a "runtime" that is responsible for creating the plugin instance, and dispatching the plugin's invocation to the plugin's implementation. +For example: +- forming environment variables and command line args for subprocess execution +- converting input to JSON and invoking a function in a Wasm runtime + +Internally, the code structure is: +Runtime.CreatePlugin() + | + | (creates) + | + \---> PluginRuntime + | + | (implements) + v + Plugin.Invoke() + +# Plugin Types +Each plugin implements a specific functionality, denoted by the plugin's "type" e.g. "getter/v1". The "type" includes a version, in order to allow a given types messaging schema and invocation options to evolve. + +Specifically, the plugin's "type" specifies the contract for the input and output messages that are expected to be passed to the plugin, and returned from the plugin. The plugin's "type" also defines the options that can be passed to the plugin when invoking it. + +# Metadata +Each plugin must have a `plugin.yaml`, that defines the plugin's metadata. The metadata includes the plugin's name, version, and other information. + +For legacy plugins, the type is inferred by which fields are set on the plugin: a downloader plugin is inferred when metadata contains a "downloaders" yaml node, otherwise it is assumed to define a Helm CLI subcommand. + +For v1 plugins, the metadata includes explicit apiVersion and type fields. It will also contain type-specific Config, and RuntimeConfig fields. + +# Runtime and type cardinality +From a cardinality perspective, this means there a "few" runtimes, and "many" plugins types. It is also expected that the subprocess runtime will not be extended to support extra plugin types, and deprecated in a future version of Helm. + +Future ideas that are intended to be implemented include extending the plugin system to support future Wasm standards. Or allowing Helm SDK user's to inject "plugins" that are actually implemented as native go modules. Or even moving Helm's internal functionality e.g. yaml rendering engine to be used as an "in-built" plugin, along side other plugins that may implement other (non-go template) rendering engines. +*/ + +package plugin diff --git a/internal/plugin/error.go b/internal/plugin/error.go new file mode 100644 index 000000000..5ace680cb --- /dev/null +++ b/internal/plugin/error.go @@ -0,0 +1,29 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +// InvokeExecError is returned when a plugin invocation returns a non-zero status/exit code +// - subprocess plugin: child process exit code +// - extism plugin: wasm function return code +type InvokeExecError struct { + Err error // Underlying error + Code int // Exeit code from plugin code execution +} + +// Error implements the error interface +func (e *InvokeExecError) Error() string { + return e.Err.Error() +} diff --git a/pkg/plugin/installer/base.go b/internal/plugin/installer/base.go similarity index 93% rename from pkg/plugin/installer/base.go rename to internal/plugin/installer/base.go index 3738246ee..c21a245a8 100644 --- a/pkg/plugin/installer/base.go +++ b/internal/plugin/installer/base.go @@ -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" diff --git a/pkg/plugin/installer/base_test.go b/internal/plugin/installer/base_test.go similarity index 94% rename from pkg/plugin/installer/base_test.go rename to internal/plugin/installer/base_test.go index 732ac7927..62b77bde5 100644 --- a/pkg/plugin/installer/base_test.go +++ b/internal/plugin/installer/base_test.go @@ -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" diff --git a/pkg/plugin/installer/doc.go b/internal/plugin/installer/doc.go similarity index 89% rename from pkg/plugin/installer/doc.go rename to internal/plugin/installer/doc.go index b927dbd37..a4cf384bf 100644 --- a/pkg/plugin/installer/doc.go +++ b/internal/plugin/installer/doc.go @@ -14,4 +14,4 @@ limitations under the License. */ // Package installer provides an interface for installing Helm plugins. -package installer // import "helm.sh/helm/v4/pkg/plugin/installer" +package installer // import "helm.sh/helm/v4/internal/plugin/installer" diff --git a/pkg/plugin/installer/http_installer.go b/internal/plugin/installer/extractor.go similarity index 71% rename from pkg/plugin/installer/http_installer.go rename to internal/plugin/installer/extractor.go index 3bcf71208..9417a0535 100644 --- a/pkg/plugin/installer/http_installer.go +++ b/internal/plugin/installer/extractor.go @@ -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-) -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-) +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`) +} diff --git a/internal/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go new file mode 100644 index 000000000..bb96314f4 --- /dev/null +++ b/internal/plugin/installer/http_installer.go @@ -0,0 +1,191 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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 +} diff --git a/pkg/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go similarity index 53% rename from pkg/plugin/installer/http_installer_test.go rename to internal/plugin/installer/http_installer_test.go index ed4b73b35..be40b1b90 100644 --- a/pkg/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -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) + } +} diff --git a/pkg/plugin/installer/installer.go b/internal/plugin/installer/installer.go similarity index 53% rename from pkg/plugin/installer/installer.go rename to internal/plugin/installer/installer.go index d88737ebf..b65dac2f4 100644 --- a/pkg/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -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 diff --git a/pkg/plugin/installer/installer_test.go b/internal/plugin/installer/installer_test.go similarity index 71% rename from pkg/plugin/installer/installer_test.go rename to internal/plugin/installer/installer_test.go index a11464924..dcd76fe9c 100644 --- a/pkg/plugin/installer/installer_test.go +++ b/internal/plugin/installer/installer_test.go @@ -26,8 +26,15 @@ func TestIsRemoteHTTPArchive(t *testing.T) { t.Errorf("Expected non-URL to return false") } - if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { - t.Errorf("Bad URL should not have succeeded.") + // URLs with valid archive extensions are considered valid archives + // even if the server is unreachable (optimization to avoid unnecessary HTTP requests) + if !isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { + t.Errorf("URL with .tgz extension should be considered a valid archive") + } + + // Test with invalid extension and unreachable server + if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.notanarchive") { + t.Errorf("Bad URL without valid extension should not succeed") } if !isRemoteHTTPArchive(source) { diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go new file mode 100644 index 000000000..e02261d59 --- /dev/null +++ b/internal/plugin/installer/local_installer.go @@ -0,0 +1,219 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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 +} diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go new file mode 100644 index 000000000..339028ef3 --- /dev/null +++ b/internal/plugin/installer/local_installer_test.go @@ -0,0 +1,148 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer // import "helm.sh/helm/v4/internal/plugin/installer" + +import ( + "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) + } +} diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go new file mode 100644 index 000000000..afbb42ca5 --- /dev/null +++ b/internal/plugin/installer/oci_installer.go @@ -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 +} diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go new file mode 100644 index 000000000..1280cf97d --- /dev/null +++ b/internal/plugin/installer/oci_installer_test.go @@ -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'") + } + } + }) + } +} diff --git a/internal/plugin/installer/plugin_structure.go b/internal/plugin/installer/plugin_structure.go new file mode 100644 index 000000000..10647141e --- /dev/null +++ b/internal/plugin/installer/plugin_structure.go @@ -0,0 +1,80 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "helm.sh/helm/v4/internal/plugin" +) + +// detectPluginRoot searches for plugin.yaml in the extracted directory +// and returns the path to the directory containing it. +// This handles cases where the tarball contains the plugin in a subdirectory. +func detectPluginRoot(extractDir string) (string, error) { + // First check if plugin.yaml is at the root + if _, err := os.Stat(filepath.Join(extractDir, plugin.PluginFileName)); err == nil { + return extractDir, nil + } + + // Otherwise, look for plugin.yaml in subdirectories (only one level deep) + entries, err := os.ReadDir(extractDir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if entry.IsDir() { + subdir := filepath.Join(extractDir, entry.Name()) + if _, err := os.Stat(filepath.Join(subdir, plugin.PluginFileName)); err == nil { + return subdir, nil + } + } + } + + return "", fmt.Errorf("plugin.yaml not found in %s or its immediate subdirectories", extractDir) +} + +// validatePluginName checks if the plugin directory name matches the plugin name +// from plugin.yaml when the plugin is in a subdirectory. +func validatePluginName(pluginRoot string, expectedName string) error { + // Only validate if plugin is in a subdirectory + dirName := filepath.Base(pluginRoot) + if dirName == expectedName { + return nil + } + + // Load plugin.yaml to get the actual name + p, err := plugin.LoadDir(pluginRoot) + if err != nil { + return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err) + } + + m := p.Metadata() + actualName := m.Name + + // For now, just log a warning if names don't match + // In the future, we might want to enforce this more strictly + if actualName != dirName && actualName != strings.TrimSuffix(expectedName, filepath.Ext(expectedName)) { + // This is just informational - not an error + return nil + } + + return nil +} diff --git a/internal/plugin/installer/plugin_structure_test.go b/internal/plugin/installer/plugin_structure_test.go new file mode 100644 index 000000000..c8766ce59 --- /dev/null +++ b/internal/plugin/installer/plugin_structure_test.go @@ -0,0 +1,165 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package installer + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectPluginRoot(t *testing.T) { + tests := []struct { + name string + setup func(dir string) error + expectRoot string + expectError bool + }{ + { + name: "plugin.yaml at root", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: ".", + expectError: false, + }, + { + name: "plugin.yaml in subdirectory", + setup: func(dir string) error { + subdir := filepath.Join(dir, "my-plugin") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: "my-plugin", + expectError: false, + }, + { + name: "no plugin.yaml", + setup: func(dir string) error { + return os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0644) + }, + expectRoot: "", + expectError: true, + }, + { + name: "plugin.yaml in nested subdirectory (should not find)", + setup: func(dir string) error { + subdir := filepath.Join(dir, "outer", "inner") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644) + }, + expectRoot: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := tt.setup(dir); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + root, err := detectPluginRoot(dir) + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + expectedPath := dir + if tt.expectRoot != "." { + expectedPath = filepath.Join(dir, tt.expectRoot) + } + if root != expectedPath { + t.Errorf("Expected root %s but got %s", expectedPath, root) + } + } + }) + } +} + +func TestValidatePluginName(t *testing.T) { + tests := []struct { + name string + setup func(dir string) error + pluginRoot string + expectedName string + expectError bool + }{ + { + name: "matching directory and plugin name", + setup: func(dir string) error { + subdir := filepath.Join(dir, "my-plugin") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + yaml := `name: my-plugin +version: 1.0.0 +usage: test +description: test` + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644) + }, + pluginRoot: "my-plugin", + expectedName: "my-plugin", + expectError: false, + }, + { + name: "different directory and plugin name", + setup: func(dir string) error { + subdir := filepath.Join(dir, "wrong-name") + if err := os.MkdirAll(subdir, 0755); err != nil { + return err + } + yaml := `name: my-plugin +version: 1.0.0 +usage: test +description: test` + return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644) + }, + pluginRoot: "wrong-name", + expectedName: "wrong-name", + expectError: false, // Currently we don't error on mismatch + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := tt.setup(dir); err != nil { + t.Fatalf("Setup failed: %v", err) + } + + pluginRoot := filepath.Join(dir, tt.pluginRoot) + err := validatePluginName(pluginRoot, tt.expectedName) + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} diff --git a/pkg/plugin/installer/vcs_installer.go b/internal/plugin/installer/vcs_installer.go similarity index 97% rename from pkg/plugin/installer/vcs_installer.go rename to internal/plugin/installer/vcs_installer.go index 3e53cbf11..3601ec7a8 100644 --- a/pkg/plugin/installer/vcs_installer.go +++ b/internal/plugin/installer/vcs_installer.go @@ -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. diff --git a/pkg/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go similarity index 94% rename from pkg/plugin/installer/vcs_installer_test.go rename to internal/plugin/installer/vcs_installer_test.go index 491d58a3f..d542a0f75 100644 --- a/pkg/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -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 diff --git a/internal/plugin/installer/verification_test.go b/internal/plugin/installer/verification_test.go new file mode 100644 index 000000000..22f0a8308 --- /dev/null +++ b/internal/plugin/installer/verification_test.go @@ -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 +} diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go new file mode 100644 index 000000000..a58a84126 --- /dev/null +++ b/internal/plugin/loader.go @@ -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 +} diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go new file mode 100644 index 000000000..63d930cbe --- /dev/null +++ b/internal/plugin/loader_test.go @@ -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)) + }) + } +} diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go new file mode 100644 index 000000000..fbe7a16b8 --- /dev/null +++ b/internal/plugin/metadata.go @@ -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 +} diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go new file mode 100644 index 000000000..ce9c2f580 --- /dev/null +++ b/internal/plugin/metadata_legacy.go @@ -0,0 +1,113 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "fmt" + "strings" + "unicode" +) + +// Downloaders represents the plugins capability if it can retrieve +// charts from special sources +type Downloaders struct { + // Protocols are the list of schemes from the charts URL. + Protocols []string `yaml:"protocols"` + // Command is the executable path with which the plugin performs + // the actual download for the corresponding Protocols + Command string `yaml:"command"` +} + +// MetadataLegacy is the legacy plugin.yaml format +type MetadataLegacy struct { + // Name is the name of the plugin + Name string `yaml:"name"` + + // Version is a SemVer 2 version of the plugin. + Version string `yaml:"version"` + + // Usage is the single-line usage text shown in help + Usage string `yaml:"usage"` + + // Description is a long description shown in places like `helm help` + Description string `yaml:"description"` + + // PlatformCommands is the plugin command, with a platform selector and support for args. + PlatformCommands []PlatformCommand `yaml:"platformCommand"` + + // Command is the plugin command, as a single string. + // DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins. + Command string `yaml:"command"` + + // IgnoreFlags ignores any flags passed in from Helm + IgnoreFlags bool `yaml:"ignoreFlags"` + + // PlatformHooks are commands that will run on plugin events, with a platform selector and support for args. + PlatformHooks PlatformHooks `yaml:"platformHooks"` + + // Hooks are commands that will run on plugin events, as a single string. + // DEPRECATED: Use PlatformHooks instead. Removed in subprocess/v1 plugins. + Hooks Hooks `yaml:"hooks"` + + // Downloaders field is used if the plugin supply downloader mechanism + // for special protocols. + Downloaders []Downloaders `yaml:"downloaders"` +} + +func (m *MetadataLegacy) Validate() error { + if !validPluginName.MatchString(m.Name) { + return fmt.Errorf("invalid plugin name") + } + m.Usage = sanitizeString(m.Usage) + + if len(m.PlatformCommands) > 0 && len(m.Command) > 0 { + return fmt.Errorf("both platformCommand and command are set") + } + + if len(m.PlatformHooks) > 0 && len(m.Hooks) > 0 { + return fmt.Errorf("both platformHooks and hooks are set") + } + + // Validate downloader plugins + for i, downloader := range m.Downloaders { + if downloader.Command == "" { + return fmt.Errorf("downloader %d has empty command", i) + } + if len(downloader.Protocols) == 0 { + return fmt.Errorf("downloader %d has no protocols", i) + } + for j, protocol := range downloader.Protocols { + if protocol == "" { + return fmt.Errorf("downloader %d has empty protocol at index %d", i, j) + } + } + } + + return nil +} + +// sanitizeString normalize spaces and removes non-printable characters. +func sanitizeString(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return ' ' + } + if unicode.IsPrint(r) { + return r + } + return -1 + }, str) +} diff --git a/internal/plugin/metadata_test.go b/internal/plugin/metadata_test.go new file mode 100644 index 000000000..810020a67 --- /dev/null +++ b/internal/plugin/metadata_test.go @@ -0,0 +1,141 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "strings" + "testing" +) + +func TestValidatePluginData(t *testing.T) { + + // A mock plugin with no commands + mockNoCommand := mockSubprocessCLIPlugin(t, "foo") + mockNoCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{ + PlatformCommands: []PlatformCommand{}, + PlatformHooks: map[string][]PlatformCommand{}, + } + + // A mock plugin with legacy commands + mockLegacyCommand := mockSubprocessCLIPlugin(t, "foo") + mockLegacyCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{ + PlatformCommands: []PlatformCommand{}, + Command: "echo \"mock plugin\"", + PlatformHooks: map[string][]PlatformCommand{}, + Hooks: map[string]string{ + Install: "echo installing...", + }, + } + + // A mock plugin with a command also set + mockWithCommand := mockSubprocessCLIPlugin(t, "foo") + mockWithCommand.metadata.RuntimeConfig = &RuntimeConfigSubprocess{ + PlatformCommands: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}}, + }, + Command: "echo \"mock plugin\"", + } + + // A mock plugin with a hooks also set + mockWithHooks := mockSubprocessCLIPlugin(t, "foo") + mockWithHooks.metadata.RuntimeConfig = &RuntimeConfigSubprocess{ + PlatformCommands: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}}, + }, + PlatformHooks: map[string][]PlatformCommand{ + Install: { + {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}}, + }, + }, + Hooks: map[string]string{ + Install: "echo installing...", + }, + } + + for i, item := range []struct { + pass bool + plug Plugin + errString string + }{ + {true, mockSubprocessCLIPlugin(t, "abcdefghijklmnopqrstuvwxyz0123456789_-ABC"), ""}, + {true, mockSubprocessCLIPlugin(t, "foo-bar-FOO-BAR_1234"), ""}, + {false, mockSubprocessCLIPlugin(t, "foo -bar"), "invalid name"}, + {false, mockSubprocessCLIPlugin(t, "$foo -bar"), "invalid name"}, // Test leading chars + {false, mockSubprocessCLIPlugin(t, "foo -bar "), "invalid name"}, // Test trailing chars + {false, mockSubprocessCLIPlugin(t, "foo\nbar"), "invalid name"}, // Test newline + {true, mockNoCommand, ""}, // Test no command metadata works + {true, mockLegacyCommand, ""}, // Test legacy command metadata works + {false, mockWithCommand, "runtime config validation failed: both platformCommand and command are set"}, // Test platformCommand and command both set fails + {false, mockWithHooks, "runtime config validation failed: both platformHooks and hooks are set"}, // Test platformHooks and hooks both set fails + } { + err := item.plug.Metadata().Validate() + if item.pass && err != nil { + t.Errorf("failed to validate case %d: %s", i, err) + } else if !item.pass && err == nil { + t.Errorf("expected case %d to fail", i) + } + if !item.pass && err.Error() != item.errString { + t.Errorf("index [%d]: expected the following error: %s, but got: %s", i, item.errString, err.Error()) + } + } +} + +func TestMetadataValidateMultipleErrors(t *testing.T) { + // Create metadata with multiple validation issues + metadata := Metadata{ + Name: "invalid name with spaces", // Invalid name + APIVersion: "", // Empty API version + Type: "", // Empty type + Runtime: "", // Empty runtime + Config: nil, // Missing config + RuntimeConfig: nil, // Missing runtime config + } + + err := metadata.Validate() + if err == nil { + t.Fatal("expected validation to fail with multiple errors") + } + + errStr := err.Error() + + // Check that all expected errors are present in the joined error + expectedErrors := []string{ + "invalid name", + "empty APIVersion", + "empty type field", + "empty runtime field", + "missing config field", + "missing runtimeConfig field", + } + + for _, expectedErr := range expectedErrors { + if !strings.Contains(errStr, expectedErr) { + t.Errorf("expected error to contain %q, but got: %v", expectedErr, errStr) + } + } + + // Verify that the error contains the correct number of error messages + errorCount := 0 + for _, expectedErr := range expectedErrors { + if strings.Contains(errStr, expectedErr) { + errorCount++ + } + } + + if errorCount < len(expectedErrors) { + t.Errorf("expected %d errors, but only found %d in: %v", len(expectedErrors), errorCount, errStr) + } +} diff --git a/internal/plugin/metadata_v1.go b/internal/plugin/metadata_v1.go new file mode 100644 index 000000000..81dbc2e20 --- /dev/null +++ b/internal/plugin/metadata_v1.go @@ -0,0 +1,67 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "fmt" +) + +// MetadataV1 is the APIVersion V1 plugin.yaml format +type MetadataV1 struct { + // APIVersion specifies the plugin API version + APIVersion string `yaml:"apiVersion"` + + // Name is the name of the plugin + Name string `yaml:"name"` + + // Type of plugin (eg, cli/v1, getter/v1, 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 +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 000000000..132b1739e --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,81 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin // import "helm.sh/helm/v4/internal/plugin" + +import ( + "context" + "io" + "regexp" +) + +const PluginFileName = "plugin.yaml" + +// Plugin defines a plugin instance. The client (Helm codebase) facing type that can be used to introspect and invoke a plugin +type Plugin interface { + // Dir return the plugin directory (as an absolute path) on the filesystem + Dir() string + + // Metadata describes the plugin's type, version, etc. + // (This metadata type is the converted and plugin version independented in-memory representation of the plugin.yaml file) + Metadata() Metadata + + // Invoke takes the given input, and dispatches the contents to plugin instance + // The input is expected to be a JSON-serializable object, which the plugin will interpret according to its type + // The plugin is expected to return a JSON-serializable object, which the invoker + // will interpret according to the plugin's type + // + // Invoke can be thought of as a request/response mechanism. Similar to e.g. http.RoundTripper + // + // If plugin's execution fails with a non-zero "return code" (this is plugin runtime implementation specific) + // an InvokeExecError is returned + Invoke(ctx context.Context, input *Input) (*Output, error) +} + +// PluginHook allows plugins to implement hooks that are invoked on plugin management events (install, upgrade, etc) +type PluginHook interface { //nolint:revive + InvokeHook(event string) error +} + +// Input defines the input message and parameters to be passed to the plugin +type Input struct { + // Message represents the type-elided value to be passed to the plugin. + // The plugin is expected to interpret the message according to its type + // The message object must be JSON-serializable + Message any + + // Optional: Reader to be consumed plugin's "stdin" + Stdin io.Reader + + // Optional: Writers to consume the plugin's "stdout" and "stderr" + Stdout, Stderr io.Writer + + // Optional: Env represents the environment as a list of "key=value" strings + // see os.Environ + Env []string +} + +// Output defines the output message and parameters the passed from the plugin +type Output struct { + // Message represents the type-elided value returned from the plugin + // The invoker is expected to interpret the message according to the plugin's type + // The message object must be JSON-serializable + Message any +} + +// validPluginName is a regular expression that validates plugin names. +// +// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-. +var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$") diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go new file mode 100644 index 000000000..fbebecac4 --- /dev/null +++ b/internal/plugin/plugin_test.go @@ -0,0 +1,58 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "testing" +) + +func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime { + t.Helper() + + rc := RuntimeConfigSubprocess{ + PlatformCommands: []PlatformCommand{ + {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}}, + {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}}, + }, + PlatformHooks: map[string][]PlatformCommand{ + Install: { + {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}}, + {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}}, + }, + }, + } + + pluginDir := t.TempDir() + + return &SubprocessPluginRuntime{ + metadata: Metadata{ + Name: pluginName, + Version: "v0.1.2", + Type: "cli/v1", + APIVersion: "v1", + Runtime: "subprocess", + Config: &ConfigCLI{ + Usage: "Mock plugin", + ShortHelp: "Mock plugin", + LongHelp: "Mock plugin for testing", + IgnoreFlags: false, + }, + RuntimeConfig: &rc, + }, + pluginDir: pluginDir, // NOTE: dir is empty (ie. plugin.yaml is not present) + RuntimeConfig: rc, + } +} diff --git a/internal/plugin/plugin_type_registry.go b/internal/plugin/plugin_type_registry.go new file mode 100644 index 000000000..63450b823 --- /dev/null +++ b/internal/plugin/plugin_type_registry.go @@ -0,0 +1,100 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* +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 +}() diff --git a/internal/plugin/plugin_type_registry_test.go b/internal/plugin/plugin_type_registry_test.go new file mode 100644 index 000000000..ee8a44bb6 --- /dev/null +++ b/internal/plugin/plugin_type_registry_test.go @@ -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) +} diff --git a/internal/plugin/runtime.go b/internal/plugin/runtime.go new file mode 100644 index 000000000..a9c01a380 --- /dev/null +++ b/internal/plugin/runtime.go @@ -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 +} diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go new file mode 100644 index 000000000..c0122d08f --- /dev/null +++ b/internal/plugin/runtime_extismv1.go @@ -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 +} diff --git a/internal/plugin/runtime_extismv1_test.go b/internal/plugin/runtime_extismv1_test.go new file mode 100644 index 000000000..8d9c55195 --- /dev/null +++ b/internal/plugin/runtime_extismv1_test.go @@ -0,0 +1,124 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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) +} diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go new file mode 100644 index 000000000..e7faeed36 --- /dev/null +++ b/internal/plugin/runtime_subprocess.go @@ -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) + } +} diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go new file mode 100644 index 000000000..af2d0c572 --- /dev/null +++ b/internal/plugin/runtime_subprocess_getter.go @@ -0,0 +1,92 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "helm.sh/helm/v4/internal/plugin/schema" +) + +func getProtocolCommand(commands []SubprocessProtocolCommand, protocol string) *SubprocessProtocolCommand { + for _, c := range commands { + if slices.Contains(c.Protocols, protocol) { + return &c + } + } + + return nil +} + +// TODO can we replace a lot of this func with RuntimeSubprocess.invokeWithEnv? +func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { + msg, ok := (input.Message).(schema.InputMessageGetterV1) + if !ok { + return nil, fmt.Errorf("expected input type schema.InputMessageGetterV1, got %T", input) + } + + tmpDir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("helm-plugin-%s-", r.metadata.Name)) + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + d := getProtocolCommand(r.RuntimeConfig.ProtocolCommands, msg.Protocol) + if d == nil { + return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol) + } + + commands := strings.Split(d.Command, " ") + args := append( + commands[1:], + msg.Options.CertFile, + msg.Options.KeyFile, + msg.Options.CAFile, + msg.Href) + + // TODO should we append to input.Env too? + env := append( + os.Environ(), + fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", msg.Options.Username), + fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", msg.Options.Password), + fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", msg.Options.PassCredentialsAll)) + + // TODO should we pass along input.Stdout? + buf := bytes.Buffer{} // subprocess getters are expected to write content to stdout + + pluginCommand := filepath.Join(r.pluginDir, commands[0]) + prog := exec.Command( + pluginCommand, + args...) + prog.Env = env + prog.Stdout = &buf + prog.Stderr = os.Stderr + if err := executeCmd(prog, r.metadata.Name); err != nil { + return nil, err + } + + return &Output{ + Message: schema.OutputMessageGetterV1{ + Data: buf.Bytes(), + }, + }, nil +} diff --git a/pkg/plugin/hooks.go b/internal/plugin/runtime_subprocess_hooks.go similarity index 94% rename from pkg/plugin/hooks.go rename to internal/plugin/runtime_subprocess_hooks.go index 10dc8580e..7b4ff5a38 100644 --- a/pkg/plugin/hooks.go +++ b/internal/plugin/runtime_subprocess_hooks.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugin // import "helm.sh/helm/v4/pkg/plugin" +package plugin // import "helm.sh/helm/v4/internal/plugin" // Types of hooks const ( diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go new file mode 100644 index 000000000..9d932816d --- /dev/null +++ b/internal/plugin/runtime_subprocess_test.go @@ -0,0 +1,64 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v4/pkg/cli" +) + +func TestSetupEnv(t *testing.T) { + name := "pequod" + base := filepath.Join("testdata/helmhome/helm/plugins", name) + + s := cli.New() + s.PluginsDirectory = "testdata/helmhome/helm/plugins" + + SetupPluginEnv(s, name, base) + for _, tt := range []struct { + name, expect string + }{ + {"HELM_PLUGIN_NAME", name}, + {"HELM_PLUGIN_DIR", base}, + } { + if got := os.Getenv(tt.name); got != tt.expect { + t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) + } + } +} + +func TestSetupEnvWithSpace(t *testing.T) { + name := "sureshdsk" + base := filepath.Join("testdata/helm home/helm/plugins", name) + + s := cli.New() + s.PluginsDirectory = "testdata/helm home/helm/plugins" + + SetupPluginEnv(s, name, base) + for _, tt := range []struct { + name, expect string + }{ + {"HELM_PLUGIN_NAME", name}, + {"HELM_PLUGIN_DIR", base}, + } { + if got := os.Getenv(tt.name); got != tt.expect { + t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) + } + } +} diff --git a/internal/plugin/runtime_test.go b/internal/plugin/runtime_test.go new file mode 100644 index 000000000..8b72648b2 --- /dev/null +++ b/internal/plugin/runtime_test.go @@ -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) + }) + } +} diff --git a/internal/plugin/schema/cli.go b/internal/plugin/schema/cli.go new file mode 100644 index 000000000..3976d3737 --- /dev/null +++ b/internal/plugin/schema/cli.go @@ -0,0 +1,29 @@ +/* + Copyright The Helm Authors. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package schema + +import ( + "bytes" + + "helm.sh/helm/v4/pkg/cli" +) + +type InputMessageCLIV1 struct { + ExtraArgs []string `json:"extraArgs"` + Settings *cli.EnvSettings `json:"settings"` +} + +type OutputMessageCLIV1 struct { + Data *bytes.Buffer `json:"data"` +} diff --git a/internal/plugin/schema/getter.go b/internal/plugin/schema/getter.go new file mode 100644 index 000000000..f9840008e --- /dev/null +++ b/internal/plugin/schema/getter.go @@ -0,0 +1,47 @@ +/* + Copyright The Helm Authors. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package schema + +import ( + "time" +) + +// TODO: can we generate these plugin input/outputs? + +type GetterOptionsV1 struct { + URL string + CertFile string + KeyFile string + CAFile string + UNTar bool + InsecureSkipVerifyTLS bool + PlainHTTP bool + AcceptHeader string + Username string + Password string + PassCredentialsAll bool + UserAgent string + Version string + Timeout time.Duration +} + +type InputMessageGetterV1 struct { + Href string `json:"href"` + Protocol string `json:"protocol"` + Options GetterOptionsV1 `json:"options"` +} + +type OutputMessageGetterV1 struct { + Data []byte `json:"data"` +} diff --git a/pkg/postrender/postrender.go b/internal/plugin/schema/postrenderer.go similarity index 50% rename from pkg/postrender/postrender.go rename to internal/plugin/schema/postrenderer.go index 3af384290..0f0c09369 100644 --- a/pkg/postrender/postrender.go +++ b/internal/plugin/schema/postrenderer.go @@ -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"` } diff --git a/internal/plugin/schema/test.go b/internal/plugin/schema/test.go new file mode 100644 index 000000000..97efa0fde --- /dev/null +++ b/internal/plugin/schema/test.go @@ -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 +} diff --git a/internal/plugin/sign.go b/internal/plugin/sign.go new file mode 100644 index 000000000..6b8aafd3e --- /dev/null +++ b/internal/plugin/sign.go @@ -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 + }) +} diff --git a/internal/plugin/sign_test.go b/internal/plugin/sign_test.go new file mode 100644 index 000000000..fce2dbeb3 --- /dev/null +++ b/internal/plugin/sign_test.go @@ -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) + } +} diff --git a/internal/plugin/signing_info.go b/internal/plugin/signing_info.go new file mode 100644 index 000000000..43d01c893 --- /dev/null +++ b/internal/plugin/signing_info.go @@ -0,0 +1,178 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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 +} diff --git a/internal/plugin/subprocess_commands.go b/internal/plugin/subprocess_commands.go new file mode 100644 index 000000000..d979f98e3 --- /dev/null +++ b/internal/plugin/subprocess_commands.go @@ -0,0 +1,111 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +// PlatformCommand represents a command for a particular operating system and architecture +type PlatformCommand struct { + OperatingSystem string `yaml:"os"` + Architecture string `yaml:"arch"` + Command string `yaml:"command"` + Args []string `yaml:"args"` +} + +// Returns command and args strings based on the following rules in priority order: +// - From the PlatformCommand where OS and Arch match the current platform +// - From the PlatformCommand where OS matches the current platform and Arch is empty/unspecified +// - From the PlatformCommand where OS is empty/unspecified and Arch matches the current platform +// - From the PlatformCommand where OS and Arch are both empty/unspecified +// - Return nil, nil +func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) { + var command, args []string + found := false + foundOs := false + + eq := strings.EqualFold + for _, c := range cmds { + if eq(c.OperatingSystem, runtime.GOOS) && eq(c.Architecture, runtime.GOARCH) { + // Return early for an exact match + return strings.Split(c.Command, " "), c.Args + } + + if (len(c.OperatingSystem) > 0 && !eq(c.OperatingSystem, runtime.GOOS)) || len(c.Architecture) > 0 { + // Skip if OS is not empty and doesn't match or if arch is set as a set arch requires an OS match + continue + } + + if !foundOs && len(c.OperatingSystem) > 0 && eq(c.OperatingSystem, runtime.GOOS) { + // First OS match with empty arch, can only be overridden by a direct match + command = strings.Split(c.Command, " ") + args = c.Args + found = true + foundOs = true + } else if !found { + // First empty match, can be overridden by a direct match or an OS match + command = strings.Split(c.Command, " ") + args = c.Args + found = true + } + } + + return command, args +} + +// PrepareCommands takes a []Plugin.PlatformCommand +// and prepares the command and arguments for execution. +// +// It merges extraArgs into any arguments supplied in the plugin. It +// returns the main command and an args array. +// +// The result is suitable to pass to exec.Command. +func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string) (string, []string, error) { + cmdParts, args := getPlatformCommand(cmds) + if len(cmdParts) == 0 || cmdParts[0] == "" { + return "", nil, fmt.Errorf("no plugin command is applicable") + } + + main := os.ExpandEnv(cmdParts[0]) + baseArgs := []string{} + if len(cmdParts) > 1 { + for _, cmdPart := range cmdParts[1:] { + if expandArgs { + baseArgs = append(baseArgs, os.ExpandEnv(cmdPart)) + } else { + baseArgs = append(baseArgs, cmdPart) + } + } + } + + for _, arg := range args { + if expandArgs { + baseArgs = append(baseArgs, os.ExpandEnv(arg)) + } else { + baseArgs = append(baseArgs, arg) + } + } + + if len(extraArgs) > 0 { + baseArgs = append(baseArgs, extraArgs...) + } + + return main, baseArgs, nil +} diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go new file mode 100644 index 000000000..3cb9325ab --- /dev/null +++ b/internal/plugin/subprocess_commands_test.go @@ -0,0 +1,257 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "reflect" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrepareCommand(t *testing.T) { + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + + platformCommands := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs}, + } + + cmd, args, err := PrepareCommands(platformCommands, true, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, cmdArgs) { + t.Fatalf("Expected %v, got %v", cmdArgs, args) + } +} + +func TestPrepareCommandExtraArgs(t *testing.T) { + + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + platformCommands := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + } + + extraArgs := []string{"--debug", "--foo", "bar"} + + type testCaseExpected struct { + cmdMain string + args []string + } + + testCases := map[string]struct { + ignoreFlags bool + expected testCaseExpected + }{ + "ignoreFlags false": { + ignoreFlags: false, + expected: testCaseExpected{ + cmdMain: cmdMain, + args: []string{"-c", "echo \"test\"", "--debug", "--foo", "bar"}, + }, + }, + "ignoreFlags true": { + ignoreFlags: true, + expected: testCaseExpected{ + cmdMain: cmdMain, + args: []string{"-c", "echo \"test\""}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // extra args are expected when ignoreFlags is unset or false + testExtraArgs := extraArgs + if tc.ignoreFlags { + testExtraArgs = []string{} + } + cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, tc.expected.cmdMain, cmd, "Expected command to match") + assert.Equal(t, tc.expected.args, args, "Expected args to match") + }) + } +} + +func TestPrepareCommands(t *testing.T) { + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + + cmds := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + } + + cmd, args, err := PrepareCommands(cmds, true, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, cmdArgs) { + t.Fatalf("Expected %v, got %v", cmdArgs, args) + } +} + +func TestPrepareCommandsExtraArgs(t *testing.T) { + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + extraArgs := []string{"--debug", "--foo", "bar"} + + cmds := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + } + + expectedArgs := append(cmdArgs, extraArgs...) + + cmd, args, err := PrepareCommands(cmds, true, extraArgs) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, expectedArgs) { + t.Fatalf("Expected %v, got %v", expectedArgs, args) + } +} + +func TestPrepareCommandsNoArch(t *testing.T) { + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + + cmds := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + } + + cmd, args, err := PrepareCommands(cmds, true, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, cmdArgs) { + t.Fatalf("Expected %v, got %v", cmdArgs, args) + } +} + +func TestPrepareCommandsNoOsNoArch(t *testing.T) { + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"test\""} + + cmds := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + {OperatingSystem: "", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"test\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, + } + + cmd, args, err := PrepareCommands(cmds, true, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, cmdArgs) { + t.Fatalf("Expected %v, got %v", cmdArgs, args) + } +} + +func TestPrepareCommandsNoMatch(t *testing.T) { + cmds := []PlatformCommand{ + {OperatingSystem: "no-os", Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}}, + {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "sh", Args: []string{"-c", "echo \"test\""}}, + {OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}}, + } + + if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil { + t.Fatalf("Expected error to be returned") + } +} + +func TestPrepareCommandsNoCommands(t *testing.T) { + cmds := []PlatformCommand{} + + if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil { + t.Fatalf("Expected error to be returned") + } +} + +func TestPrepareCommandsExpand(t *testing.T) { + t.Setenv("TEST", "test") + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"${TEST}\""} + cmds := []PlatformCommand{ + {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs}, + } + + expectedArgs := []string{"-c", "echo \"test\""} + + cmd, args, err := PrepareCommands(cmds, true, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, expectedArgs) { + t.Fatalf("Expected %v, got %v", expectedArgs, args) + } +} + +func TestPrepareCommandsNoExpand(t *testing.T) { + t.Setenv("TEST", "test") + cmdMain := "sh" + cmdArgs := []string{"-c", "echo \"${TEST}\""} + cmds := []PlatformCommand{ + {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs}, + } + + cmd, args, err := PrepareCommands(cmds, false, []string{}) + if err != nil { + t.Fatal(err) + } + if cmd != cmdMain { + t.Fatalf("Expected %q, got %q", cmdMain, cmd) + } + if !reflect.DeepEqual(args, cmdArgs) { + t.Fatalf("Expected %v, got %v", cmdArgs, args) + } +} diff --git a/pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml b/internal/plugin/testdata/plugdir/bad/duplicate-entries-legacy/plugin.yaml similarity index 100% rename from pkg/plugin/testdata/plugdir/bad/duplicate-entries/plugin.yaml rename to internal/plugin/testdata/plugdir/bad/duplicate-entries-legacy/plugin.yaml diff --git a/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml new file mode 100644 index 000000000..030ae6aca --- /dev/null +++ b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml @@ -0,0 +1,16 @@ +name: "duplicate-entries" +version: "0.1.0" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "test duplicate entries" + longHelp: |- + description + ignoreFlags: true +runtimeConfig: + command: "echo hello" + hooks: + install: "echo installing..." + hooks: + install: "echo installing something different" diff --git a/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml b/internal/plugin/testdata/plugdir/good/downloader/plugin.yaml similarity index 98% rename from pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml rename to internal/plugin/testdata/plugdir/good/downloader/plugin.yaml index c0b90379b..4e85f1f79 100644 --- a/pkg/plugin/testdata/plugdir/good/downloader/plugin.yaml +++ b/internal/plugin/testdata/plugdir/good/downloader/plugin.yaml @@ -1,3 +1,4 @@ +--- name: "downloader" version: "1.2.3" usage: "usage" diff --git a/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml b/internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml similarity index 85% rename from pkg/plugin/testdata/plugdir/good/echo/plugin.yaml rename to internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml index 8baa35b6d..ef84a4d8f 100644 --- a/pkg/plugin/testdata/plugdir/good/echo/plugin.yaml +++ b/internal/plugin/testdata/plugdir/good/echo-legacy/plugin.yaml @@ -1,4 +1,5 @@ -name: "echo" +--- +name: "echo-legacy" version: "1.2.3" usage: "echo something" description: |- diff --git a/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml new file mode 100644 index 000000000..8bbef9c0f --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/echo-v1/plugin.yaml @@ -0,0 +1,15 @@ +--- +name: "echo-v1" +version: "1.2.3" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "echo something" + longHelp: |- + This is a testing fixture. + ignoreFlags: false +runtimeConfig: + command: "echo Hello" + hooks: + install: "echo Installing" diff --git a/internal/plugin/testdata/plugdir/good/getter/plugin.yaml b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml new file mode 100644 index 000000000..cfe80fbdc --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml @@ -0,0 +1,16 @@ +--- +name: "getter" +version: "1.2.3" +type: getter/v1 +apiVersion: v1 +runtime: subprocess +config: + protocols: + - "myprotocol" + - "myprotocols" +runtimeConfig: + protocolCommands: + - command: "echo getter" + protocols: + - "myprotocol" + - "myprotocols" diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.ps1 b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.ps1 similarity index 100% rename from pkg/plugin/testdata/plugdir/good/hello/hello.ps1 rename to internal/plugin/testdata/plugdir/good/hello-legacy/hello.ps1 diff --git a/pkg/plugin/testdata/plugdir/good/hello/hello.sh b/internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh similarity index 100% rename from pkg/plugin/testdata/plugdir/good/hello/hello.sh rename to internal/plugin/testdata/plugdir/good/hello-legacy/hello.sh diff --git a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml b/internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml similarity index 84% rename from pkg/plugin/testdata/plugdir/good/hello/plugin.yaml rename to internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml index 71dc88259..bf37e0626 100644 --- a/pkg/plugin/testdata/plugdir/good/hello/plugin.yaml +++ b/internal/plugin/testdata/plugdir/good/hello-legacy/plugin.yaml @@ -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..."'] diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1 b/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1 new file mode 100644 index 000000000..bee61f27d --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/hello-v1/hello.ps1 @@ -0,0 +1,3 @@ +#!/usr/bin/env pwsh + +Write-Host "Hello, world!" diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh new file mode 100755 index 000000000..dcfd58876 --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/hello-v1/hello.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo "Hello from a Helm plugin" + +echo "PARAMS" +echo $* + +$HELM_BIN ls --all + diff --git a/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml new file mode 100644 index 000000000..044a3476d --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/hello-v1/plugin.yaml @@ -0,0 +1,32 @@ +--- +name: "hello-v1" +version: "0.1.0" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + usage: hello [params]... + shortHelp: "echo hello message" + longHelp: |- + description + ignoreFlags: true +runtimeConfig: + platformCommand: + - os: linux + arch: + command: "sh" + args: ["-c", "${HELM_PLUGIN_DIR}/hello.sh"] + - os: windows + arch: + command: "pwsh" + args: ["-c", "${HELM_PLUGIN_DIR}/hello.ps1"] + platformHooks: + install: + - os: linux + arch: "" + command: "sh" + args: ["-c", 'echo "installing..."'] + - os: windows + arch: "" + command: "pwsh" + args: ["-c", 'echo "installing..."'] diff --git a/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml b/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml @@ -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" diff --git a/internal/plugin/testdata/plugdir/good/postrenderer-v1/sed-test.sh b/internal/plugin/testdata/plugdir/good/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/internal/plugin/testdata/plugdir/good/postrenderer-v1/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 diff --git a/internal/plugin/testdata/src/extismv1-test/.gitignore b/internal/plugin/testdata/src/extismv1-test/.gitignore new file mode 100644 index 000000000..ef7d91fbb --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/.gitignore @@ -0,0 +1 @@ +plugin.wasm diff --git a/internal/plugin/testdata/src/extismv1-test/Makefile b/internal/plugin/testdata/src/extismv1-test/Makefile new file mode 100644 index 000000000..24da1f371 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/Makefile @@ -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 ./... diff --git a/internal/plugin/testdata/src/extismv1-test/go.mod b/internal/plugin/testdata/src/extismv1-test/go.mod new file mode 100644 index 000000000..baed75fab --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/go.mod @@ -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 diff --git a/internal/plugin/testdata/src/extismv1-test/go.sum b/internal/plugin/testdata/src/extismv1-test/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/go.sum @@ -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= diff --git a/internal/plugin/testdata/src/extismv1-test/main.go b/internal/plugin/testdata/src/extismv1-test/main.go new file mode 100644 index 000000000..31c739a5b --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/main.go @@ -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() {} diff --git a/internal/plugin/testdata/src/extismv1-test/plugin.yaml b/internal/plugin/testdata/src/extismv1-test/plugin.yaml new file mode 100644 index 000000000..fea1e3f66 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/plugin.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +type: test/v1 +name: extismv1-test +version: 0.1.0 +runtime: extism/v1 +runtimeConfig: + fileSystem: + createTempDir: true \ No newline at end of file diff --git a/internal/plugin/verify.go b/internal/plugin/verify.go new file mode 100644 index 000000000..760a56e67 --- /dev/null +++ b/internal/plugin/verify.go @@ -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" +} diff --git a/internal/plugin/verify_test.go b/internal/plugin/verify_test.go new file mode 100644 index 000000000..9c907788f --- /dev/null +++ b/internal/plugin/verify_test.go @@ -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") + } +} diff --git a/internal/third_party/dep/fs/fs.go b/internal/third_party/dep/fs/fs.go index 717eff04d..6e2720f3b 100644 --- a/internal/third_party/dep/fs/fs.go +++ b/internal/third_party/dep/fs/fs.go @@ -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 { diff --git a/internal/third_party/dep/fs/fs_test.go b/internal/third_party/dep/fs/fs_test.go index 4c59d17fe..610771bc3 100644 --- a/internal/third_party/dep/fs/fs_test.go +++ b/internal/third_party/dep/fs/fs_test.go @@ -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) } } diff --git a/pkg/action/action.go b/pkg/action/action.go index 69bcf4da2..7b8fa3c34 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -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 +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 43cf94622..7a510ace6 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -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)) +} diff --git a/pkg/action/get_metadata.go b/pkg/action/get_metadata.go index 4cb77361a..889545ddc 100644 --- a/pkg/action/get_metadata.go +++ b/pkg/action/get_metadata.go @@ -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 } diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index d01ec84a0..458a6342c 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -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) diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index ad1de2c59..e3a2c0808 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -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) diff --git a/pkg/action/install.go b/pkg/action/install.go index d8efa5d5d..5ca499d64 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -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 } diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 1882f19e7..f567b3df4 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -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() diff --git a/pkg/action/package.go b/pkg/action/package.go index e57ce4921..6e762b507 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -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 } diff --git a/pkg/action/pull.go b/pkg/action/pull.go index b4779f8d2..c1f77e44c 100644 --- a/pkg/action/pull.go +++ b/pkg/action/pull.go @@ -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) { diff --git a/pkg/action/release_testing.go b/pkg/action/release_testing.go index b5f6fe712..009f4d793 100644 --- a/pkg/action/release_testing.go +++ b/pkg/action/release_testing.go @@ -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 diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index f529fa422..adaf22615 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -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 } } diff --git a/pkg/action/show.go b/pkg/action/show.go index a3fbcfa9e..6d6e10d24 100644 --- a/pkg/action/show.go +++ b/pkg/action/show.go @@ -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)) } } diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index b1c5d6164..67eba2338 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -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) diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 61e10b2c8..6aa87d331 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -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) } } diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index 8b148522c..f7c9e5f44 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -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") } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 0567c8de2..f7fbd490f 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -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) + } +} diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index e20955560..7e27ef594 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -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) + }) + } + +} diff --git a/pkg/action/verify.go b/pkg/action/verify.go index 68a5e2d88..ca2f4fa63 100644 --- a/pkg/action/verify.go +++ b/pkg/action/verify.go @@ -39,7 +39,7 @@ func NewVerify() *Verify { // Run executes 'helm verify'. func (v *Verify) Run(chartfile string) error { var out strings.Builder - p, err := downloader.VerifyChart(chartfile, v.Keyring) + p, err := downloader.VerifyChart(chartfile, chartfile+".prov", v.Keyring) if err != nil { return err } diff --git a/pkg/chart/v2/util/create.go b/pkg/chart/v2/util/create.go index 93699e0fd..a8ae3ab40 100644 --- a/pkg/chart/v2/util/create.go +++ b/pkg/chart/v2/util/create.go @@ -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 diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index f34144526..1a2aa1c95 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -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, diff --git a/pkg/chart/v2/util/jsonschema.go b/pkg/chart/v2/util/jsonschema.go index 820e5953a..72e133363 100644 --- a/pkg/chart/v2/util/jsonschema.go +++ b/pkg/chart/v2/util/jsonschema.go @@ -18,16 +18,58 @@ package util import ( "bytes" + "crypto/tls" "errors" "fmt" "log/slog" + "net/http" "strings" + "time" "github.com/santhosh-tekuri/jsonschema/v6" + "helm.sh/helm/v4/internal/version" + chart "helm.sh/helm/v4/pkg/chart/v2" ) +// HTTPURLLoader implements a loader for HTTP/HTTPS URLs +type HTTPURLLoader http.Client + +func (l *HTTPURLLoader) Load(urlStr string) (any, error) { + client := (*http.Client)(l) + + req, err := http.NewRequest(http.MethodGet, urlStr, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request for %s: %w", urlStr, err) + } + req.Header.Set("User-Agent", version.GetUserAgent()) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed for %s: %w", urlStr, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP request to %s returned status %d (%s)", urlStr, resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + return jsonschema.UnmarshalJSON(resp.Body) +} + +// newHTTPURLLoader creates a HTTP URL loader with proxy support. +func newHTTPURLLoader() *HTTPURLLoader { + httpLoader := HTTPURLLoader(http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{}, + }, + }) + return &httpLoader +} + // ValidateAgainstSchema checks that values does not violate the structure laid out in schema func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { var sb strings.Builder @@ -71,7 +113,15 @@ func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error } slog.Debug("unmarshalled JSON schema", "schema", schemaJSON) + // Configure compiler with loaders for different URL schemes + loader := jsonschema.SchemeURLLoader{ + "file": jsonschema.FileLoader{}, + "http": newHTTPURLLoader(), + "https": newHTTPURLLoader(), + } + compiler := jsonschema.NewCompiler() + compiler.UseLoader(loader) err = compiler.AddResource("file:///values.schema.json", schema) if err != nil { return err diff --git a/pkg/chart/v2/util/jsonschema_test.go b/pkg/chart/v2/util/jsonschema_test.go index 3279eb0db..cd95b7faf 100644 --- a/pkg/chart/v2/util/jsonschema_test.go +++ b/pkg/chart/v2/util/jsonschema_test.go @@ -17,7 +17,10 @@ limitations under the License. package util import ( + "net/http" + "net/http/httptest" "os" + "strings" "testing" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -245,3 +248,40 @@ func TestValidateAgainstSchema2020Negative(t *testing.T) { t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) } } + +func TestHTTPURLLoader_Load(t *testing.T) { + // Test successful JSON schema loading + t.Run("successful load", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"type": "object", "properties": {"name": {"type": "string"}}}`)) + })) + defer server.Close() + + loader := newHTTPURLLoader() + result, err := loader.Load(server.URL) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if result == nil { + t.Fatal("Expected result to be non-nil") + } + }) + + t.Run("HTTP error status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + loader := newHTTPURLLoader() + _, err := loader.Load(server.URL) + if err == nil { + t.Fatal("Expected error for HTTP 404") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("Expected error message to contain '404', got: %v", err) + } + }) +} diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index c5f87cf24..106d24336 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -91,6 +91,8 @@ type EnvSettings struct { QPS float32 // ColorMode controls colorized output (never, auto, always) ColorMode string + // ContentCache is the location where cached charts are stored + ContentCache string } func New() *EnvSettings { @@ -109,6 +111,7 @@ func New() *EnvSettings { RegistryConfig: envOr("HELM_REGISTRY_CONFIG", helmpath.ConfigPath("registry/config.json")), RepositoryConfig: envOr("HELM_REPOSITORY_CONFIG", helmpath.ConfigPath("repositories.yaml")), RepositoryCache: envOr("HELM_REPOSITORY_CACHE", helmpath.CachePath("repository")), + ContentCache: envOr("HELM_CONTENT_CACHE", helmpath.CachePath("content")), BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), QPS: envFloat32Or("HELM_QPS", defaultQPS), ColorMode: envColorMode(), @@ -161,6 +164,7 @@ func (s *EnvSettings) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.RegistryConfig, "registry-config", s.RegistryConfig, "path to the registry config file") fs.StringVar(&s.RepositoryConfig, "repository-config", s.RepositoryConfig, "path to the file containing repository names and URLs") fs.StringVar(&s.RepositoryCache, "repository-cache", s.RepositoryCache, "path to the directory containing cached repository indexes") + fs.StringVar(&s.ContentCache, "content-cache", s.ContentCache, "path to the directory containing cached content (e.g. charts)") fs.IntVar(&s.BurstLimit, "burst-limit", s.BurstLimit, "client-side default throttling limit") fs.Float32Var(&s.QPS, "qps", s.QPS, "queries per second used when communicating with the Kubernetes API, not including bursting") fs.StringVar(&s.ColorMode, "color", s.ColorMode, "use colored output (never, auto, always)") @@ -245,6 +249,7 @@ func (s *EnvSettings) EnvVars() map[string]string { "HELM_PLUGINS": s.PluginsDirectory, "HELM_REGISTRY_CONFIG": s.RegistryConfig, "HELM_REPOSITORY_CACHE": s.RepositoryCache, + "HELM_CONTENT_CACHE": s.ContentCache, "HELM_REPOSITORY_CONFIG": s.RepositoryConfig, "HELM_NAMESPACE": s.Namespace(), "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), diff --git a/pkg/cmd/dependency_build.go b/pkg/cmd/dependency_build.go index 16907facf..320fe12ae 100644 --- a/pkg/cmd/dependency_build.go +++ b/pkg/cmd/dependency_build.go @@ -69,6 +69,7 @@ func newDependencyBuildCmd(out io.Writer) *cobra.Command { RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, } if client.Verify { diff --git a/pkg/cmd/dependency_update.go b/pkg/cmd/dependency_update.go index 921e5ef49..b534fb48a 100644 --- a/pkg/cmd/dependency_update.go +++ b/pkg/cmd/dependency_update.go @@ -73,6 +73,7 @@ func newDependencyUpdateCmd(_ *action.Configuration, out io.Writer) *cobra.Comma RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, } if client.Verify { diff --git a/pkg/cmd/dependency_update_test.go b/pkg/cmd/dependency_update_test.go index 9646c6816..f1b39c4b7 100644 --- a/pkg/cmd/dependency_update_test.go +++ b/pkg/cmd/dependency_update_test.go @@ -45,6 +45,7 @@ func TestDependencyUpdateCmd(t *testing.T) { if err != nil { t.Fatal(err) } + contentCache := t.TempDir() ociChartName := "oci-depending-chart" c := createTestingMetadataForOCI(ociChartName, ociSrv.RegistryURL) @@ -69,7 +70,7 @@ func TestDependencyUpdateCmd(t *testing.T) { } _, out, err := executeActionCommand( - fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir()), + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache), ) if err != nil { t.Logf("Output: %s", out) @@ -112,7 +113,7 @@ func TestDependencyUpdateCmd(t *testing.T) { t.Fatal(err) } - _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir())) + _, out, err = executeActionCommand(fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache)) if err != nil { t.Logf("Output: %s", out) t.Fatal(err) @@ -133,11 +134,12 @@ func TestDependencyUpdateCmd(t *testing.T) { if err := chartutil.SaveDir(c, dir()); err != nil { t.Fatal(err) } - cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --plain-http", + cmd := fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --registry-config %s/config.json --content-cache %s --plain-http", dir(ociChartName), dir("repositories.yaml"), dir(), - dir()) + dir(), + contentCache) _, out, err = executeActionCommand(cmd) if err != nil { t.Logf("Output: %s", out) @@ -179,8 +181,9 @@ func TestDependencyUpdateCmd_DoNotDeleteOldChartsOnError(t *testing.T) { // Chart repo is down srv.Stop() + contentCache := t.TempDir() - _, output, err = executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir())) + _, output, err = executeActionCommand(fmt.Sprintf("dependency update %s --repository-config %s --repository-cache %s --content-cache %s --plain-http", dir(chartname), dir("repositories.yaml"), dir(), contentCache)) if err == nil { t.Logf("Output: %s", output) t.Fatal("Expected error, got nil") @@ -232,9 +235,11 @@ func TestDependencyUpdateCmd_WithRepoThatWasNotAdded(t *testing.T) { t.Fatal(err) } + contentCache := t.TempDir() + _, out, err := executeActionCommand( - fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s", dir(chartname), - dir("repositories.yaml"), dir()), + fmt.Sprintf("dependency update '%s' --repository-config %s --repository-cache %s --content-cache %s", dir(chartname), + dir("repositories.yaml"), dir(), contentCache), ) if err != nil { diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 74c3c8352..98881c795 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -27,14 +27,16 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "k8s.io/klog/v2" "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/kube" - "helm.sh/helm/v4/pkg/postrender" + "helm.sh/helm/v4/pkg/postrenderer" "helm.sh/helm/v4/pkg/repo" ) @@ -163,16 +165,18 @@ func (o *outputValue) Set(s string) error { return nil } -func bindPostRenderFlag(cmd *cobra.Command, varRef *postrender.PostRenderer) { - p := &postRendererOptions{varRef, "", []string{}} - cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path") +// TODO there is probably a better way to pass cobra settings than as a param +func bindPostRenderFlag(cmd *cobra.Command, varRef *postrenderer.PostRenderer, settings *cli.EnvSettings) { + p := &postRendererOptions{varRef, "", []string{}, settings} + cmd.Flags().Var(&postRendererString{p}, postRenderFlag, "the name of a postrenderer type plugin to be used for post rendering. If it exists, the plugin will be used") cmd.Flags().Var(&postRendererArgsSlice{p}, postRenderArgsFlag, "an argument to the post-renderer (can specify multiple)") } type postRendererOptions struct { - renderer *postrender.PostRenderer - binaryPath string + renderer *postrenderer.PostRenderer + pluginName string args []string + settings *cli.EnvSettings } type postRendererString struct { @@ -180,7 +184,7 @@ type postRendererString struct { } func (p *postRendererString) String() string { - return p.options.binaryPath + return p.options.pluginName } func (p *postRendererString) Type() string { @@ -191,11 +195,11 @@ func (p *postRendererString) Set(val string) error { if val == "" { return nil } - if p.options.binaryPath != "" { + if p.options.pluginName != "" { return fmt.Errorf("cannot specify --post-renderer flag more than once") } - p.options.binaryPath = val - pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) + p.options.pluginName = val + pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) if err != nil { return err } @@ -220,11 +224,11 @@ func (p *postRendererArgsSlice) Set(val string) error { // a post-renderer defined by a user may accept empty arguments p.options.args = append(p.options.args, val) - if p.options.binaryPath == "" { + if p.options.pluginName == "" { return nil } // overwrite if already create PostRenderer by `post-renderer` flags - pr, err := postrender.NewExec(p.options.binaryPath, p.options.args...) + pr, err := postrenderer.NewPostRendererPlugin(p.options.settings, p.options.pluginName, p.options.args...) if err != nil { return err } diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index cbc2e6419..dce748a6b 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -101,20 +101,22 @@ func outputFlagCompletionTest(t *testing.T, cmdName string) { func TestPostRendererFlagSetOnce(t *testing.T) { cfg := action.Configuration{} client := action.NewInstall(&cfg) + settings.PluginsDirectory = "testdata/helmhome/helm/plugins" str := postRendererString{ options: &postRendererOptions{ renderer: &client.PostRenderer, + settings: settings, }, } - // Set the binary once - err := str.Set("echo") + // Set the plugin name once + err := str.Set("postrenderer-v1") require.NoError(t, err) - // Set the binary again to the same value is not ok - err = str.Set("echo") + // Set the plugin name again to the same value is not ok + err = str.Set("postrenderer-v1") require.Error(t, err) - // Set the binary again to a different value is not ok + // Set the plugin name again to a different value is not ok err = str.Set("cat") require.Error(t, err) } diff --git a/pkg/cmd/get_metadata.go b/pkg/cmd/get_metadata.go index aea149f5e..eb90b6e44 100644 --- a/pkg/cmd/get_metadata.go +++ b/pkg/cmd/get_metadata.go @@ -27,6 +27,8 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" + + release "helm.sh/helm/v4/pkg/release/v1" ) type metadataWriter struct { @@ -75,6 +77,20 @@ func newGetMetadataCmd(cfg *action.Configuration, out io.Writer) *cobra.Command } func (w metadataWriter) WriteTable(out io.Writer) error { + + formatApplyMethod := func(applyMethod string) string { + switch applyMethod { + case "": + return "client-side apply (defaulted)" + case string(release.ApplyMethodClientSideApply): + return "client-side apply" + case string(release.ApplyMethodServerSideApply): + return "server-side apply" + default: + return fmt.Sprintf("unknown (%q)", applyMethod) + } + } + _, _ = fmt.Fprintf(out, "NAME: %v\n", w.metadata.Name) _, _ = fmt.Fprintf(out, "CHART: %v\n", w.metadata.Chart) _, _ = fmt.Fprintf(out, "VERSION: %v\n", w.metadata.Version) @@ -86,6 +102,7 @@ func (w metadataWriter) WriteTable(out io.Writer) error { _, _ = fmt.Fprintf(out, "REVISION: %v\n", w.metadata.Revision) _, _ = fmt.Fprintf(out, "STATUS: %v\n", w.metadata.Status) _, _ = fmt.Fprintf(out, "DEPLOYED_AT: %v\n", w.metadata.DeployedAt) + _, _ = fmt.Fprintf(out, "APPLY_METHOD: %v\n", formatApplyMethod(w.metadata.ApplyMethod)) return nil } diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 8c06db4ae..40478c30e 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -104,6 +104,10 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) root.SetArgs(args) oldStdin := os.Stdin + defer func() { + os.Stdin = oldStdin + }() + if in != nil { root.SetIn(in) os.Stdin = in @@ -116,8 +120,6 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) result := buf.String() - os.Stdin = oldStdin - return c, result, err } diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index d53b1d981..c4e121c1f 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -179,7 +179,7 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() f.BoolVar(&client.HideSecret, "hide-secret", false, "hide Kubernetes Secrets when also using the --dry-run flag") bindOutputFlag(cmd, &outfmt) - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) return cmd } @@ -196,6 +196,8 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.BoolVar(&client.ServerSideApply, "server-side", true, "object updates run in the server instead of the client") f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during install") f.BoolVar(&client.Replace, "replace", false, "reuse the given name, only if that name is a deleted release which remains in the history. This is unsafe in production") f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") @@ -206,7 +208,8 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal f.BoolVar(&client.Devel, "devel", false, "use development versions, too. Equivalent to version '>0.0.0-0'. If --version is set, this is ignored") f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the installation process will not validate rendered templates against the Kubernetes OpenAPI Schema") - f.BoolVar(&client.Atomic, "atomic", false, "if set, the installation process deletes the installation on failure. The --wait flag will be set automatically to \"watcher\" if --atomic is used") + f.BoolVar(&client.RollbackOnFailure, "rollback-on-failure", false, "if set, Helm will rollback (uninstall) the installation upon failure. The --wait flag will be default to \"watcher\" if --rollback-on-failure is set") + f.MarkDeprecated("atomic", "use --rollback-on-failure instead") f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed. By default, CRDs are installed if not already present") f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent") f.BoolVar(&client.SkipSchemaValidation, "skip-schema-validation", false, "if set, disables JSON schema validation") @@ -217,6 +220,8 @@ func addInstallFlags(cmd *cobra.Command, f *pflag.FlagSet, client *action.Instal addValueOptionsFlags(f, valueOpts) addChartPathOptionsFlags(f, &client.ChartPathOptions) AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { requiredArgs := 2 @@ -287,6 +292,7 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options Getters: p, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, RegistryClient: client.GetRegistryClient(), } diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index 5c7f618eb..5057c1033 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -17,23 +17,30 @@ package cmd import ( "bytes" + "context" "fmt" "io" "log" "os" - "os/exec" "path/filepath" "slices" "strconv" "strings" - "syscall" + + "helm.sh/helm/v4/internal/plugin/schema" "github.com/spf13/cobra" "sigs.k8s.io/yaml" - "helm.sh/helm/v4/pkg/plugin" + "helm.sh/helm/v4/internal/plugin" ) +// TODO: move pluginDynamicCompletionExecutable pkg/plugin/runtime_subprocess.go +// any references to executables should be for [plugin.SubprocessPluginRuntime] only +// this should also be for backwards compatibility in [plugin.Legacy] only +// +// TODO: for v1 make this configurable with a new CompletionCommand field for +// [plugin.RuntimeConfigSubprocess] const ( pluginStaticCompletionFile = "completion.yaml" pluginDynamicCompletionExecutable = "plugin.complete" @@ -44,18 +51,22 @@ type PluginError struct { Code int } -// loadPlugins loads plugins into the command list. +// loadCLIPlugins loads CLI plugins into the command list. // // This follows a different pattern than the other commands because it has // to inspect its environment and then add commands to the base command // as it finds them. -func loadPlugins(baseCmd *cobra.Command, out io.Writer) { +func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { // If HELM_NO_PLUGINS is set to 1, do not load plugins. if os.Getenv("HELM_NO_PLUGINS") == "1" { return } - found, err := plugin.FindPlugins(settings.PluginsDirectory) + dirs := filepath.SplitList(settings.PluginsDirectory) + descriptor := plugin.Descriptor{ + Type: "cli/v1", + } + found, err := plugin.FindPlugins(dirs, descriptor) if err != nil { fmt.Fprintf(os.Stderr, "failed to load plugins: %s\n", err) return @@ -63,33 +74,69 @@ func loadPlugins(baseCmd *cobra.Command, out io.Writer) { // Now we create commands for all of these. for _, plug := range found { - plug := plug - md := plug.Metadata - if md.Usage == "" { - md.Usage = fmt.Sprintf("the %q plugin", md.Name) + var use, short, long string + var ignoreFlags bool + if cliConfig, ok := plug.Metadata().Config.(*plugin.ConfigCLI); ok { + use = cliConfig.Usage + short = cliConfig.ShortHelp + long = cliConfig.LongHelp + ignoreFlags = cliConfig.IgnoreFlags + } + + // Set defaults + if use == "" { + use = plug.Metadata().Name + } + if short == "" { + short = fmt.Sprintf("the %q plugin", plug.Metadata().Name) } + // long has no default, empty is ok c := &cobra.Command{ - Use: md.Name, - Short: md.Usage, - Long: md.Description, + Use: use, + Short: short, + Long: long, RunE: func(cmd *cobra.Command, args []string) error { u, err := processParent(cmd, args) if err != nil { return err } + // Setup plugin environment + plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir()) + + // For CLI plugin types runtime, set extra args and settings + extraArgs := []string{} + if !ignoreFlags { + extraArgs = u + } - // Call setupEnv before PrepareCommand because - // PrepareCommand uses os.ExpandEnv and expects the - // setupEnv vars. - plugin.SetupPluginEnv(settings, md.Name, plug.Dir) - main, argv, prepCmdErr := plug.PrepareCommand(u) - if prepCmdErr != nil { - os.Stderr.WriteString(prepCmdErr.Error()) - return fmt.Errorf("plugin %q exited with error", md.Name) + // Prepare environment + env := os.Environ() + for k, v := range settings.EnvVars() { + env = append(env, fmt.Sprintf("%s=%s", k, v)) } - return callPluginExecutable(md.Name, main, argv, out) + // Invoke plugin + input := &plugin.Input{ + Message: schema.InputMessageCLIV1{ + ExtraArgs: extraArgs, + Settings: settings, + }, + Env: env, + Stdin: os.Stdin, + Stdout: out, + Stderr: os.Stderr, + } + _, err = plug.Invoke(context.Background(), input) + // TODO do we want to keep execErr here? + if execErr, ok := err.(*plugin.InvokeExecError); ok { + // TODO can we replace cmd.PluginError with plugin.Error? + return PluginError{ + error: execErr.Err, + Code: execErr.Code, + } + } + return err }, // This passes all the flags to the subcommand. DisableFlagParsing: true, @@ -119,34 +166,6 @@ func processParent(cmd *cobra.Command, args []string) ([]string, error) { return u, nil } -// This function is used to setup the environment for the plugin and then -// call the executable specified by the parameter 'main' -func callPluginExecutable(pluginName string, main string, argv []string, out io.Writer) error { - env := os.Environ() - for k, v := range settings.EnvVars() { - env = append(env, fmt.Sprintf("%s=%s", k, v)) - } - - mainCmdExp := os.ExpandEnv(main) - prog := exec.Command(mainCmdExp, argv...) - prog.Env = env - prog.Stdin = os.Stdin - prog.Stdout = out - prog.Stderr = os.Stderr - if err := prog.Run(); err != nil { - if eerr, ok := err.(*exec.ExitError); ok { - os.Stderr.Write(eerr.Stderr) - status := eerr.Sys().(syscall.WaitStatus) - return PluginError{ - error: fmt.Errorf("plugin %q exited with error", pluginName), - Code: status.ExitStatus(), - } - } - return err - } - return nil -} - // manuallyProcessArgs processes an arg array, removing special args. // // Returns two sets of args: known and unknown (in that order) @@ -201,10 +220,10 @@ type pluginCommand struct { // loadCompletionForPlugin will load and parse any completion.yaml provided by the plugin // and add the dynamic completion hook to call the optional plugin.complete -func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) { +func loadCompletionForPlugin(pluginCmd *cobra.Command, plug plugin.Plugin) { // Parse the yaml file providing the plugin's sub-commands and flags cmds, err := loadFile(strings.Join( - []string{plugin.Dir, pluginStaticCompletionFile}, string(filepath.Separator))) + []string{plug.Dir(), pluginStaticCompletionFile}, string(filepath.Separator))) if err != nil { // The file could be missing or invalid. No static completion for this plugin. @@ -218,12 +237,12 @@ func loadCompletionForPlugin(pluginCmd *cobra.Command, plugin *plugin.Plugin) { // Preserve the Usage string specified for the plugin cmds.Name = pluginCmd.Use - addPluginCommands(plugin, pluginCmd, cmds) + addPluginCommands(plug, pluginCmd, cmds) } // addPluginCommands is a recursive method that adds each different level // of sub-commands and flags for the plugins that have provided such information -func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) { +func addPluginCommands(plug plugin.Plugin, baseCmd *cobra.Command, cmds *pluginCommand) { if cmds == nil { return } @@ -246,7 +265,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug // calling plugin.complete at every completion, which greatly simplifies // development of plugin.complete for plugin developers. baseCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return pluginDynamicComp(plugin, cmd, args, toComplete) + return pluginDynamicComp(plug, cmd, args, toComplete) } } @@ -301,7 +320,7 @@ func addPluginCommands(plugin *plugin.Plugin, baseCmd *cobra.Command, cmds *plug Run: func(_ *cobra.Command, _ []string) {}, } baseCmd.AddCommand(subCmd) - addPluginCommands(plugin, subCmd, &cmd) + addPluginCommands(plug, subCmd, &cmd) } } @@ -320,8 +339,19 @@ func loadFile(path string) (*pluginCommand, error) { // pluginDynamicComp call the plugin.complete script of the plugin (if available) // to obtain the dynamic completion choices. It must pass all the flags and sub-commands // specified in the command-line to the plugin.complete executable (except helm's global flags) -func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - md := plug.Metadata +func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + + subprocessPlug, ok := plug.(*plugin.SubprocessPluginRuntime) + if !ok { + // Completion only supported for subprocess plugins (TODO: fix this) + cobra.CompDebugln(fmt.Sprintf("Unsupported plugin runtime: %q", plug.Metadata().Runtime), settings.Debug) + return nil, cobra.ShellCompDirectiveDefault + } + + var ignoreFlags bool + if cliConfig, ok := subprocessPlug.Metadata().Config.(*plugin.ConfigCLI); ok { + ignoreFlags = cliConfig.IgnoreFlags + } u, err := processParent(cmd, args) if err != nil { @@ -329,21 +359,29 @@ func pluginDynamicComp(plug *plugin.Plugin, cmd *cobra.Command, args []string, t } // We will call the dynamic completion script of the plugin - main := strings.Join([]string{plug.Dir, pluginDynamicCompletionExecutable}, string(filepath.Separator)) + main := strings.Join([]string{plug.Dir(), pluginDynamicCompletionExecutable}, string(filepath.Separator)) // We must include all sub-commands passed on the command-line. // To do that, we pass-in the entire CommandPath, except the first two elements // which are 'helm' and 'pluginName'. argv := strings.Split(cmd.CommandPath(), " ")[2:] - if !md.IgnoreFlags { + if !ignoreFlags { argv = append(argv, u...) argv = append(argv, toComplete) } - plugin.SetupPluginEnv(settings, md.Name, plug.Dir) + plugin.SetupPluginEnv(settings, plug.Metadata().Name, plug.Dir()) cobra.CompDebugln(fmt.Sprintf("calling %s with args %v", main, argv), settings.Debug) buf := new(bytes.Buffer) - if err := callPluginExecutable(md.Name, main, argv, buf); err != nil { + + // Prepare environment + env := os.Environ() + for k, v := range settings.EnvVars() { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + + // For subprocess runtime, use InvokeWithEnv for dynamic completion + if err := subprocessPlug.InvokeWithEnv(main, argv, env, nil, buf, buf); err != nil { // The dynamic completion file is optional for a plugin, so this error is ok. cobra.CompDebugln(fmt.Sprintf("Unable to call %s: %v", main, err.Error()), settings.Debug) return nil, cobra.ShellCompDirectiveDefault diff --git a/pkg/cmd/package.go b/pkg/cmd/package.go index 40c503222..fc56e936a 100644 --- a/pkg/cmd/package.go +++ b/pkg/cmd/package.go @@ -100,6 +100,7 @@ func newPackageCmd(out io.Writer) *cobra.Command { RegistryClient: registryClient, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, } if err := downloadManager.Update(); err != nil { diff --git a/pkg/cmd/plugin.go b/pkg/cmd/plugin.go index a2bb838df..393e9672c 100644 --- a/pkg/cmd/plugin.go +++ b/pkg/cmd/plugin.go @@ -16,15 +16,11 @@ limitations under the License. package cmd import ( - "fmt" "io" - "log/slog" - "os" - "os/exec" "github.com/spf13/cobra" - "helm.sh/helm/v4/pkg/plugin" + "helm.sh/helm/v4/internal/plugin" ) const pluginHelp = ` @@ -42,40 +38,19 @@ func newPluginCmd(out io.Writer) *cobra.Command { newPluginListCmd(out), newPluginUninstallCmd(out), newPluginUpdateCmd(out), + newPluginPackageCmd(out), + newPluginVerifyCmd(out), ) return cmd } // runHook will execute a plugin hook. -func runHook(p *plugin.Plugin, event string) error { - plugin.SetupPluginEnv(settings, p.Metadata.Name, p.Dir) - - cmds := p.Metadata.PlatformHooks[event] - expandArgs := true - if len(cmds) == 0 && len(p.Metadata.Hooks) > 0 { - cmd := p.Metadata.Hooks[event] - if len(cmd) > 0 { - cmds = []plugin.PlatformCommand{{Command: "sh", Args: []string{"-c", cmd}}} - expandArgs = false - } - } - - main, argv, err := plugin.PrepareCommands(cmds, expandArgs, []string{}) - if err != nil { - return nil +func runHook(p plugin.Plugin, event string) error { + pluginHook, ok := p.(plugin.PluginHook) + if ok { + plugin.SetupPluginEnv(settings, p.Metadata().Name, p.Dir()) + return pluginHook.InvokeHook(event) } - prog := exec.Command(main, argv...) - - slog.Debug("running hook", "event", event, "program", prog) - - 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, p.Metadata.Name) - } - return err - } return nil } diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index 945bf8ee0..0abefa76b 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -19,21 +19,42 @@ import ( "fmt" "io" "log/slog" + "strings" "github.com/spf13/cobra" + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/plugin/installer" "helm.sh/helm/v4/pkg/cmd/require" - "helm.sh/helm/v4/pkg/plugin" - "helm.sh/helm/v4/pkg/plugin/installer" + "helm.sh/helm/v4/pkg/getter" + "helm.sh/helm/v4/pkg/registry" ) type pluginInstallOptions struct { source string version string + // signing options + verify bool + keyring string + // OCI-specific options + certFile string + keyFile string + caFile string + insecureSkipTLSverify bool + plainHTTP bool + password string + username string } const pluginInstallDesc = ` This command allows you to install a plugin from a url to a VCS repo or a local path. + +By default, plugin signatures are verified before installation when installing from +tarballs (.tgz or .tar.gz). This requires a corresponding .prov file to be available +alongside the tarball. +For local development, plugins installed from local directories are automatically +treated as "local dev" and do not require signatures. +Use --verify=false to skip signature verification for remote plugins. ` func newPluginInstallCmd(out io.Writer) *cobra.Command { @@ -60,6 +81,17 @@ func newPluginInstallCmd(out io.Writer) *cobra.Command { }, } cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed") + cmd.Flags().BoolVar(&o.verify, "verify", true, "verify the plugin signature before installing") + cmd.Flags().StringVar(&o.keyring, "keyring", defaultKeyring(), "location of public keys used for verification") + + // Add OCI-specific flags + cmd.Flags().StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file") + cmd.Flags().StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file") + cmd.Flags().StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + cmd.Flags().BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the plugin download") + cmd.Flags().BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the plugin download") + cmd.Flags().StringVar(&o.username, "username", "", "registry username") + cmd.Flags().StringVar(&o.password, "password", "", "registry password") return cmd } @@ -68,17 +100,76 @@ func (o *pluginInstallOptions) complete(args []string) error { return nil } +func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, error) { + // Check if source is an OCI registry reference + if strings.HasPrefix(o.source, fmt.Sprintf("%s://", registry.OCIScheme)) { + // Build getter options for OCI + options := []getter.Option{ + getter.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), + getter.WithInsecureSkipVerifyTLS(o.insecureSkipTLSverify), + getter.WithPlainHTTP(o.plainHTTP), + getter.WithBasicAuth(o.username, o.password), + } + + return installer.NewOCIInstaller(o.source, options...) + } + + // For non-OCI sources, use the original logic + return installer.NewForSource(o.source, o.version) +} + func (o *pluginInstallOptions) run(out io.Writer) error { installer.Debug = settings.Debug - i, err := installer.NewForSource(o.source, o.version) + i, err := o.newInstallerForSource() if err != nil { return err } - if err := installer.Install(i); err != nil { + + // Determine if we should verify based on installer type and flags + shouldVerify := o.verify + + // Check if this is a local directory installation (for development) + if localInst, ok := i.(*installer.LocalInstaller); ok && !localInst.SupportsVerification() { + // Local directory installations are allowed without verification + shouldVerify = false + fmt.Fprintf(out, "Installing plugin from local directory (development mode)\n") + } else if shouldVerify { + // For remote installations, check if verification is supported + if verifier, ok := i.(installer.Verifier); !ok || !verifier.SupportsVerification() { + return fmt.Errorf("plugin source does not support verification. Use --verify=false to skip verification") + } + } else { + // User explicitly disabled verification + fmt.Fprintf(out, "WARNING: Skipping plugin signature verification\n") + } + + // Set up installation options + opts := installer.Options{ + Verify: shouldVerify, + Keyring: o.keyring, + } + + // If verify is requested, show verification output + if shouldVerify { + fmt.Fprintf(out, "Verifying plugin signature...\n") + } + + // Install the plugin with options + verifyResult, err := installer.InstallWithOptions(i, opts) + if err != nil { return err } + // If verification was successful, show the details + if verifyResult != nil { + for _, signer := range verifyResult.SignedBy { + fmt.Fprintf(out, "Signed by: %s\n", signer) + } + fmt.Fprintf(out, "Using Key With Fingerprint: %s\n", verifyResult.Fingerprint) + fmt.Fprintf(out, "Plugin Hash Verified: %s\n", verifyResult.FileHash) + } + slog.Debug("loading plugin", "path", i.Path()) p, err := plugin.LoadDir(i.Path()) if err != nil { @@ -89,6 +180,6 @@ func (o *pluginInstallOptions) run(out io.Writer) error { return err } - fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata.Name) + fmt.Fprintf(out, "Installed plugin: %s\n", p.Metadata().Name) return nil } diff --git a/pkg/cmd/plugin_list.go b/pkg/cmd/plugin_list.go index 5bb9ff68d..9b2895441 100644 --- a/pkg/cmd/plugin_list.go +++ b/pkg/cmd/plugin_list.go @@ -19,15 +19,17 @@ import ( "fmt" "io" "log/slog" + "path/filepath" "slices" "github.com/gosuri/uitable" "github.com/spf13/cobra" - "helm.sh/helm/v4/pkg/plugin" + "helm.sh/helm/v4/internal/plugin" ) func newPluginListCmd(out io.Writer) *cobra.Command { + var pluginType string cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -35,33 +37,54 @@ func newPluginListCmd(out io.Writer) *cobra.Command { ValidArgsFunction: noMoreArgsCompFunc, RunE: func(_ *cobra.Command, _ []string) error { slog.Debug("pluginDirs", "directory", settings.PluginsDirectory) - plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + dirs := filepath.SplitList(settings.PluginsDirectory) + descriptor := plugin.Descriptor{ + Type: pluginType, + } + plugins, err := plugin.FindPlugins(dirs, descriptor) if err != nil { return err } + // Get signing info for all plugins + signingInfo := plugin.GetSigningInfoForPlugins(plugins) + table := uitable.New() - table.AddRow("NAME", "VERSION", "DESCRIPTION") + table.AddRow("NAME", "VERSION", "TYPE", "APIVERSION", "PROVENANCE", "SOURCE") for _, p := range plugins { - table.AddRow(p.Metadata.Name, p.Metadata.Version, p.Metadata.Description) + m := p.Metadata() + sourceURL := m.SourceURL + if sourceURL == "" { + sourceURL = "unknown" + } + // Get signing status + signedStatus := "unknown" + if info, ok := signingInfo[m.Name]; ok { + signedStatus = info.Status + } + table.AddRow(m.Name, m.Version, m.Type, m.APIVersion, signedStatus, sourceURL) } fmt.Fprintln(out, table) return nil }, } + + f := cmd.Flags() + f.StringVar(&pluginType, "type", "", "Plugin type") + return cmd } // Returns all plugins from plugins, except those with names matching ignoredPluginNames -func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plugin.Plugin { - // if ignoredPluginNames is nil, just return plugins - if ignoredPluginNames == nil { +func filterPlugins(plugins []plugin.Plugin, ignoredPluginNames []string) []plugin.Plugin { + // if ignoredPluginNames is nil or empty, just return plugins + if len(ignoredPluginNames) == 0 { return plugins } - var filteredPlugins []*plugin.Plugin + var filteredPlugins []plugin.Plugin for _, plugin := range plugins { - found := slices.Contains(ignoredPluginNames, plugin.Metadata.Name) + found := slices.Contains(ignoredPluginNames, plugin.Metadata().Name) if !found { filteredPlugins = append(filteredPlugins, plugin) } @@ -73,11 +96,20 @@ func filterPlugins(plugins []*plugin.Plugin, ignoredPluginNames []string) []*plu // Provide dynamic auto-completion for plugin names func compListPlugins(_ string, ignoredPluginNames []string) []string { var pNames []string - plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + dirs := filepath.SplitList(settings.PluginsDirectory) + descriptor := plugin.Descriptor{ + Type: "cli/v1", + } + plugins, err := plugin.FindPlugins(dirs, descriptor) if err == nil && len(plugins) > 0 { filteredPlugins := filterPlugins(plugins, ignoredPluginNames) for _, p := range filteredPlugins { - pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata.Name, p.Metadata.Usage)) + m := p.Metadata() + var shortHelp string + if config, ok := m.Config.(*plugin.ConfigCLI); ok { + shortHelp = config.ShortHelp + } + pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata().Name, shortHelp)) } } return pNames diff --git a/pkg/cmd/plugin_package.go b/pkg/cmd/plugin_package.go new file mode 100644 index 000000000..05f8bb5ad --- /dev/null +++ b/pkg/cmd/plugin_package.go @@ -0,0 +1,216 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/pkg/cmd/require" + "helm.sh/helm/v4/pkg/provenance" +) + +const pluginPackageDesc = ` +This command packages a Helm plugin directory into a tarball. + +By default, the command will generate a provenance file signed with a PGP key. +This ensures the plugin can be verified after installation. + +Use --sign=false to skip signing (not recommended for distribution). +` + +type pluginPackageOptions struct { + sign bool + keyring string + key string + passphraseFile string + pluginPath string + destination string +} + +func newPluginPackageCmd(out io.Writer) *cobra.Command { + o := &pluginPackageOptions{} + + cmd := &cobra.Command{ + Use: "package [PATH]", + Short: "package a plugin directory into a plugin archive", + Long: pluginPackageDesc, + Args: require.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + o.pluginPath = args[0] + return o.run(out) + }, + } + + f := cmd.Flags() + f.BoolVar(&o.sign, "sign", true, "use a PGP private key to sign this plugin") + f.StringVar(&o.key, "key", "", "name of the key to use when signing. Used if --sign is true") + f.StringVar(&o.keyring, "keyring", defaultKeyring(), "location of a public keyring") + f.StringVar(&o.passphraseFile, "passphrase-file", "", "location of a file which contains the passphrase for the signing key. Use \"-\" to read from stdin.") + f.StringVarP(&o.destination, "destination", "d", ".", "location to write the plugin tarball.") + + return cmd +} + +func (o *pluginPackageOptions) run(out io.Writer) error { + // Check if the plugin path exists and is a directory + fi, err := os.Stat(o.pluginPath) + if err != nil { + return err + } + if !fi.IsDir() { + return fmt.Errorf("plugin package only supports directories, not tarballs") + } + + // Load and validate plugin metadata + pluginMeta, err := plugin.LoadDir(o.pluginPath) + if err != nil { + return fmt.Errorf("invalid plugin directory: %w", err) + } + + // Create destination directory if needed + if err := os.MkdirAll(o.destination, 0755); err != nil { + return err + } + + // If signing is requested, prepare the signer first + var signer *provenance.Signatory + if o.sign { + // Load the signing key + signer, err = provenance.NewFromKeyring(o.keyring, o.key) + if err != nil { + return fmt.Errorf("error reading from keyring: %w", err) + } + + // Get passphrase + passphraseFetcher := o.promptUser + if o.passphraseFile != "" { + passphraseFetcher, err = o.passphraseFileFetcher() + if err != nil { + return err + } + } + + // Decrypt the key + if err := signer.DecryptKey(passphraseFetcher); err != nil { + return err + } + } else { + // User explicitly disabled signing + fmt.Fprintf(out, "WARNING: Skipping plugin signing. This is not recommended for plugins intended for distribution.\n") + } + + // Now create the tarball (only after signing prerequisites are met) + // Use plugin metadata for filename: PLUGIN_NAME-SEMVER.tgz + metadata := pluginMeta.Metadata() + filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version) + tarballPath := filepath.Join(o.destination, filename) + + tarFile, err := os.Create(tarballPath) + if err != nil { + return fmt.Errorf("failed to create tarball: %w", err) + } + defer tarFile.Close() + + if err := plugin.CreatePluginTarball(o.pluginPath, metadata.Name, tarFile); err != nil { + os.Remove(tarballPath) + return fmt.Errorf("failed to create plugin tarball: %w", err) + } + tarFile.Close() // Ensure file is closed before signing + + // If signing was requested, sign the tarball + if o.sign { + // Read the tarball data + tarballData, err := os.ReadFile(tarballPath) + if err != nil { + os.Remove(tarballPath) + return fmt.Errorf("failed to read tarball for signing: %w", err) + } + + // Sign the plugin tarball data + sig, err := plugin.SignPlugin(tarballData, filepath.Base(tarballPath), signer) + if err != nil { + os.Remove(tarballPath) + return fmt.Errorf("failed to sign plugin: %w", err) + } + + // Write the signature + provFile := tarballPath + ".prov" + if err := os.WriteFile(provFile, []byte(sig), 0644); err != nil { + os.Remove(tarballPath) + return err + } + + fmt.Fprintf(out, "Successfully signed. Signature written to: %s\n", provFile) + } + + fmt.Fprintf(out, "Successfully packaged plugin and saved it to: %s\n", tarballPath) + + return nil +} + +func (o *pluginPackageOptions) promptUser(name string) ([]byte, error) { + fmt.Printf("Password for key %q > ", name) + pw, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + return pw, err +} + +func (o *pluginPackageOptions) passphraseFileFetcher() (provenance.PassphraseFetcher, error) { + file, err := openPassphraseFile(o.passphraseFile, os.Stdin) + if err != nil { + return nil, err + } + defer file.Close() + + // Read the entire passphrase + passphrase, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + // Trim any trailing newline characters (both \n and \r\n) + passphrase = bytes.TrimRight(passphrase, "\r\n") + + return func(_ string) ([]byte, error) { + return passphrase, nil + }, nil +} + +// copied from action.openPassphraseFile +// TODO: should we move this to pkg/action so we can reuse the func from there? +func openPassphraseFile(passphraseFile string, stdin *os.File) (*os.File, error) { + if passphraseFile == "-" { + stat, err := stdin.Stat() + if err != nil { + return nil, err + } + if (stat.Mode() & os.ModeNamedPipe) == 0 { + return nil, errors.New("specified reading passphrase from stdin, without input on stdin") + } + return stdin, nil + } + return os.Open(passphraseFile) +} diff --git a/pkg/cmd/plugin_package_test.go b/pkg/cmd/plugin_package_test.go new file mode 100644 index 000000000..df6cdd849 --- /dev/null +++ b/pkg/cmd/plugin_package_test.go @@ -0,0 +1,170 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// Common plugin.yaml content for v1 format tests +const testPluginYAML = `apiVersion: v1 +name: test-plugin +version: 1.0.0 +type: cli/v1 +runtime: subprocess +config: + usage: test-plugin [flags] + shortHelp: A test plugin + longHelp: A test plugin for testing purposes +runtimeConfig: + platformCommands: + - os: linux + command: echo + args: ["test"]` + +func TestPluginPackageWithoutSigning(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 + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create package options with sign=false + o := &pluginPackageOptions{ + sign: false, // Explicitly disable signing + pluginPath: pluginDir, + destination: tempDir, + } + + // Run the package command + out := &bytes.Buffer{} + err := o.run(out) + + // Should succeed without error + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // Check that tarball was created with plugin name and version + tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tgz") + if _, err := os.Stat(tarballPath); os.IsNotExist(err) { + t.Error("tarball should exist when sign=false") + } + + // Check that no .prov file was created + provPath := tarballPath + ".prov" + if _, err := os.Stat(provPath); !os.IsNotExist(err) { + t.Error("provenance file should not exist when sign=false") + } + + // Output should contain warning about skipping signing + output := out.String() + if !strings.Contains(output, "WARNING: Skipping plugin signing") { + t.Error("should print warning when signing is skipped") + } + if !strings.Contains(output, "Successfully packaged") { + t.Error("should print success message") + } +} + +func TestPluginPackageDefaultRequiresSigning(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 + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create package options with default sign=true and invalid keyring + o := &pluginPackageOptions{ + sign: true, // This is now the default + keyring: "/non/existent/keyring", + pluginPath: pluginDir, + destination: tempDir, + } + + // Run the package command + out := &bytes.Buffer{} + err := o.run(out) + + // Should fail because signing is required by default + if err == nil { + t.Error("expected error when signing fails with default settings") + } + + // Check that no tarball was created + tarballPath := filepath.Join(tempDir, "test-plugin.tgz") + if _, err := os.Stat(tarballPath); !os.IsNotExist(err) { + t.Error("tarball should not exist when signing fails") + } +} + +func TestPluginPackageSigningFailure(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 + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create package options with sign flag but invalid keyring + o := &pluginPackageOptions{ + sign: true, + keyring: "/non/existent/keyring", // This will cause signing to fail + pluginPath: pluginDir, + destination: tempDir, + } + + // Run the package command + out := &bytes.Buffer{} + err := o.run(out) + + // Should get an error + if err == nil { + t.Error("expected error when signing fails, got nil") + } + + // Check that no tarball was created + tarballPath := filepath.Join(tempDir, "test-plugin.tgz") + if _, err := os.Stat(tarballPath); !os.IsNotExist(err) { + t.Error("tarball should not exist when signing fails") + } + + // Output should not contain success message + if bytes.Contains(out.Bytes(), []byte("Successfully packaged")) { + t.Error("should not print success message when signing fails") + } +} diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index 74f7a276a..b476b80d2 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -19,12 +19,13 @@ import ( "bytes" "os" "runtime" - "sort" "strings" "testing" "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -81,7 +82,7 @@ func TestManuallyProcessArgs(t *testing.T) { } } -func TestLoadPlugins(t *testing.T) { +func TestLoadCLIPlugins(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.RepositoryConfig = "testdata/helmhome/helm/repositories.yaml" settings.RepositoryCache = "testdata/helmhome/helm/repository" @@ -90,7 +91,7 @@ func TestLoadPlugins(t *testing.T) { out bytes.Buffer cmd cobra.Command ) - loadPlugins(&cmd, &out) + loadCLIPlugins(&cmd, &out) envs := strings.Join([]string{ "fullenv", @@ -119,9 +120,7 @@ func TestLoadPlugins(t *testing.T) { plugins := cmd.Commands() - if len(plugins) != len(tests) { - t.Fatalf("Expected %d plugins, got %d", len(tests), len(plugins)) - } + require.Len(t, plugins, len(tests), "Expected %d plugins, got %d", len(tests), len(plugins)) for i := 0; i < len(plugins); i++ { out.Reset() @@ -153,9 +152,7 @@ func TestLoadPlugins(t *testing.T) { t.Errorf("Error running %s: %+v", tt.use, err) } } - if out.String() != tt.expect { - t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String()) - } + assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use) } } } @@ -169,7 +166,7 @@ func TestLoadPluginsWithSpace(t *testing.T) { out bytes.Buffer cmd cobra.Command ) - loadPlugins(&cmd, &out) + loadCLIPlugins(&cmd, &out) envs := strings.Join([]string{ "fullenv", @@ -228,9 +225,7 @@ func TestLoadPluginsWithSpace(t *testing.T) { t.Errorf("Error running %s: %+v", tt.use, err) } } - if out.String() != tt.expect { - t.Errorf("Expected %s to output:\n%s\ngot\n%s", tt.use, tt.expect, out.String()) - } + assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use) } } } @@ -242,7 +237,7 @@ type staticCompletionDetails struct { next []staticCompletionDetails } -func TestLoadPluginsForCompletion(t *testing.T) { +func TestLoadCLIPluginsForCompletion(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" var out bytes.Buffer @@ -250,8 +245,7 @@ func TestLoadPluginsForCompletion(t *testing.T) { cmd := &cobra.Command{ Use: "completion", } - - loadPlugins(cmd, &out) + loadCLIPlugins(cmd, &out) tests := []staticCompletionDetails{ {"args", []string{}, []string{}, []staticCompletionDetails{}}, @@ -276,30 +270,17 @@ func TestLoadPluginsForCompletion(t *testing.T) { func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompletionDetails) { t.Helper() - if len(plugins) != len(tests) { - t.Fatalf("Expected commands %v, got %v", tests, plugins) - } + require.Len(t, plugins, len(tests), "Expected commands %v, got %v", tests, plugins) - for i := 0; i < len(plugins); i++ { + is := assert.New(t) + for i := range plugins { pp := plugins[i] tt := tests[i] - if pp.Use != tt.use { - t.Errorf("%s: Expected Use=%q, got %q", pp.Name(), tt.use, pp.Use) - } + is.Equal(pp.Use, tt.use, "Expected Use=%q, got %q", tt.use, pp.Use) targs := tt.validArgs pargs := pp.ValidArgs - if len(targs) != len(pargs) { - t.Fatalf("%s: expected args %v, got %v", pp.Name(), targs, pargs) - } - - sort.Strings(targs) - sort.Strings(pargs) - for j := range targs { - if targs[j] != pargs[j] { - t.Errorf("%s: expected validArg=%q, got %q", pp.Name(), targs[j], pargs[j]) - } - } + is.ElementsMatch(targs, pargs) tflags := tt.flags var pflags []string @@ -309,17 +290,8 @@ func checkCommand(t *testing.T, plugins []*cobra.Command, tests []staticCompleti pflags = append(pflags, flag.Shorthand) } }) - if len(tflags) != len(pflags) { - t.Fatalf("%s: expected flags %v, got %v", pp.Name(), tflags, pflags) - } + is.ElementsMatch(tflags, pflags) - sort.Strings(tflags) - sort.Strings(pflags) - for j := range tflags { - if tflags[j] != pflags[j] { - t.Errorf("%s: expected flag=%q, got %q", pp.Name(), tflags[j], pflags[j]) - } - } // Check the next level checkCommand(t, pp.Commands(), tt.next) } @@ -358,7 +330,7 @@ func TestPluginDynamicCompletion(t *testing.T) { } } -func TestLoadPlugins_HelmNoPlugins(t *testing.T) { +func TestLoadCLIPlugins_HelmNoPlugins(t *testing.T) { settings.PluginsDirectory = "testdata/helmhome/helm/plugins" settings.RepositoryConfig = "testdata/helmhome/helm/repository" @@ -366,7 +338,7 @@ func TestLoadPlugins_HelmNoPlugins(t *testing.T) { out := bytes.NewBuffer(nil) cmd := &cobra.Command{} - loadPlugins(cmd, out) + loadCLIPlugins(cmd, out) plugins := cmd.Commands() if len(plugins) != 0 { diff --git a/pkg/cmd/plugin_uninstall.go b/pkg/cmd/plugin_uninstall.go index ec73ad6df..85eb46219 100644 --- a/pkg/cmd/plugin_uninstall.go +++ b/pkg/cmd/plugin_uninstall.go @@ -21,10 +21,11 @@ import ( "io" "log/slog" "os" + "path/filepath" "github.com/spf13/cobra" - "helm.sh/helm/v4/pkg/plugin" + "helm.sh/helm/v4/internal/plugin" ) type pluginUninstallOptions struct { @@ -61,7 +62,7 @@ func (o *pluginUninstallOptions) complete(args []string) error { func (o *pluginUninstallOptions) run(out io.Writer) error { slog.Debug("loading installer plugins", "dir", settings.PluginsDirectory) - plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + plugins, err := plugin.LoadAll(settings.PluginsDirectory) if err != nil { return err } @@ -83,16 +84,47 @@ func (o *pluginUninstallOptions) run(out io.Writer) error { return nil } -func uninstallPlugin(p *plugin.Plugin) error { - if err := os.RemoveAll(p.Dir); err != nil { +func uninstallPlugin(p plugin.Plugin) error { + if err := os.RemoveAll(p.Dir()); err != nil { return err } + + // Clean up versioned tarball and provenance files from HELM_PLUGINS directory + // These files are saved with pattern: PLUGIN_NAME-VERSION.tgz and PLUGIN_NAME-VERSION.tgz.prov + pluginName := p.Metadata().Name + pluginVersion := p.Metadata().Version + pluginsDir := settings.PluginsDirectory + + // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov + if pluginVersion != "" { + versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion) + + // Remove tarball file + tarballPath := filepath.Join(pluginsDir, versionedBasename) + if _, err := os.Stat(tarballPath); err == nil { + slog.Debug("removing versioned tarball", "path", tarballPath) + if err := os.Remove(tarballPath); err != nil { + slog.Debug("failed to remove tarball file", "path", tarballPath, "error", err) + } + } + + // Remove provenance file + provPath := filepath.Join(pluginsDir, versionedBasename+".prov") + if _, err := os.Stat(provPath); err == nil { + slog.Debug("removing versioned provenance", "path", provPath) + if err := os.Remove(provPath); err != nil { + slog.Debug("failed to remove provenance file", "path", provPath, "error", err) + } + } + } + return runHook(p, plugin.Delete) } -func findPlugin(plugins []*plugin.Plugin, name string) *plugin.Plugin { +// TODO should this be in pkg/plugin/loader.go? +func findPlugin(plugins []plugin.Plugin, name string) plugin.Plugin { for _, p := range plugins { - if p.Metadata.Name == name { + if p.Metadata().Name == name { return p } } diff --git a/pkg/cmd/plugin_uninstall_test.go b/pkg/cmd/plugin_uninstall_test.go new file mode 100644 index 000000000..93d4dc8a8 --- /dev/null +++ b/pkg/cmd/plugin_uninstall_test.go @@ -0,0 +1,146 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/test/ensure" + "helm.sh/helm/v4/pkg/cli" +) + +func TestPluginUninstallCleansUpVersionedFiles(t *testing.T) { + ensure.HelmHome(t) + + // Create a fake plugin directory structure in a temp directory + pluginsDir := t.TempDir() + t.Setenv("HELM_PLUGINS", pluginsDir) + + // Create a new settings instance that will pick up the environment variable + testSettings := cli.New() + pluginName := "test-plugin" + + // Create plugin directory + pluginDir := filepath.Join(pluginsDir, pluginName) + if err := os.MkdirAll(pluginDir, 0755); err != nil { + t.Fatal(err) + } + + // Create plugin.yaml + pluginYAML := `name: test-plugin +version: 1.2.3 +description: Test plugin +command: $HELM_PLUGIN_DIR/test-plugin +` + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil { + t.Fatal(err) + } + + // Create versioned tarball and provenance files + tarballFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz") + provFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz.prov") + otherVersionTarball := filepath.Join(pluginsDir, "test-plugin-2.0.0.tgz") + + if err := os.WriteFile(tarballFile, []byte("fake tarball"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(provFile, []byte("fake provenance"), 0644); err != nil { + t.Fatal(err) + } + // Create another version that should NOT be removed + if err := os.WriteFile(otherVersionTarball, []byte("other version"), 0644); err != nil { + t.Fatal(err) + } + + // Load the plugin + p, err := plugin.LoadDir(pluginDir) + if err != nil { + t.Fatal(err) + } + + // Create a test uninstall function that uses our test settings + testUninstallPlugin := func(plugin plugin.Plugin) error { + if err := os.RemoveAll(plugin.Dir()); err != nil { + return err + } + + // Clean up versioned tarball and provenance files from test HELM_PLUGINS directory + pluginName := plugin.Metadata().Name + pluginVersion := plugin.Metadata().Version + testPluginsDir := testSettings.PluginsDirectory + + // Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov + if pluginVersion != "" { + versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion) + + // Remove tarball file + tarballPath := filepath.Join(testPluginsDir, versionedBasename) + if _, err := os.Stat(tarballPath); err == nil { + if err := os.Remove(tarballPath); err != nil { + t.Logf("failed to remove tarball file: %v", err) + } + } + + // Remove provenance file + provPath := filepath.Join(testPluginsDir, versionedBasename+".prov") + if _, err := os.Stat(provPath); err == nil { + if err := os.Remove(provPath); err != nil { + t.Logf("failed to remove provenance file: %v", err) + } + } + } + + // Skip runHook in test + return nil + } + + // Verify files exist before uninstall + if _, err := os.Stat(tarballFile); os.IsNotExist(err) { + t.Fatal("tarball file should exist before uninstall") + } + if _, err := os.Stat(provFile); os.IsNotExist(err) { + t.Fatal("provenance file should exist before uninstall") + } + if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) { + t.Fatal("other version tarball should exist before uninstall") + } + + // Uninstall the plugin + if err := testUninstallPlugin(p); err != nil { + t.Fatal(err) + } + + // Verify plugin directory is removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { + t.Error("plugin directory should be removed") + } + + // Verify only exact version files are removed + if _, err := os.Stat(tarballFile); !os.IsNotExist(err) { + t.Error("versioned tarball file should be removed") + } + if _, err := os.Stat(provFile); !os.IsNotExist(err) { + t.Error("versioned provenance file should be removed") + } + // Verify other version files are NOT removed + if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) { + t.Error("other version tarball should NOT be removed") + } +} diff --git a/pkg/cmd/plugin_update.go b/pkg/cmd/plugin_update.go index 59d884877..c6d4b8530 100644 --- a/pkg/cmd/plugin_update.go +++ b/pkg/cmd/plugin_update.go @@ -24,8 +24,8 @@ import ( "github.com/spf13/cobra" - "helm.sh/helm/v4/pkg/plugin" - "helm.sh/helm/v4/pkg/plugin/installer" + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/plugin/installer" ) type pluginUpdateOptions struct { @@ -63,7 +63,7 @@ func (o *pluginUpdateOptions) complete(args []string) error { func (o *pluginUpdateOptions) run(out io.Writer) error { installer.Debug = settings.Debug slog.Debug("loading installed plugins", "path", settings.PluginsDirectory) - plugins, err := plugin.FindPlugins(settings.PluginsDirectory) + plugins, err := plugin.LoadAll(settings.PluginsDirectory) if err != nil { return err } @@ -86,8 +86,8 @@ func (o *pluginUpdateOptions) run(out io.Writer) error { return nil } -func updatePlugin(p *plugin.Plugin) error { - exactLocation, err := filepath.EvalSymlinks(p.Dir) +func updatePlugin(p plugin.Plugin) error { + exactLocation, err := filepath.EvalSymlinks(p.Dir()) if err != nil { return err } diff --git a/pkg/cmd/plugin_verify.go b/pkg/cmd/plugin_verify.go new file mode 100644 index 000000000..5f89e743e --- /dev/null +++ b/pkg/cmd/plugin_verify.go @@ -0,0 +1,123 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/pkg/cmd/require" +) + +const pluginVerifyDesc = ` +This command verifies that a Helm plugin has a valid provenance file, +and that the provenance file is signed by a trusted PGP key. + +It supports both: +- Plugin tarballs (.tgz or .tar.gz files) +- Installed plugin directories + +For installed plugins, use the path shown by 'helm env HELM_PLUGINS' followed +by the plugin name. For example: + helm plugin verify ~/.local/share/helm/plugins/example-cli + +To generate a signed plugin, use the 'helm plugin package --sign' command. +` + +type pluginVerifyOptions struct { + keyring string + pluginPath string +} + +func newPluginVerifyCmd(out io.Writer) *cobra.Command { + o := &pluginVerifyOptions{} + + cmd := &cobra.Command{ + Use: "verify [PATH]", + Short: "verify that a plugin at the given path has been signed and is valid", + Long: pluginVerifyDesc, + Args: require.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + o.pluginPath = args[0] + return o.run(out) + }, + } + + cmd.Flags().StringVar(&o.keyring, "keyring", defaultKeyring(), "keyring containing public keys") + + return cmd +} + +func (o *pluginVerifyOptions) run(out io.Writer) error { + // Verify the plugin path exists + fi, err := os.Stat(o.pluginPath) + if err != nil { + return err + } + + // Only support tarball verification + if fi.IsDir() { + return fmt.Errorf("directory verification not supported - only plugin tarballs can be verified") + } + + // Verify it's a tarball + if !plugin.IsTarball(o.pluginPath) { + return fmt.Errorf("plugin file must be a gzipped tarball (.tar.gz or .tgz)") + } + + // Look for provenance file + provFile := o.pluginPath + ".prov" + if _, err := os.Stat(provFile); err != nil { + return fmt.Errorf("could not find provenance file %s: %w", provFile, err) + } + + // Read the files + archiveData, err := os.ReadFile(o.pluginPath) + if err != nil { + return fmt.Errorf("failed to read plugin file: %w", err) + } + + provData, err := os.ReadFile(provFile) + if err != nil { + return fmt.Errorf("failed to read provenance file: %w", err) + } + + // Verify the plugin using data + verification, err := plugin.VerifyPlugin(archiveData, provData, filepath.Base(o.pluginPath), o.keyring) + if err != nil { + return err + } + + // Output verification details + for name := range verification.SignedBy.Identities { + fmt.Fprintf(out, "Signed by: %v\n", name) + } + fmt.Fprintf(out, "Using Key With Fingerprint: %X\n", verification.SignedBy.PrimaryKey.Fingerprint) + + // Only show hash for tarballs + if verification.FileHash != "" { + fmt.Fprintf(out, "Plugin Hash Verified: %s\n", verification.FileHash) + } else { + fmt.Fprintf(out, "Plugin Metadata Verified: %s\n", verification.FileName) + } + + return nil +} diff --git a/pkg/cmd/plugin_verify_test.go b/pkg/cmd/plugin_verify_test.go new file mode 100644 index 000000000..e631814dd --- /dev/null +++ b/pkg/cmd/plugin_verify_test.go @@ -0,0 +1,264 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/test/ensure" +) + +func TestPluginVerifyCmd_NoArgs(t *testing.T) { + ensure.HelmHome(t) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when no arguments provided") + } + if !strings.Contains(err.Error(), "requires 1 argument") { + t.Errorf("expected 'requires 1 argument' error, got: %v", err) + } +} + +func TestPluginVerifyCmd_TooManyArgs(t *testing.T) { + ensure.HelmHome(t) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{"plugin1", "plugin2"}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when too many arguments provided") + } + if !strings.Contains(err.Error(), "requires 1 argument") { + t.Errorf("expected 'requires 1 argument' error, got: %v", err) + } +} + +func TestPluginVerifyCmd_NonexistentFile(t *testing.T) { + ensure.HelmHome(t) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{"/nonexistent/plugin.tgz"}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when plugin file doesn't exist") + } +} + +func TestPluginVerifyCmd_MissingProvenance(t *testing.T) { + ensure.HelmHome(t) + + // Create a plugin tarball without .prov file + pluginTgz := createTestPluginTarball(t) + defer os.Remove(pluginTgz) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{pluginTgz}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when .prov file is missing") + } + if !strings.Contains(err.Error(), "could not find provenance file") { + t.Errorf("expected 'could not find provenance file' error, got: %v", err) + } +} + +func TestPluginVerifyCmd_InvalidProvenance(t *testing.T) { + ensure.HelmHome(t) + + // Create a plugin tarball with invalid .prov file + pluginTgz := createTestPluginTarball(t) + defer os.Remove(pluginTgz) + + // Create invalid .prov file + provFile := pluginTgz + ".prov" + if err := os.WriteFile(provFile, []byte("invalid provenance"), 0644); err != nil { + t.Fatal(err) + } + defer os.Remove(provFile) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{pluginTgz}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when .prov file is invalid") + } +} + +func TestPluginVerifyCmd_DirectoryNotSupported(t *testing.T) { + ensure.HelmHome(t) + + // Create a plugin directory + pluginDir := createTestPluginDir(t) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{pluginDir}) + + err := cmd.Execute() + if err == nil { + t.Error("expected error when verifying directory") + } + if !strings.Contains(err.Error(), "directory verification not supported") { + t.Errorf("expected 'directory verification not supported' error, got: %v", err) + } +} + +func TestPluginVerifyCmd_KeyringFlag(t *testing.T) { + ensure.HelmHome(t) + + // Create a plugin tarball with .prov file + pluginTgz := createTestPluginTarball(t) + defer os.Remove(pluginTgz) + + // Create .prov file + provFile := pluginTgz + ".prov" + createProvFile(t, provFile, pluginTgz, "") + defer os.Remove(provFile) + + // Create empty keyring file + keyring := createTestKeyring(t) + defer os.Remove(keyring) + + out := &bytes.Buffer{} + cmd := newPluginVerifyCmd(out) + cmd.SetArgs([]string{"--keyring", keyring, pluginTgz}) + + // Should fail with keyring error but command parsing should work + err := cmd.Execute() + if err == nil { + t.Error("expected error with empty keyring") + } + // The important thing is that the keyring flag was parsed and used +} + +func TestPluginVerifyOptions_Run_Success(t *testing.T) { + // Skip this test as it would require real PGP keys and valid signatures + // The core verification logic is thoroughly tested in internal/plugin/verify_test.go + t.Skip("Success case requires real PGP keys - core logic tested in internal/plugin/verify_test.go") +} + +// 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) + } + + // Use the same plugin YAML as other cmd tests + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(testPluginYAML), 0644); err != nil { + t.Fatalf("Failed to create plugin.yaml: %v", err) + } + + return pluginDir +} + +func createTestPluginTarball(t *testing.T) string { + t.Helper() + + pluginDir := createTestPluginDir(t) + + // 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 + 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 + 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 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 +} diff --git a/pkg/cmd/pull_test.go b/pkg/cmd/pull_test.go index 58e1862ae..c3156c394 100644 --- a/pkg/cmd/pull_test.go +++ b/pkg/cmd/pull_test.go @@ -212,15 +212,18 @@ func TestPullCmd(t *testing.T) { }, } + contentCache := t.TempDir() + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { outdir := srv.Root() - cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s --plain-http", + cmd := fmt.Sprintf("fetch %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s --content-cache %s --plain-http", tt.args, outdir, filepath.Join(outdir, "repositories.yaml"), outdir, filepath.Join(outdir, "config.json"), + contentCache, ) // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 if tt.existFile != "" { @@ -268,6 +271,78 @@ func TestPullCmd(t *testing.T) { } } +// runPullTests is a helper function to run pull command tests with common logic +func runPullTests(t *testing.T, tests []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + expectFile string + expectDir bool +}, outdir string, additionalFlags string) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s %s", + tt.args, + outdir, + filepath.Join(outdir, "repositories.yaml"), + outdir, + filepath.Join(outdir, "config.json"), + additionalFlags, + ) + // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 + if tt.existFile != "" { + file := filepath.Join(outdir, tt.existFile) + _, err := os.Create(file) + if err != nil { + t.Fatal(err) + } + } + if tt.existDir != "" { + file := filepath.Join(outdir, tt.existDir) + err := os.Mkdir(file, 0755) + if err != nil { + t.Fatal(err) + } + } + _, _, err := executeActionCommand(cmd) + if err != nil { + if tt.wantError { + if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { + t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) + } + return + } + t.Fatalf("%q reported error: %s", tt.name, err) + } + + ef := filepath.Join(outdir, tt.expectFile) + fi, err := os.Stat(ef) + if err != nil { + t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) + } + if fi.IsDir() != tt.expectDir { + t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) + } + }) + } +} + +// buildOCIURL is a helper function to build OCI URLs with credentials +func buildOCIURL(registryURL, chartName, version, username, password string) string { + baseURL := fmt.Sprintf("oci://%s/u/ocitestuser/%s", registryURL, chartName) + if version != "" { + baseURL += fmt.Sprintf(" --version %s", version) + } + if username != "" && password != "" { + baseURL += fmt.Sprintf(" --username %s --password %s", username, password) + } + return baseURL +} + func TestPullWithCredentialsCmd(t *testing.T) { srv := repotest.NewTempServer( t, @@ -323,52 +398,7 @@ func TestPullWithCredentialsCmd(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - outdir := srv.Root() - cmd := fmt.Sprintf("pull %s -d '%s' --repository-config %s --repository-cache %s --registry-config %s", - tt.args, - outdir, - filepath.Join(outdir, "repositories.yaml"), - outdir, - filepath.Join(outdir, "config.json"), - ) - // Create file or Dir before helm pull --untar, see: https://github.com/helm/helm/issues/7182 - if tt.existFile != "" { - file := filepath.Join(outdir, tt.existFile) - _, err := os.Create(file) - if err != nil { - t.Fatal(err) - } - } - if tt.existDir != "" { - file := filepath.Join(outdir, tt.existDir) - err := os.Mkdir(file, 0755) - if err != nil { - t.Fatal(err) - } - } - _, _, err := executeActionCommand(cmd) - if err != nil { - if tt.wantError { - if tt.wantErrorMsg != "" && tt.wantErrorMsg == err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, tt.wantErrorMsg) - } - return - } - t.Fatalf("%q reported error: %s", tt.name, err) - } - - ef := filepath.Join(outdir, tt.expectFile) - fi, err := os.Stat(ef) - if err != nil { - t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) - } - if fi.IsDir() != tt.expectDir { - t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) - } - }) - } + runPullTests(t, tests, srv.Root(), "") } func TestPullVersionCompletion(t *testing.T) { @@ -401,6 +431,72 @@ func TestPullVersionCompletion(t *testing.T) { runTestCmd(t, tests) } +func TestPullWithCredentialsCmdOCIRegistry(t *testing.T) { + srv := repotest.NewTempServer( + t, + repotest.WithChartSourceGlob("testdata/testcharts/*.tgz*"), + ) + defer srv.Stop() + + ociSrv, err := repotest.NewOCIServer(t, srv.Root()) + if err != nil { + t.Fatal(err) + } + ociSrv.Run(t) + + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + // all flags will get "-d outdir" appended. + tests := []struct { + name string + args string + existFile string + existDir string + wantError bool + wantErrorMsg string + expectFile string + expectDir bool + }{ + { + name: "OCI Chart fetch with credentials", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword), + expectFile: "./oci-dependent-chart-0.1.0.tgz", + }, + { + name: "OCI Chart fetch with credentials and untar", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar", + expectFile: "./oci-dependent-chart", + expectDir: true, + }, + { + name: "OCI Chart fetch with credentials and untardir", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword) + " --untar --untardir ocitest-credentials", + expectFile: "./ocitest-credentials", + expectDir: true, + }, + { + name: "Fail fetching OCI chart with wrong credentials", + args: buildOCIURL(ociSrv.RegistryURL, "oci-dependent-chart", "0.1.0", "wronguser", "wrongpass"), + wantError: true, + }, + { + name: "Fail fetching non-existent OCI chart with credentials", + args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "0.1.0", ociSrv.TestUsername, ociSrv.TestPassword), + wantError: true, + }, + { + name: "Fail fetching OCI chart without version specified", + args: buildOCIURL(ociSrv.RegistryURL, "nosuchthing", "", ociSrv.TestUsername, ociSrv.TestPassword), + wantErrorMsg: "Error: --version flag is explicitly required for OCI registries", + wantError: true, + }, + } + + runPullTests(t, tests, srv.Root(), "--plain-http") +} + func TestPullFileCompletion(t *testing.T) { checkFileCompletion(t, "pull", false) checkFileCompletion(t, "pull repo/chart", false) diff --git a/pkg/cmd/release_testing.go b/pkg/cmd/release_testing.go index b43b67ca0..b660a16c5 100644 --- a/pkg/cmd/release_testing.go +++ b/pkg/cmd/release_testing.go @@ -59,8 +59,8 @@ func newReleaseTestCmd(cfg *action.Configuration, out io.Writer) *cobra.Command client.Namespace = settings.Namespace() notName := regexp.MustCompile(`^!\s?name=`) for _, f := range filter { - if strings.HasPrefix(f, "name=") { - client.Filters[action.IncludeNameFilter] = append(client.Filters[action.IncludeNameFilter], strings.TrimPrefix(f, "name=")) + if after, ok := strings.CutPrefix(f, "name="); ok { + client.Filters[action.IncludeNameFilter] = append(client.Filters[action.IncludeNameFilter], after) } else if notName.MatchString(f) { client.Filters[action.ExcludeNameFilter] = append(client.Filters[action.ExcludeNameFilter], notName.ReplaceAllLiteralString(f, "")) } diff --git a/pkg/cmd/rollback.go b/pkg/cmd/rollback.go index 4b7f3016d..ff60aaedf 100644 --- a/pkg/cmd/rollback.go +++ b/pkg/cmd/rollback.go @@ -80,12 +80,16 @@ func newRollbackCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.StringVar(&client.ServerSideApply, "server-side", "auto", "must be \"true\", \"false\" or \"auto\". Object updates run in the server instead of the client (\"auto\" defaults the value from the previous chart release's method)") f.BoolVar(&client.DisableHooks, "no-hooks", false, "prevent hooks from running during rollback") f.DurationVar(&client.Timeout, "timeout", 300*time.Second, "time to wait for any individual Kubernetes operation (like Jobs for hooks)") f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this rollback when rollback fails") f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") return cmd } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index f43ce7abe..836df834d 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -291,8 +291,8 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg newPushCmd(actionConfig, out), ) - // Find and add plugins - loadPlugins(cmd, out) + // Find and add CLI plugins + loadCLIPlugins(cmd, out) // Check for expired repositories checkForExpiredRepos(settings.RepositoryConfig) diff --git a/pkg/cmd/show_test.go b/pkg/cmd/show_test.go index ab8cafc37..5ccb4bcad 100644 --- a/pkg/cmd/show_test.go +++ b/pkg/cmd/show_test.go @@ -64,14 +64,17 @@ func TestShowPreReleaseChart(t *testing.T) { }, } + contentTmp := t.TempDir() + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { outdir := srv.Root() - cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s", + cmd := fmt.Sprintf("show all '%s' %s --repository-config %s --repository-cache %s --content-cache %s", tt.args, tt.flags, filepath.Join(outdir, "repositories.yaml"), outdir, + contentTmp, ) //_, out, err := executeActionCommand(cmd) _, _, err := executeActionCommand(cmd) diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index ac20a45b3..c93b5395b 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -203,7 +203,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.StringVar(&kubeVersion, "kube-version", "", "Kubernetes version used for Capabilities.KubeVersion") f.StringSliceVarP(&extraAPIs, "api-versions", "a", []string{}, "Kubernetes api versions used for Capabilities.APIVersions (multiple can be specified)") f.BoolVar(&client.UseReleaseName, "release-name", false, "use release name in the output-dir path.") - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) return cmd } diff --git a/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml index 63f2f12db..8b874da1d 100644 --- a/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml +++ b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml @@ -1,4 +1,10 @@ name: fullenv -usage: "show env vars" -description: "show all env vars" -command: "$HELM_PLUGIN_DIR/fullenv.sh" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "show env vars" + longHelp: "show all env vars" + ignoreFlags: false +runtimeConfig: + command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml index 21e28a7c2..57312cbfa 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -1,4 +1,10 @@ name: args -usage: "echo args" -description: "This echos args" -command: "$HELM_PLUGIN_DIR/args.sh" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "echo args" + longHelp: "This echos args" + ignoreFlags: false +runtimeConfig: + command: "$HELM_PLUGIN_DIR/args.sh" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml index 7b9362a08..544efa85e 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -1,4 +1,10 @@ name: echo -usage: "echo stuff" -description: "This echos stuff" -command: "echo hello" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "echo stuff" + longHelp: "This echos stuff" + ignoreFlags: false +runtimeConfig: + command: "echo hello" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml index 52cb7a848..d7a4c229c 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml @@ -1,4 +1,10 @@ name: env -usage: "env stuff" -description: "show the env" -command: "echo $HELM_PLUGIN_NAME" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "env stuff" + longHelp: "show the env" + ignoreFlags: false +runtimeConfig: + command: "echo $HELM_PLUGIN_NAME" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml index 5691d1712..06a350f83 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -1,4 +1,10 @@ name: exitwith -usage: "exitwith code" -description: "This exits with the specified exit code" -command: "$HELM_PLUGIN_DIR/exitwith.sh" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "exitwith code" + longHelp: "This exits with the specified exit code" + ignoreFlags: false +runtimeConfig: + command: "$HELM_PLUGIN_DIR/exitwith.sh" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml index 63f2f12db..8b874da1d 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -1,4 +1,10 @@ name: fullenv -usage: "show env vars" -description: "show all env vars" -command: "$HELM_PLUGIN_DIR/fullenv.sh" +type: cli/v1 +apiVersion: v1 +runtime: subprocess +config: + shortHelp: "show env vars" + longHelp: "show all env vars" + ignoreFlags: false +runtimeConfig: + command: "$HELM_PLUGIN_DIR/fullenv.sh" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml @@ -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" diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/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 diff --git a/pkg/cmd/testdata/output/env-comp.txt b/pkg/cmd/testdata/output/env-comp.txt index 8f9c53fc7..9d38ee464 100644 --- a/pkg/cmd/testdata/output/env-comp.txt +++ b/pkg/cmd/testdata/output/env-comp.txt @@ -2,6 +2,7 @@ HELM_BIN HELM_BURST_LIMIT HELM_CACHE_HOME HELM_CONFIG_HOME +HELM_CONTENT_CACHE HELM_DATA_HOME HELM_DEBUG HELM_KUBEAPISERVER diff --git a/pkg/cmd/testdata/output/get-metadata.txt b/pkg/cmd/testdata/output/get-metadata.txt index 5744083dd..b3cb73ee2 100644 --- a/pkg/cmd/testdata/output/get-metadata.txt +++ b/pkg/cmd/testdata/output/get-metadata.txt @@ -9,3 +9,4 @@ NAMESPACE: default REVISION: 1 STATUS: deployed DEPLOYED_AT: 1977-09-02T22:04:05Z +APPLY_METHOD: client-side apply (defaulted) diff --git a/pkg/cmd/testdata/testplugin/plugin.yaml b/pkg/cmd/testdata/testplugin/plugin.yaml deleted file mode 100644 index 890292cbf..000000000 --- a/pkg/cmd/testdata/testplugin/plugin.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: testplugin -usage: "echo test" -description: "This echos test" -command: "echo test" diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index c3288286b..c8fbf8bd3 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -140,7 +140,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { instClient.WaitForJobs = client.WaitForJobs instClient.Devel = client.Devel instClient.Namespace = client.Namespace - instClient.Atomic = client.Atomic + instClient.RollbackOnFailure = client.RollbackOnFailure instClient.PostRenderer = client.PostRenderer instClient.DisableOpenAPIValidation = client.DisableOpenAPIValidation instClient.SubNotes = client.SubNotes @@ -210,6 +210,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { Getters: p, RepositoryConfig: settings.RepositoryConfig, RepositoryCache: settings.RepositoryCache, + ContentCache: settings.ContentCache, Debug: settings.Debug, } if err := man.Update(); err != nil { @@ -273,6 +274,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.ForceReplace, "force-replace", false, "force resource updates by replacement") f.BoolVar(&client.ForceReplace, "force", false, "deprecated") f.MarkDeprecated("force", "use --force-replace instead") + f.BoolVar(&client.ForceConflicts, "force-conflicts", false, "if set server-side apply will force changes against conflicts") + f.StringVar(&client.ServerSideApply, "server-side", "auto", "must be \"true\", \"false\" or \"auto\". Object updates run in the server instead of the client (\"auto\" defaults the value from the previous chart release's method)") f.BoolVar(&client.DisableHooks, "no-hooks", false, "disable pre/post upgrade hooks") f.BoolVar(&client.DisableOpenAPIValidation, "disable-openapi-validation", false, "if set, the upgrade process will not validate rendered templates against the Kubernetes OpenAPI Schema") f.BoolVar(&client.SkipCRDs, "skip-crds", false, "if set, no CRDs will be installed when an upgrade is performed with install flag enabled. By default, CRDs are installed if not already present, when an upgrade is performed with install flag enabled") @@ -281,7 +284,9 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.ReuseValues, "reuse-values", false, "when upgrading, reuse the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' is specified, this is ignored") f.BoolVar(&client.ResetThenReuseValues, "reset-then-reuse-values", false, "when upgrading, reset the values to the ones built into the chart, apply the last release's values and merge in any overrides from the command line via --set and -f. If '--reset-values' or '--reuse-values' is specified, this is ignored") f.BoolVar(&client.WaitForJobs, "wait-for-jobs", false, "if set and --wait enabled, will wait until all Jobs have been completed before marking the release as successful. It will wait for as long as --timeout") - f.BoolVar(&client.Atomic, "atomic", false, "if set, upgrade process rolls back changes made in case of failed upgrade. The --wait flag will be set automatically to \"watcher\" if --atomic is used") + f.BoolVar(&client.RollbackOnFailure, "rollback-on-failure", false, "if set, Helm will rollback the upgrade to previous success release upon failure. The --wait flag will be defaulted to \"watcher\" if --rollback-on-failure is set") + f.BoolVar(&client.RollbackOnFailure, "atomic", false, "deprecated") + f.MarkDeprecated("atomic", "use --rollback-on-failure instead") f.IntVar(&client.MaxHistory, "history-max", settings.MaxHistory, "limit the maximum number of revisions saved per release. Use 0 for no limit") f.BoolVar(&client.CleanupOnFail, "cleanup-on-fail", false, "allow deletion of new resources created in this upgrade when upgrade fails") f.BoolVar(&client.SubNotes, "render-subchart-notes", false, "if set, render subchart notes along with the parent") @@ -295,8 +300,10 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addChartPathOptionsFlags(f, &client.ChartPathOptions) addValueOptionsFlags(f, valueOpts) bindOutputFlag(cmd, &outfmt) - bindPostRenderFlag(cmd, &client.PostRenderer) + bindPostRenderFlag(cmd, &client.PostRenderer, settings) AddWaitFlag(cmd, &client.WaitStrategy) + cmd.MarkFlagsMutuallyExclusive("force-replace", "force-conflicts") + cmd.MarkFlagsMutuallyExclusive("force", "force-conflicts") err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 2 { diff --git a/pkg/downloader/cache.go b/pkg/downloader/cache.go new file mode 100644 index 000000000..cecfc8bd7 --- /dev/null +++ b/pkg/downloader/cache.go @@ -0,0 +1,89 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package downloader + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + + "helm.sh/helm/v4/internal/fileutil" +) + +// Cache describes a cache that can get and put chart data. +// The cache key is the sha256 has of the content. sha256 is used in Helm for +// digests in index files providing a common key for checking content. +type Cache interface { + // Get returns a reader for the given key. + Get(key [sha256.Size]byte, cacheType string) (string, error) + // Put stores the given reader for the given key. + Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) +} + +// CacheChart specifies the content is a chart +var CacheChart = ".chart" + +// CacheProv specifies the content is a provenance file +var CacheProv = ".prov" + +// TODO: The cache assumes files because much of Helm assumes files. Convert +// Helm to pass content around instead of file locations. + +// DiskCache is a cache that stores data on disk. +type DiskCache struct { + Root string +} + +// Get returns a reader for the given key. +func (c *DiskCache) Get(key [sha256.Size]byte, cacheType string) (string, error) { + p := c.fileName(key, cacheType) + fi, err := os.Stat(p) + if err != nil { + return "", err + } + // Empty files treated as not exist because there is no content. + if fi.Size() == 0 { + return p, os.ErrNotExist + } + // directories should never happen unless something outside helm is operating + // on this content. + if fi.IsDir() { + return p, errors.New("is a directory") + } + return p, nil +} + +// Put stores the given reader for the given key. +// It returns the path to the stored file. +func (c *DiskCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) { + // TODO: verify the key and digest of the key are the same. + p := c.fileName(key, cacheType) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + slog.Error("failed to create cache directory") + return p, err + } + return p, fileutil.AtomicWriteFile(p, data, 0644) +} + +// fileName generates the filename in a structured manner where the first part is the +// directory and the full hash is the filename. +func (c *DiskCache) fileName(id [sha256.Size]byte, cacheType string) string { + return filepath.Join(c.Root, fmt.Sprintf("%02x", id[0]), fmt.Sprintf("%x", id)+cacheType) +} diff --git a/pkg/downloader/cache_test.go b/pkg/downloader/cache_test.go new file mode 100644 index 000000000..340c77aba --- /dev/null +++ b/pkg/downloader/cache_test.go @@ -0,0 +1,122 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package downloader + +import ( + "bytes" + "crypto/sha256" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// compiler check to ensure DiskCache implements the Cache interface. +var _ Cache = (*DiskCache)(nil) + +func TestDiskCache_PutAndGet(t *testing.T) { + // Setup a temporary directory for the cache + tmpDir := t.TempDir() + cache := &DiskCache{Root: tmpDir} + + // Test data + content := []byte("hello world") + key := sha256.Sum256(content) + + // --- Test case 1: Put and Get a regular file (prov=false) --- + t.Run("PutAndGetTgz", func(t *testing.T) { + // Put the data into the cache + path, err := cache.Put(key, bytes.NewReader(content), CacheChart) + require.NoError(t, err, "Put should not return an error") + + // Verify the file exists at the returned path + _, err = os.Stat(path) + require.NoError(t, err, "File should exist after Put") + + // Get the file from the cache + retrievedPath, err := cache.Get(key, CacheChart) + require.NoError(t, err, "Get should not return an error for existing file") + assert.Equal(t, path, retrievedPath, "Get should return the same path as Put") + + // Verify content + data, err := os.ReadFile(retrievedPath) + require.NoError(t, err) + assert.Equal(t, content, data, "Content of retrieved file should match original content") + }) + + // --- Test case 2: Put and Get a provenance file (prov=true) --- + t.Run("PutAndGetProv", func(t *testing.T) { + provContent := []byte("provenance data") + provKey := sha256.Sum256(provContent) + + path, err := cache.Put(provKey, bytes.NewReader(provContent), CacheProv) + require.NoError(t, err) + + retrievedPath, err := cache.Get(provKey, CacheProv) + require.NoError(t, err) + assert.Equal(t, path, retrievedPath) + + data, err := os.ReadFile(retrievedPath) + require.NoError(t, err) + assert.Equal(t, provContent, data) + }) + + // --- Test case 3: Get a non-existent file --- + t.Run("GetNonExistent", func(t *testing.T) { + nonExistentKey := sha256.Sum256([]byte("does not exist")) + _, err := cache.Get(nonExistentKey, CacheChart) + assert.ErrorIs(t, err, os.ErrNotExist, "Get for a non-existent key should return os.ErrNotExist") + }) + + // --- Test case 4: Put an empty file --- + t.Run("PutEmptyFile", func(t *testing.T) { + emptyContent := []byte{} + emptyKey := sha256.Sum256(emptyContent) + + path, err := cache.Put(emptyKey, bytes.NewReader(emptyContent), CacheChart) + require.NoError(t, err) + + // Get should return ErrNotExist for empty files + _, err = cache.Get(emptyKey, CacheChart) + assert.ErrorIs(t, err, os.ErrNotExist, "Get for an empty file should return os.ErrNotExist") + + // But the file should exist + _, err = os.Stat(path) + require.NoError(t, err, "Empty file should still exist on disk") + }) + + // --- Test case 5: Get a directory --- + t.Run("GetDirectory", func(t *testing.T) { + dirKey := sha256.Sum256([]byte("i am a directory")) + dirPath := cache.fileName(dirKey, CacheChart) + err := os.MkdirAll(dirPath, 0755) + require.NoError(t, err) + + _, err = cache.Get(dirKey, CacheChart) + assert.EqualError(t, err, "is a directory") + }) +} + +func TestDiskCache_fileName(t *testing.T) { + cache := &DiskCache{Root: "/tmp/cache"} + key := sha256.Sum256([]byte("some data")) + + assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.chart"), cache.fileName(key, CacheChart)) + assert.Equal(t, filepath.Join("/tmp/cache", "13", "1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee.prov"), cache.fileName(key, CacheProv)) +} diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index 529fd788e..a24cad3fd 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -16,16 +16,21 @@ limitations under the License. package downloader import ( + "bytes" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" "io/fs" + "log/slog" "net/url" "os" "path/filepath" "strings" "helm.sh/helm/v4/internal/fileutil" + ifs "helm.sh/helm/v4/internal/third_party/dep/fs" "helm.sh/helm/v4/internal/urlutil" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/helmpath" @@ -72,6 +77,14 @@ type ChartDownloader struct { RegistryClient *registry.Client RepositoryConfig string RepositoryCache string + + // ContentCache is the location where Cache stores its files by default + // In previous versions of Helm the charts were put in the RepositoryCache. The + // repositories and charts are stored in 2 difference caches. + ContentCache string + + // Cache specifies the cache implementation to use. + Cache Cache } // DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. @@ -86,7 +99,14 @@ type ChartDownloader struct { // Returns a string path to the location where the file was downloaded and a verification // (if provenance was verified), or an error if something bad happened. func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) { - u, err := c.ResolveChartVersion(ref, version) + if c.Cache == nil { + if c.ContentCache == "" { + return "", nil, errors.New("content cache must be set") + } + c.Cache = &DiskCache{Root: c.ContentCache} + slog.Debug("setup up default downloader cache") + } + hash, u, err := c.ResolveChartVersion(ref, version) if err != nil { return "", nil, err } @@ -96,11 +116,37 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven return "", nil, err } - c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream")) + // Check the cache for the content. Otherwise download it. + // Note, this process will pull from the cache but does not automatically populate + // the cache with the file it downloads. + var data *bytes.Buffer + var found bool + var digest []byte + var digest32 [32]byte + if hash != "" { + // if there is a hash, populate the other formats + digest, err = hex.DecodeString(hash) + if err != nil { + return "", nil, err + } + copy(digest32[:], digest) + if pth, err := c.Cache.Get(digest32, CacheChart); err == nil { + fdata, err := os.ReadFile(pth) + if err == nil { + found = true + data = bytes.NewBuffer(fdata) + slog.Debug("found chart in cache", "id", hash) + } + } + } - data, err := g.Get(u.String(), c.Options...) - if err != nil { - return "", nil, err + if !found { + c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream")) + + data, err = g.Get(u.String(), c.Options...) + if err != nil { + return "", nil, err + } } name := filepath.Base(u.Path) @@ -117,13 +163,27 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven // If provenance is requested, verify it. ver := &provenance.Verification{} if c.Verify > VerifyNever { - body, err := g.Get(u.String() + ".prov") - if err != nil { - if c.Verify == VerifyAlways { - return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") + found = false + var body *bytes.Buffer + if hash != "" { + if pth, err := c.Cache.Get(digest32, CacheProv); err == nil { + fdata, err := os.ReadFile(pth) + if err == nil { + found = true + body = bytes.NewBuffer(fdata) + slog.Debug("found provenance in cache", "id", hash) + } + } + } + if !found { + body, err = g.Get(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) + return destfile, ver, nil } - fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) - return destfile, ver, nil } provfile := destfile + ".prov" if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil { @@ -131,7 +191,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven } if c.Verify != VerifyLater { - ver, err = VerifyChart(destfile, c.Keyring) + ver, err = VerifyChart(destfile, destfile+".prov", c.Keyring) if err != nil { // Fail always in this case, since it means the verification step // failed. @@ -142,10 +202,143 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven return destfile, ver, nil } +// DownloadToCache retrieves resources while using a content based cache. +func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) { + if c.Cache == nil { + if c.ContentCache == "" { + return "", nil, errors.New("content cache must be set") + } + c.Cache = &DiskCache{Root: c.ContentCache} + slog.Debug("setup up default downloader cache") + } + + digestString, u, err := c.ResolveChartVersion(ref, version) + if err != nil { + return "", nil, err + } + + g, err := c.Getters.ByScheme(u.Scheme) + if err != nil { + return "", nil, err + } + + c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream")) + + // Check the cache for the file + digest, err := hex.DecodeString(digestString) + if err != nil { + return "", nil, err + } + var digest32 [32]byte + copy(digest32[:], digest) + if err != nil { + return "", nil, fmt.Errorf("unable to decode digest: %w", err) + } + + var pth string + // only fetch from the cache if we have a digest + if len(digest) > 0 { + pth, err = c.Cache.Get(digest32, CacheChart) + if err == nil { + slog.Debug("found chart in cache", "id", digestString) + } + } + if len(digest) == 0 || err != nil { + slog.Debug("attempting to download chart", "ref", ref, "version", version) + if err != nil && !os.IsNotExist(err) { + return "", nil, err + } + + // Get file not in the cache + data, gerr := g.Get(u.String(), c.Options...) + if gerr != nil { + return "", nil, gerr + } + + // Generate the digest + if len(digest) == 0 { + digest32 = sha256.Sum256(data.Bytes()) + } + + pth, err = c.Cache.Put(digest32, data, CacheChart) + if err != nil { + return "", nil, err + } + slog.Debug("put downloaded chart in cache", "id", hex.EncodeToString(digest32[:])) + } + + // If provenance is requested, verify it. + ver := &provenance.Verification{} + if c.Verify > VerifyNever { + + ppth, err := c.Cache.Get(digest32, CacheProv) + if err == nil { + slog.Debug("found provenance in cache", "id", digestString) + } else { + if !os.IsNotExist(err) { + return pth, ver, err + } + + body, err := g.Get(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return pth, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err) + return pth, ver, nil + } + + ppth, err = c.Cache.Put(digest32, body, CacheProv) + if err != nil { + return "", nil, err + } + slog.Debug("put downloaded provenance file in cache", "id", hex.EncodeToString(digest32[:])) + } + + if c.Verify != VerifyLater { + + // provenance files pin to a specific name so this needs to be accounted for + // when verifying. + // Note, this does make an assumption that the name/version is unique to a + // hash when a provenance file is used. If this isn't true, this section of code + // will need to be reworked. + name := filepath.Base(u.Path) + if u.Scheme == registry.OCIScheme { + idx := strings.LastIndexByte(name, ':') + name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:]) + } + + // Copy chart to a known location with the right name for verification and then + // clean it up. + tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp")) + if err := os.MkdirAll(tmpdir, 0755); err != nil { + return pth, ver, err + } + tmpfile := filepath.Join(tmpdir, name) + err = ifs.CopyFile(pth, tmpfile) + if err != nil { + return pth, ver, err + } + // Not removing the tmp dir itself because a concurrent process may be using it + defer os.RemoveAll(tmpfile) + + ver, err = VerifyChart(tmpfile, ppth, c.Keyring) + if err != nil { + // Fail always in this case, since it means the verification step + // failed. + return pth, ver, err + } + } + } + return pth, ver, nil +} + // ResolveChartVersion resolves a chart reference to a URL. // -// It returns the URL and sets the ChartDownloader's Options that can fetch -// the URL using the appropriate Getter. +// It returns: +// - A hash of the content if available +// - The URL and sets the ChartDownloader's Options that can fetch the URL using the appropriate Getter. +// - An error if there is one // // A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname' // reference, or a local path. @@ -157,23 +350,26 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven // - If version is non-empty, this will return the URL for that version // - If version is empty, this will return the URL for the latest version // - If no version can be found, an error is returned -func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, error) { +// +// TODO: support OCI hash +func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) { u, err := url.Parse(ref) if err != nil { - return nil, fmt.Errorf("invalid chart URL format: %s", ref) + return "", nil, fmt.Errorf("invalid chart URL format: %s", ref) } if registry.IsOCI(u.String()) { if c.RegistryClient == nil { - return nil, fmt.Errorf("unable to lookup ref %s at version '%s', missing registry client", ref, version) + return "", nil, fmt.Errorf("unable to lookup ref %s at version '%s', missing registry client", ref, version) } - return c.RegistryClient.ValidateReference(ref, version, u) + digest, OCIref, err := c.RegistryClient.ValidateReference(ref, version, u) + return digest, OCIref, err } rf, err := loadRepoConfig(c.RepositoryConfig) if err != nil { - return u, err + return "", u, err } if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { @@ -190,9 +386,9 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er if err == ErrNoOwnerRepo { // Make sure to add the ref URL as the URL for the getter c.Options = append(c.Options, getter.WithURL(ref)) - return u, nil + return "", u, nil } - return u, err + return "", u, err } // If we get here, we don't need to go through the next phase of looking @@ -211,20 +407,20 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er getter.WithPassCredentialsAll(rc.PassCredentialsAll), ) } - return u, nil + return "", u, nil } // See if it's of the form: repo/path_to_chart p := strings.SplitN(u.Path, "/", 2) if len(p) < 2 { - return u, fmt.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) + return "", u, fmt.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u) } repoName := p[0] chartName := p[1] rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories) if err != nil { - return u, err + return "", u, err } // Now that we have the chart repository information we can use that URL @@ -233,7 +429,7 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er r, err := repo.NewChartRepository(rc, c.Getters) if err != nil { - return u, err + return "", u, err } if r != nil && r.Config != nil { @@ -252,32 +448,33 @@ func (c *ChartDownloader) ResolveChartVersion(ref, version string) (*url.URL, er idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name)) i, err := repo.LoadIndexFile(idxFile) if err != nil { - return u, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err) + return "", u, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err) } cv, err := i.Get(chartName, version) if err != nil { - return u, fmt.Errorf("chart %q matching %s not found in %s index. (try 'helm repo update'): %w", chartName, version, r.Config.Name, err) + return "", u, fmt.Errorf("chart %q matching %s not found in %s index. (try 'helm repo update'): %w", chartName, version, r.Config.Name, err) } if len(cv.URLs) == 0 { - return u, fmt.Errorf("chart %q has no downloadable URLs", ref) + return "", u, fmt.Errorf("chart %q has no downloadable URLs", ref) } // TODO: Seems that picking first URL is not fully correct resolvedURL, err := repo.ResolveReferenceURL(rc.URL, cv.URLs[0]) if err != nil { - return u, fmt.Errorf("invalid chart URL format: %s", ref) + return cv.Digest, u, fmt.Errorf("invalid chart URL format: %s", ref) } - return url.Parse(resolvedURL) + loc, err := url.Parse(resolvedURL) + return cv.Digest, loc, err } // VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. // // It assumes that a chart archive file is accompanied by a provenance file whose // name is the archive file name plus the ".prov" extension. -func VerifyChart(path, keyring string) (*provenance.Verification, error) { +func VerifyChart(path, provfile, keyring string) (*provenance.Verification, error) { // For now, error out if it's not a tar file. switch fi, err := os.Stat(path); { case err != nil: @@ -288,7 +485,6 @@ func VerifyChart(path, keyring string) (*provenance.Verification, error) { return nil, errors.New("chart must be a tgz file") } - provfile := path + ".prov" if _, err := os.Stat(provfile); err != nil { return nil, fmt.Errorf("could not load provenance file %s: %w", provfile, err) } @@ -297,7 +493,18 @@ func VerifyChart(path, keyring string) (*provenance.Verification, error) { if err != nil { return nil, fmt.Errorf("failed to load keyring: %w", err) } - return sig.Verify(path, provfile) + + // Read archive and provenance files + archiveData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read chart archive: %w", err) + } + provData, err := os.ReadFile(provfile) + if err != nil { + return nil, fmt.Errorf("failed to read provenance file: %w", err) + } + + return sig.Verify(archiveData, provData, filepath.Base(path)) } // isTar tests whether the given file is a tar file. diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go index a2e09eae5..649448fef 100644 --- a/pkg/downloader/chart_downloader_test.go +++ b/pkg/downloader/chart_downloader_test.go @@ -16,10 +16,14 @@ limitations under the License. package downloader import ( + "crypto/sha256" + "encoding/hex" "os" "path/filepath" "testing" + "github.com/stretchr/testify/require" + "helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" @@ -79,7 +83,7 @@ func TestResolveChartRef(t *testing.T) { } for _, tt := range tests { - u, err := c.ResolveChartVersion(tt.ref, tt.version) + _, u, err := c.ResolveChartVersion(tt.ref, tt.version) if err != nil { if tt.fail { continue @@ -131,7 +135,7 @@ func TestResolveChartOpts(t *testing.T) { continue } - u, err := c.ResolveChartVersion(tt.ref, tt.version) + _, u, err := c.ResolveChartVersion(tt.ref, tt.version) if err != nil { t.Errorf("%s: failed with error %s", tt.name, err) continue @@ -155,7 +159,7 @@ func TestResolveChartOpts(t *testing.T) { } func TestVerifyChart(t *testing.T) { - v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/helm-test-key.pub") + v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/signtest-0.1.0.tgz.prov", "testdata/helm-test-key.pub") if err != nil { t.Fatal(err) } @@ -198,15 +202,19 @@ func TestDownloadTo(t *testing.T) { t.Fatal(err) } + contentCache := t.TempDir() + c := ChartDownloader{ Out: os.Stderr, Verify: VerifyAlways, Keyring: "testdata/helm-test-key.pub", RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), Options: []getter.Option{ getter.WithBasicAuth("username", "password"), @@ -250,6 +258,7 @@ func TestDownloadTo_TLS(t *testing.T) { repoConfig := filepath.Join(srv.Root(), "repositories.yaml") repoCache := srv.Root() + contentCache := t.TempDir() c := ChartDownloader{ Out: os.Stderr, @@ -257,9 +266,11 @@ func TestDownloadTo_TLS(t *testing.T) { Keyring: "testdata/helm-test-key.pub", RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), Options: []getter.Option{ getter.WithTLSClientConfig( @@ -304,15 +315,18 @@ func TestDownloadTo_VerifyLater(t *testing.T) { if err := srv.LinkIndices(); err != nil { t.Fatal(err) } + contentCache := t.TempDir() c := ChartDownloader{ Out: os.Stderr, Verify: VerifyLater, RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, Getters: getter.All(&cli.EnvSettings{ RepositoryConfig: repoConfig, RepositoryCache: repoCache, + ContentCache: contentCache, }), } cname := "/signtest-0.1.0.tgz" @@ -366,3 +380,108 @@ func TestScanReposForURL(t *testing.T) { t.Fatalf("expected ErrNoOwnerRepo, got %v", err) } } + +func TestDownloadToCache(t *testing.T) { + srv := repotest.NewTempServer(t, + repotest.WithChartSourceGlob("testdata/*.tgz*"), + ) + defer srv.Stop() + if err := srv.CreateIndex(); err != nil { + t.Fatal(err) + } + if err := srv.LinkIndices(); err != nil { + t.Fatal(err) + } + + // The repo file needs to point to our server. + repoFile := filepath.Join(srv.Root(), "repositories.yaml") + repoCache := srv.Root() + contentCache := t.TempDir() + + c := ChartDownloader{ + Out: os.Stderr, + Verify: VerifyNever, + RepositoryConfig: repoFile, + RepositoryCache: repoCache, + Getters: getter.All(&cli.EnvSettings{ + RepositoryConfig: repoFile, + RepositoryCache: repoCache, + ContentCache: contentCache, + }), + Cache: &DiskCache{Root: contentCache}, + } + + // Case 1: Chart not in cache, download it. + t.Run("download and cache chart", func(t *testing.T) { + // Clear cache for this test + os.RemoveAll(contentCache) + os.MkdirAll(contentCache, 0755) + c.Cache = &DiskCache{Root: contentCache} + + pth, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + + // Check that the file exists at the returned path + _, err = os.Stat(pth) + require.NoError(t, err, "chart should exist at returned path") + + // Check that it's in the cache + digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0") + require.NoError(t, err) + digestBytes, err := hex.DecodeString(digest) + require.NoError(t, err) + var digestArray [sha256.Size]byte + copy(digestArray[:], digestBytes) + + cachePath, err := c.Cache.Get(digestArray, CacheChart) + require.NoError(t, err, "chart should now be in cache") + require.Equal(t, pth, cachePath) + }) + + // Case 2: Chart is in cache, get from cache. + t.Run("get chart from cache", func(t *testing.T) { + // The cache should be populated from the previous test. + // To prove it's coming from cache, we can stop the server. + // But repotest doesn't support restarting. + // Let's just call it again and assume it works if it's fast and doesn't error. + pth, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + + _, err = os.Stat(pth) + require.NoError(t, err, "chart should exist at returned path") + }) + + // Case 3: Download with verification + t.Run("download and verify", func(t *testing.T) { + // Clear cache + os.RemoveAll(contentCache) + os.MkdirAll(contentCache, 0755) + c.Cache = &DiskCache{Root: contentCache} + c.Verify = VerifyAlways + c.Keyring = "testdata/helm-test-key.pub" + + _, v, err := c.DownloadToCache("test/signtest", "0.1.0") + require.NoError(t, err) + require.NotNil(t, v) + require.NotEmpty(t, v.FileHash, "verification should have a file hash") + + // Check that both chart and prov are in cache + digest, _, err := c.ResolveChartVersion("test/signtest", "0.1.0") + require.NoError(t, err) + digestBytes, err := hex.DecodeString(digest) + require.NoError(t, err) + var digestArray [sha256.Size]byte + copy(digestArray[:], digestBytes) + + _, err = c.Cache.Get(digestArray, CacheChart) + require.NoError(t, err, "chart should be in cache") + _, err = c.Cache.Get(digestArray, CacheProv) + require.NoError(t, err, "provenance file should be in cache") + + // Reset for other tests + c.Verify = VerifyNever + c.Keyring = "" + }) +} diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index b43165975..8b77a77c0 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -75,6 +75,9 @@ type Manager struct { RegistryClient *registry.Client RepositoryConfig string RepositoryCache string + + // ContentCache is a location where a cache of charts can be stored + ContentCache string } // Build rebuilds a local charts directory from a lockfile. @@ -331,6 +334,7 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { Keyring: m.Keyring, RepositoryConfig: m.RepositoryConfig, RepositoryCache: m.RepositoryCache, + ContentCache: m.ContentCache, RegistryClient: m.RegistryClient, Getters: m.Getters, Options: []getter.Option{ diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go index f01a5d7ad..b7121a4ce 100644 --- a/pkg/downloader/manager_test.go +++ b/pkg/downloader/manager_test.go @@ -488,12 +488,14 @@ func checkBuildWithOptionalFields(t *testing.T, chartName string, dep chart.Depe Schemes: []string{"http", "https"}, New: getter.NewHTTPGetter, }} + contentCache := t.TempDir() m := &Manager{ ChartPath: dir(chartName), Out: b, Getters: g, RepositoryConfig: dir("repositories.yaml"), RepositoryCache: dir(), + ContentCache: contentCache, } // First build will update dependencies and create Chart.lock file. diff --git a/pkg/getter/getter.go b/pkg/getter/getter.go index 5605e043f..a2d0f0ee2 100644 --- a/pkg/getter/getter.go +++ b/pkg/getter/getter.go @@ -27,10 +27,11 @@ import ( "helm.sh/helm/v4/pkg/registry" ) -// options are generic parameters to be provided to the getter during instantiation. +// getterOptions are generic parameters to be provided to the getter during instantiation. // // Getters may or may not ignore these parameters as they are passed in. -type options struct { +// TODO what is the difference between this and schema.GetterOptionsV1? +type getterOptions struct { url string certFile string keyFile string @@ -47,58 +48,59 @@ type options struct { registryClient *registry.Client timeout time.Duration transport *http.Transport + artifactType string } // Option allows specifying various settings configurable by the user for overriding the defaults // used when performing Get operations with the Getter. -type Option func(*options) +type Option func(*getterOptions) // WithURL informs the getter the server name that will be used when fetching objects. Used in conjunction with // WithTLSClientConfig to set the TLSClientConfig's server name. func WithURL(url string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.url = url } } // WithAcceptHeader sets the request's Accept header as some REST APIs serve multiple content types func WithAcceptHeader(header string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.acceptHeader = header } } // WithBasicAuth sets the request's Authorization header to use the provided credentials func WithBasicAuth(username, password string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.username = username opts.password = password } } func WithPassCredentialsAll(pass bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.passCredentialsAll = pass } } // WithUserAgent sets the request's User-Agent header to use the provided agent name. func WithUserAgent(userAgent string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.userAgent = userAgent } } // WithInsecureSkipVerifyTLS determines if a TLS Certificate will be checked func WithInsecureSkipVerifyTLS(insecureSkipVerifyTLS bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.insecureSkipVerifyTLS = insecureSkipVerifyTLS } } // WithTLSClientConfig sets the client auth with the provided credentials. func WithTLSClientConfig(certFile, keyFile, caFile string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.certFile = certFile opts.keyFile = keyFile opts.caFile = caFile @@ -106,43 +108,50 @@ func WithTLSClientConfig(certFile, keyFile, caFile string) Option { } func WithPlainHTTP(plainHTTP bool) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.plainHTTP = plainHTTP } } // WithTimeout sets the timeout for requests func WithTimeout(timeout time.Duration) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.timeout = timeout } } func WithTagName(tagname string) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.version = tagname } } func WithRegistryClient(client *registry.Client) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.registryClient = client } } func WithUntar() Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.unTar = true } } // WithTransport sets the http.Transport to allow overwriting the HTTPGetter default. func WithTransport(transport *http.Transport) Option { - return func(opts *options) { + return func(opts *getterOptions) { opts.transport = transport } } +// WithArtifactType sets the type of OCI artifact ("chart" or "plugin") +func WithArtifactType(artifactType string) Option { + return func(opts *getterOptions) { + opts.artifactType = artifactType + } +} + // Getter is an interface to support GET to the specified URL. type Getter interface { // Get file content by url string @@ -217,7 +226,7 @@ func Getters(extraOpts ...Option) Providers { // notations are collected. func All(settings *cli.EnvSettings, opts ...Option) Providers { result := Getters(opts...) - pluginDownloaders, _ := collectPlugins(settings) + pluginDownloaders, _ := collectGetterPlugins(settings) result = append(result, pluginDownloaders...) return result } diff --git a/pkg/getter/httpgetter.go b/pkg/getter/httpgetter.go index 4cf528797..110f45c54 100644 --- a/pkg/getter/httpgetter.go +++ b/pkg/getter/httpgetter.go @@ -30,7 +30,7 @@ import ( // HTTPGetter is the default HTTP(/S) backend handler type HTTPGetter struct { - opts options + opts getterOptions transport *http.Transport once sync.Once } diff --git a/pkg/getter/httpgetter_test.go b/pkg/getter/httpgetter_test.go index a997c7f03..f87d71877 100644 --- a/pkg/getter/httpgetter_test.go +++ b/pkg/getter/httpgetter_test.go @@ -50,7 +50,7 @@ func TestHTTPGetter(t *testing.T) { timeout := time.Second * 5 transport := &http.Transport{} - // Test with options + // Test with getterOptions g, err = NewHTTPGetter( WithBasicAuth("I", "Am"), WithPassCredentialsAll(false), diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go index 7e8bcfcfb..24fc60c56 100644 --- a/pkg/getter/ocigetter.go +++ b/pkg/getter/ocigetter.go @@ -33,7 +33,7 @@ import ( // OCIGetter is the default HTTP(/S) backend handler type OCIGetter struct { - opts options + opts getterOptions transport *http.Transport once sync.Once } @@ -63,6 +63,12 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) { if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") { ref = fmt.Sprintf("%s:%s", ref, version) } + // Check if this is a plugin request + if g.opts.artifactType == "plugin" { + return g.getPlugin(client, ref) + } + + // Default to chart behavior for backward compatibility var pullOpts []registry.PullOption requestingProv := strings.HasSuffix(ref, ".prov") if requestingProv { @@ -166,3 +172,42 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) { return client, nil } + +// getPlugin handles plugin-specific OCI pulls +func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) { + // Check if this is a provenance file request + requestingProv := strings.HasSuffix(ref, ".prov") + if requestingProv { + ref = strings.TrimSuffix(ref, ".prov") + } + + // Extract plugin name from the reference + // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name" + parts := strings.Split(ref, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid OCI reference: %s", ref) + } + lastPart := parts[len(parts)-1] + pluginName := lastPart + if idx := strings.LastIndex(lastPart, ":"); idx > 0 { + pluginName = lastPart[:idx] + } + if idx := strings.LastIndex(lastPart, "@"); idx > 0 { + pluginName = lastPart[:idx] + } + + var pullOpts []registry.PluginPullOption + if requestingProv { + pullOpts = append(pullOpts, registry.PullPluginOptWithProv(true)) + } + + result, err := client.PullPlugin(ref, pluginName, pullOpts...) + if err != nil { + return nil, err + } + + if requestingProv { + return bytes.NewBuffer(result.Prov.Data), nil + } + return bytes.NewBuffer(result.PluginData), nil +} diff --git a/pkg/getter/ocigetter_test.go b/pkg/getter/ocigetter_test.go index e3d9278a5..ef196afcc 100644 --- a/pkg/getter/ocigetter_test.go +++ b/pkg/getter/ocigetter_test.go @@ -42,7 +42,7 @@ func TestOCIGetter(t *testing.T) { insecureSkipVerifyTLS := false plainHTTP := false - // Test with options + // Test with getterOptions g, err = NewOCIGetter( WithBasicAuth("I", "Am"), WithTLSClientConfig(pub, priv, ca), diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index 3b8185543..b2dfb3e42 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -17,92 +17,109 @@ package getter import ( "bytes" + "context" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "net/url" + + "helm.sh/helm/v4/internal/plugin" + + "helm.sh/helm/v4/internal/plugin/schema" "helm.sh/helm/v4/pkg/cli" - "helm.sh/helm/v4/pkg/plugin" ) -// collectPlugins scans for getter plugins. +// collectGetterPlugins scans for getter plugins. // This will load plugins according to the cli. -func collectPlugins(settings *cli.EnvSettings) (Providers, error) { - plugins, err := plugin.FindPlugins(settings.PluginsDirectory) +func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) { + d := plugin.Descriptor{ + Type: "getter/v1", + } + plgs, err := plugin.FindPlugins([]string{settings.PluginsDirectory}, d) if err != nil { return nil, err } - var result Providers - for _, plugin := range plugins { - for _, downloader := range plugin.Metadata.Downloaders { - result = append(result, Provider{ - Schemes: downloader.Protocols, - New: NewPluginGetter( - downloader.Command, - settings, - plugin.Metadata.Name, - plugin.Dir, - ), + pluginConstructorBuilder := func(plg plugin.Plugin) Constructor { + return func(option ...Option) (Getter, error) { + + return &getterPlugin{ + options: append([]Option{}, option...), + plg: plg, + }, nil + } + } + results := make([]Provider, 0, len(plgs)) + for _, plg := range plgs { + if c, ok := plg.Metadata().Config.(*plugin.ConfigGetter); ok { + results = append(results, Provider{ + Schemes: c.Protocols, + New: pluginConstructorBuilder(plg), }) } } - return result, nil + return results, nil } -// pluginGetter is a generic type to invoke custom downloaders, -// implemented in plugins. -type pluginGetter struct { - command string - settings *cli.EnvSettings - name string - base string - opts options +func convertOptions(globalOptions, options []Option) schema.GetterOptionsV1 { + opts := getterOptions{} + for _, opt := range globalOptions { + opt(&opts) + } + for _, opt := range options { + opt(&opts) + } + + result := schema.GetterOptionsV1{ + URL: opts.url, + CertFile: opts.certFile, + KeyFile: opts.keyFile, + CAFile: opts.caFile, + UNTar: opts.unTar, + InsecureSkipVerifyTLS: opts.insecureSkipVerifyTLS, + PlainHTTP: opts.plainHTTP, + AcceptHeader: opts.acceptHeader, + Username: opts.username, + Password: opts.password, + PassCredentialsAll: opts.passCredentialsAll, + UserAgent: opts.userAgent, + Version: opts.version, + Timeout: opts.timeout, + } + + return result } -func (p *pluginGetter) setupOptionsEnv(env []string) []string { - env = append(env, fmt.Sprintf("HELM_PLUGIN_USERNAME=%s", p.opts.username)) - env = append(env, fmt.Sprintf("HELM_PLUGIN_PASSWORD=%s", p.opts.password)) - env = append(env, fmt.Sprintf("HELM_PLUGIN_PASS_CREDENTIALS_ALL=%t", p.opts.passCredentialsAll)) - return env +type getterPlugin struct { + options []Option + plg plugin.Plugin } -// Get runs downloader plugin command -func (p *pluginGetter) Get(href string, options ...Option) (*bytes.Buffer, error) { - for _, opt := range options { - opt(&p.opts) - } - commands := strings.Split(p.command, " ") - argv := append(commands[1:], p.opts.certFile, p.opts.keyFile, p.opts.caFile, href) - prog := exec.Command(filepath.Join(p.base, commands[0]), argv...) - plugin.SetupPluginEnv(p.settings, p.name, p.base) - prog.Env = p.setupOptionsEnv(os.Environ()) - buf := bytes.NewBuffer(nil) - prog.Stdout = buf - prog.Stderr = os.Stderr - if err := prog.Run(); err != nil { - if eerr, ok := err.(*exec.ExitError); ok { - os.Stderr.Write(eerr.Stderr) - return nil, fmt.Errorf("plugin %q exited with error", p.command) - } +func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error) { + opts := convertOptions(g.options, options) + + // TODO optimization: pass this along to Get() instead of re-parsing here + u, err := url.Parse(href) + if err != nil { return nil, err } - return buf, nil -} -// NewPluginGetter constructs a valid plugin getter -func NewPluginGetter(command string, settings *cli.EnvSettings, name, base string) Constructor { - return func(options ...Option) (Getter, error) { - result := &pluginGetter{ - command: command, - settings: settings, - name: name, - base: base, - } - for _, opt := range options { - opt(&result.opts) - } - return result, nil + input := &plugin.Input{ + Message: schema.InputMessageGetterV1{ + Href: href, + Options: opts, + Protocol: u.Scheme, + }, + // TODO should we pass Stdin, Stdout, and Stderr through Input here to getter plugins? + //Stdout: os.Stdout, + } + output, err := g.plg.Invoke(context.Background(), input) + if err != nil { + return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err) } + + outputMessage, ok := output.Message.(schema.OutputMessageGetterV1) + if !ok { + return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name) + } + + return bytes.NewBuffer(outputMessage.Data), nil } diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 310ab9e07..8e0619635 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -16,9 +16,16 @@ limitations under the License. package getter import ( - "runtime" - "strings" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/plugin/schema" "helm.sh/helm/v4/pkg/cli" ) @@ -27,7 +34,7 @@ func TestCollectPlugins(t *testing.T) { env := cli.New() env.PluginsDirectory = pluginDir - p, err := collectPlugins(env) + p, err := collectGetterPlugins(env) if err != nil { t.Fatal(err) } @@ -49,53 +56,91 @@ func TestCollectPlugins(t *testing.T) { } } -func TestPluginGetter(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("TODO: refactor this test to work on windows") +func TestConvertOptions(t *testing.T) { + opts := convertOptions( + []Option{ + WithURL("example://foo"), + WithAcceptHeader("Accept-Header"), + WithBasicAuth("username", "password"), + WithPassCredentialsAll(true), + WithUserAgent("User-agent"), + WithInsecureSkipVerifyTLS(true), + WithTLSClientConfig("certFile.pem", "keyFile.pem", "caFile.pem"), + WithPlainHTTP(true), + WithTimeout(10), + WithTagName("1.2.3"), + WithUntar(), + }, + []Option{ + WithTimeout(20), + }, + ) + + expected := schema.GetterOptionsV1{ + URL: "example://foo", + CertFile: "certFile.pem", + KeyFile: "keyFile.pem", + CAFile: "caFile.pem", + UNTar: true, + Timeout: 20, + InsecureSkipVerifyTLS: true, + PlainHTTP: true, + AcceptHeader: "Accept-Header", + Username: "username", + Password: "password", + PassCredentialsAll: true, + UserAgent: "User-agent", + Version: "1.2.3", } + assert.Equal(t, expected, opts) +} - env := cli.New() - env.PluginsDirectory = pluginDir - pg := NewPluginGetter("echo", env, "test", ".") - g, err := pg() - if err != nil { - t.Fatal(err) - } +type testPlugin struct { + t *testing.T + dir string +} - data, err := g.Get("test://foo/bar") - if err != nil { - t.Fatal(err) - } +func (t *testPlugin) Dir() string { + return t.dir +} - expect := "test://foo/bar" - got := strings.TrimSpace(data.String()) - if got != expect { - t.Errorf("Expected %q, got %q", expect, got) +func (t *testPlugin) Metadata() plugin.Metadata { + return plugin.Metadata{ + Name: "fake-plugin", + Type: "cli/v1", + APIVersion: "v1", + Runtime: "subprocess", + Config: &plugin.ConfigCLI{}, + RuntimeConfig: &plugin.RuntimeConfigSubprocess{ + PlatformCommands: []plugin.PlatformCommand{ + { + Command: "echo fake-plugin", + }, + }, + }, } } -func TestPluginSubCommands(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("TODO: refactor this test to work on windows") +func (t *testPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) { + // Simulate a plugin invocation + output := &plugin.Output{ + Message: schema.OutputMessageGetterV1{ + Data: []byte("fake-plugin output"), + }, } + return output, nil +} - env := cli.New() - env.PluginsDirectory = pluginDir +var _ plugin.Plugin = (*testPlugin)(nil) - pg := NewPluginGetter("echo -n", env, "test", ".") - g, err := pg() - if err != nil { - t.Fatal(err) +func TestGetterPlugin(t *testing.T) { + gp := getterPlugin{ + options: []Option{}, + plg: &testPlugin{t: t, dir: "fake/dir"}, } - data, err := g.Get("test://foo/bar") - if err != nil { - t.Fatal(err) - } + buf, err := gp.Get("test://example.com", WithTimeout(5*time.Second)) + require.NoError(t, err) - expect := " test://foo/bar" - got := data.String() - if got != expect { - t.Errorf("Expected %q, got %q", expect, got) - } + assert.Equal(t, "fake-plugin output", buf.String()) } diff --git a/pkg/getter/testdata/plugins/testgetter/get.sh b/pkg/getter/testdata/plugins/testgetter/get.sh deleted file mode 100755 index cdd992369..000000000 --- a/pkg/getter/testdata/plugins/testgetter/get.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo ENVIRONMENT -env - -echo "" -echo ARGUMENTS -echo $@ diff --git a/pkg/getter/testdata/plugins/testgetter/plugin.yaml b/pkg/getter/testdata/plugins/testgetter/plugin.yaml index d1b929e3f..ca11b95ea 100644 --- a/pkg/getter/testdata/plugins/testgetter/plugin.yaml +++ b/pkg/getter/testdata/plugins/testgetter/plugin.yaml @@ -1,15 +1,13 @@ name: "testgetter" version: "0.1.0" -usage: "Fetch a package from a test:// source" -description: |- - Print the environment that the plugin was given, then exit. - - This registers the test:// protocol. - -command: "$HELM_PLUGIN_DIR/get.sh" -ignoreFlags: true -downloaders: -#- command: "$HELM_PLUGIN_DIR/get.sh" -- command: "echo" +type: getter/v1 +apiVersion: v1 +runtime: subprocess +config: protocols: - "test" +runtimeConfig: + protocolCommands: + - command: "echo" + protocols: + - "test" diff --git a/pkg/getter/testdata/plugins/testgetter2/get.sh b/pkg/getter/testdata/plugins/testgetter2/get.sh deleted file mode 100755 index cdd992369..000000000 --- a/pkg/getter/testdata/plugins/testgetter2/get.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo ENVIRONMENT -env - -echo "" -echo ARGUMENTS -echo $@ diff --git a/pkg/getter/testdata/plugins/testgetter2/plugin.yaml b/pkg/getter/testdata/plugins/testgetter2/plugin.yaml index f1a527ef9..1c944a7c7 100644 --- a/pkg/getter/testdata/plugins/testgetter2/plugin.yaml +++ b/pkg/getter/testdata/plugins/testgetter2/plugin.yaml @@ -1,10 +1,13 @@ name: "testgetter2" version: "0.1.0" -usage: "Fetch a different package from a test2:// source" -description: "Handle test2 scheme" -command: "$HELM_PLUGIN_DIR/get.sh" -ignoreFlags: true -downloaders: -- command: "echo" +type: getter/v1 +apiVersion: v1 +runtime: subprocess +config: protocols: - "test2" +runtimeConfig: + protocolCommands: + - command: "echo" + protocols: + - "test2" diff --git a/pkg/kube/client.go b/pkg/kube/client.go index 78ed4e088..c41165490 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -24,6 +24,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "os" "path/filepath" "reflect" @@ -46,12 +47,14 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/jsonmergepatch" "k8s.io/apimachinery/pkg/util/mergepatch" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/resource" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + "k8s.io/client-go/util/csaupgrade" "k8s.io/client-go/util/retry" cmdutil "k8s.io/kubectl/pkg/cmd/util" ) @@ -91,6 +94,14 @@ const ( HookOnlyStrategy WaitStrategy = "hookOnly" ) +type FieldValidationDirective string + +const ( + FieldValidationDirectiveIgnore FieldValidationDirective = "Ignore" + FieldValidationDirectiveWarn FieldValidationDirective = "Warn" + FieldValidationDirectiveStrict FieldValidationDirective = "Strict" +) + func init() { // Add CRDs to the scheme. They are missing by default. if err := apiextv1.AddToScheme(scheme.Scheme); err != nil { @@ -194,10 +205,102 @@ func (c *Client) IsReachable() error { return nil } +type clientCreateOptions struct { + serverSideApply bool + forceConflicts bool + dryRun bool + fieldValidationDirective FieldValidationDirective +} + +type ClientCreateOption func(*clientCreateOptions) error + +// ClientUpdateOptionServerSideApply enables performing object apply server-side +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ +// +// `forceConflicts` forces conflicts to be resolved (may be when serverSideApply enabled only) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts +func ClientCreateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientCreateOption { + return func(o *clientCreateOptions) error { + if !serverSideApply && forceConflicts { + return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + } + + o.serverSideApply = serverSideApply + o.forceConflicts = forceConflicts + + return nil + } +} + +// ClientCreateOptionDryRun requests the server to perform non-mutating operations only +func ClientCreateOptionDryRun(dryRun bool) ClientCreateOption { + return func(o *clientCreateOptions) error { + o.dryRun = dryRun + + return nil + } +} + +// ClientCreateOptionFieldValidationDirective specifies show API operations validate object's schema +// - For client-side apply: this is ignored +// - For server-side apply: the directive is sent to the server to perform the validation +// +// Defaults to `FieldValidationDirectiveStrict` +func ClientCreateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientCreateOption { + return func(o *clientCreateOptions) error { + o.fieldValidationDirective = fieldValidationDirective + + return nil + } +} + // Create creates Kubernetes resources specified in the resource list. -func (c *Client) Create(resources ResourceList) (*Result, error) { +func (c *Client) Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) { slog.Debug("creating resource(s)", "resources", len(resources)) - if err := perform(resources, createResource); err != nil { + + createOptions := clientCreateOptions{ + serverSideApply: true, // Default to server-side apply + fieldValidationDirective: FieldValidationDirectiveStrict, + } + + errs := make([]error, 0, len(options)) + for _, o := range options { + errs = append(errs, o(&createOptions)) + } + if err := errors.Join(errs...); err != nil { + return nil, fmt.Errorf("invalid client create option(s): %w", err) + } + + if createOptions.forceConflicts && !createOptions.serverSideApply { + return nil, fmt.Errorf("invalid operation: force conflicts can only be used with server-side apply") + } + + makeCreateApplyFunc := func() func(target *resource.Info) error { + if createOptions.serverSideApply { + slog.Debug("using server-side apply for resource creation", slog.Bool("forceConflicts", createOptions.forceConflicts), slog.Bool("dryRun", createOptions.dryRun), slog.String("fieldValidationDirective", string(createOptions.fieldValidationDirective))) + return func(target *resource.Info) error { + err := patchResourceServerSide(target, createOptions.dryRun, createOptions.forceConflicts, createOptions.fieldValidationDirective) + + logger := slog.With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String())) + if err != nil { + logger.Debug("Error patching resource", slog.Any("error", err)) + return err + } + + logger.Debug("Patched resource") + + return nil + } + } + + slog.Debug("using client-side apply for resource creation") + return createResource + } + + if err := perform(resources, makeCreateApplyFunc()); err != nil { return nil, err } return &Result{Created: resources}, nil @@ -348,96 +451,98 @@ func (c *Client) namespace() string { return v1.NamespaceDefault } -// newBuilder returns a new resource builder for structured api objects. -func (c *Client) newBuilder() *resource.Builder { - return c.Factory.NewBuilder(). - ContinueOnError(). - NamespaceParam(c.namespace()). - DefaultNamespace(). - Flatten() -} - -// Build validates for Kubernetes objects and returns unstructured infos. -func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) { - validationDirective := metav1.FieldValidationIgnore +func determineFieldValidationDirective(validate bool) FieldValidationDirective { if validate { - validationDirective = metav1.FieldValidationStrict + return FieldValidationDirectiveStrict } - schema, err := c.Factory.Validator(validationDirective) + return FieldValidationDirectiveIgnore +} + +func buildResourceList(f Factory, namespace string, validationDirective FieldValidationDirective, reader io.Reader, transformRequest resource.RequestTransform) (ResourceList, error) { + + schema, err := f.Validator(string(validationDirective)) if err != nil { return nil, err } - result, err := c.newBuilder(). + + builder := f.NewBuilder(). + ContinueOnError(). + NamespaceParam(namespace). + DefaultNamespace(). + Flatten(). Unstructured(). Schema(schema). - Stream(reader, ""). - Do().Infos() + Stream(reader, "") + if transformRequest != nil { + builder.TransformRequests(transformRequest) + } + result, err := builder.Do().Infos() return result, scrubValidationError(err) } +// Build validates for Kubernetes objects and returns unstructured infos. +func (c *Client) Build(reader io.Reader, validate bool) (ResourceList, error) { + return buildResourceList( + c.Factory, + c.namespace(), + determineFieldValidationDirective(validate), + reader, + nil) +} + // BuildTable validates for Kubernetes objects and returns unstructured infos. // The returned kind is a Table. func (c *Client) BuildTable(reader io.Reader, validate bool) (ResourceList, error) { - validationDirective := metav1.FieldValidationIgnore - if validate { - validationDirective = metav1.FieldValidationStrict - } - - schema, err := c.Factory.Validator(validationDirective) - if err != nil { - return nil, err - } - result, err := c.newBuilder(). - Unstructured(). - Schema(schema). - Stream(reader, ""). - TransformRequests(transformRequests). - Do().Infos() - return result, scrubValidationError(err) + return buildResourceList( + c.Factory, + c.namespace(), + determineFieldValidationDirective(validate), + reader, + transformRequests) } -func (c *Client) update(original, target ResourceList, force, threeWayMerge bool) (*Result, error) { +func (c *Client) update(originals, targets ResourceList, updateApplyFunc UpdateApplyFunc) (*Result, error) { updateErrors := []error{} res := &Result{} - slog.Debug("checking resources for changes", "resources", len(target)) - err := target.Visit(func(info *resource.Info, err error) error { + slog.Debug("checking resources for changes", "resources", len(targets)) + err := targets.Visit(func(target *resource.Info, err error) error { if err != nil { return err } - helper := resource.NewHelper(info.Client, info.Mapping).WithFieldManager(getManagedFieldsManager()) - if _, err := helper.Get(info.Namespace, info.Name); err != nil { + helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) + if _, err := helper.Get(target.Namespace, target.Name); err != nil { if !apierrors.IsNotFound(err) { return fmt.Errorf("could not get information about the resource: %w", err) } // Append the created resource to the results, even if something fails - res.Created = append(res.Created, info) + res.Created = append(res.Created, target) // Since the resource does not exist, create it. - if err := createResource(info); err != nil { + if err := createResource(target); err != nil { return fmt.Errorf("failed to create resource: %w", err) } - kind := info.Mapping.GroupVersionKind.Kind - slog.Debug("created a new resource", "namespace", info.Namespace, "name", info.Name, "kind", kind) + kind := target.Mapping.GroupVersionKind.Kind + slog.Debug("created a new resource", "namespace", target.Namespace, "name", target.Name, "kind", kind) return nil } - originalInfo := original.Get(info) - if originalInfo == nil { - kind := info.Mapping.GroupVersionKind.Kind - return fmt.Errorf("no %s with the name %q found", kind, info.Name) + original := originals.Get(target) + if original == nil { + kind := target.Mapping.GroupVersionKind.Kind + return fmt.Errorf("original object %s with the name %q not found", kind, target.Name) } - if err := updateResource(c, info, originalInfo.Object, force, threeWayMerge); err != nil { - slog.Debug("error updating the resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + if err := updateApplyFunc(original, target); err != nil { updateErrors = append(updateErrors, err) } + // Because we check for errors later, append the info regardless - res.Updated = append(res.Updated, info) + res.Updated = append(res.Updated, target) return nil }) @@ -449,7 +554,7 @@ func (c *Client) update(original, target ResourceList, force, threeWayMerge bool return res, joinErrors(updateErrors, " && ") } - for _, info := range original.Difference(target) { + for _, info := range originals.Difference(targets) { slog.Debug("deleting resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind) if err := info.Get(); err != nil { @@ -473,20 +578,99 @@ func (c *Client) update(original, target ResourceList, force, threeWayMerge bool return res, nil } -// Update takes the current list of objects and target list of objects and -// creates resources that don't already exist, updates resources that have been -// modified in the target configuration, and deletes resources from the current -// configuration that are not present in the target configuration. If an error -// occurs, a Result will still be returned with the error, containing all -// resource updates, creations, and deletions that were attempted. These can be -// used for cleanup or other logging purposes. +type clientUpdateOptions struct { + threeWayMergeForUnstructured bool + serverSideApply bool + forceReplace bool + forceConflicts bool + dryRun bool + fieldValidationDirective FieldValidationDirective + upgradeClientSideFieldManager bool +} + +type ClientUpdateOption func(*clientUpdateOptions) error + +// ClientUpdateOptionThreeWayMergeForUnstructured enables performing three-way merge for unstructured objects +// Must not be enabled when ClientUpdateOptionServerSideApply is enabled +func ClientUpdateOptionThreeWayMergeForUnstructured(threeWayMergeForUnstructured bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.threeWayMergeForUnstructured = threeWayMergeForUnstructured + + return nil + } +} + +// ClientUpdateOptionServerSideApply enables performing object apply server-side (default) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ +// Must not be enabled when ClientUpdateOptionThreeWayMerge is enabled // -// The difference to Update is that UpdateThreeWayMerge does a three-way-merge -// for unstructured objects. -func (c *Client) UpdateThreeWayMerge(original, target ResourceList, force bool) (*Result, error) { - return c.update(original, target, force, true) +// `forceConflicts` forces conflicts to be resolved (may be enabled when serverSideApply enabled only) +// see: https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts +func ClientUpdateOptionServerSideApply(serverSideApply, forceConflicts bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + if !serverSideApply && forceConflicts { + return fmt.Errorf("forceConflicts enabled when serverSideApply disabled") + } + + o.serverSideApply = serverSideApply + o.forceConflicts = forceConflicts + + return nil + } } +// ClientUpdateOptionForceReplace forces objects to be replaced rather than updated via patch +// Must not be enabled when ClientUpdateOptionForceConflicts is enabled +func ClientUpdateOptionForceReplace(forceReplace bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.forceReplace = forceReplace + + return nil + } +} + +// ClientUpdateOptionDryRun requests the server to perform non-mutating operations only +func ClientUpdateOptionDryRun(dryRun bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.dryRun = dryRun + + return nil + } +} + +// ClientUpdateOptionFieldValidationDirective specifies show API operations validate object's schema +// - For client-side apply: this is ignored +// - For server-side apply: the directive is sent to the server to perform the validation +// +// Defaults to `FieldValidationDirectiveStrict` +func ClientUpdateOptionFieldValidationDirective(fieldValidationDirective FieldValidationDirective) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.fieldValidationDirective = fieldValidationDirective + + return nil + } +} + +// ClientUpdateOptionUpgradeClientSideFieldManager specifies that resources client-side field manager should be upgraded to server-side apply +// (before applying the object server-side) +// This is required when upgrading a chart from client-side to server-side apply, otherwise the client-side field management remains. Conflicting with server-side applied updates. +// +// Note: +// if this option is specified, but the object is not managed by client-side field manager, it will be a no-op. However, the cost of fetching the objects will be incurred. +// +// see: +// - https://github.com/kubernetes/kubernetes/pull/112905 +// - `UpgradeManagedFields` / https://github.com/kubernetes/kubernetes/blob/f47e9696d7237f1011d23c9b55f6947e60526179/staging/src/k8s.io/client-go/util/csaupgrade/upgrade.go#L81 +func ClientUpdateOptionUpgradeClientSideFieldManager(upgradeClientSideFieldManager bool) ClientUpdateOption { + return func(o *clientUpdateOptions) error { + o.upgradeClientSideFieldManager = upgradeClientSideFieldManager + + return nil + } +} + +type UpdateApplyFunc func(original, target *resource.Info) error + // Update takes the current list of objects and target list of objects and // creates resources that don't already exist, updates resources that have been // modified in the target configuration, and deletes resources from the current @@ -494,8 +678,95 @@ func (c *Client) UpdateThreeWayMerge(original, target ResourceList, force bool) // occurs, a Result will still be returned with the error, containing all // resource updates, creations, and deletions that were attempted. These can be // used for cleanup or other logging purposes. -func (c *Client) Update(original, target ResourceList, force bool) (*Result, error) { - return c.update(original, target, force, false) +// +// The default is to use server-side apply, equivalent to: `ClientUpdateOptionServerSideApply(true)` +func (c *Client) Update(originals, targets ResourceList, options ...ClientUpdateOption) (*Result, error) { + updateOptions := clientUpdateOptions{ + serverSideApply: true, // Default to server-side apply + fieldValidationDirective: FieldValidationDirectiveStrict, + } + + errs := make([]error, 0, len(options)) + for _, o := range options { + errs = append(errs, o(&updateOptions)) + } + if err := errors.Join(errs...); err != nil { + return nil, fmt.Errorf("invalid client update option(s): %w", err) + } + + if updateOptions.threeWayMergeForUnstructured && updateOptions.serverSideApply { + return nil, fmt.Errorf("invalid operation: cannot use three-way merge for unstructured and server-side apply together") + } + + if updateOptions.forceConflicts && updateOptions.forceReplace { + return nil, fmt.Errorf("invalid operation: cannot use force conflicts and force replace together") + } + + if updateOptions.serverSideApply && updateOptions.forceReplace { + return nil, fmt.Errorf("invalid operation: cannot use server-side apply and force replace together") + } + + makeUpdateApplyFunc := func() UpdateApplyFunc { + if updateOptions.forceReplace { + slog.Debug( + "using resource replace update strategy", + slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective))) + return func(original, target *resource.Info) error { + if err := replaceResource(target, updateOptions.fieldValidationDirective); err != nil { + slog.Debug("error replacing the resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + return err + } + + originalObject := original.Object + kind := target.Mapping.GroupVersionKind.Kind + slog.Debug("replace succeeded", "name", original.Name, "initialKind", originalObject.GetObjectKind().GroupVersionKind().Kind, "kind", kind) + + return nil + } + } else if updateOptions.serverSideApply { + slog.Debug( + "using server-side apply for resource update", + slog.Bool("forceConflicts", updateOptions.forceConflicts), + slog.Bool("dryRun", updateOptions.dryRun), + slog.String("fieldValidationDirective", string(updateOptions.fieldValidationDirective)), + slog.Bool("upgradeClientSideFieldManager", updateOptions.upgradeClientSideFieldManager)) + return func(original, target *resource.Info) error { + + logger := slog.With( + slog.String("namespace", target.Namespace), + slog.String("name", target.Name), + slog.String("gvk", target.Mapping.GroupVersionKind.String())) + + if updateOptions.upgradeClientSideFieldManager { + patched, err := upgradeClientSideFieldManager(original, updateOptions.dryRun, updateOptions.fieldValidationDirective) + if err != nil { + slog.Debug("Error patching resource to replace CSA field management", slog.Any("error", err)) + return err + } + + if patched { + logger.Debug("Upgraded object client-side field management with server-side apply field management") + } + } + + if err := patchResourceServerSide(target, updateOptions.dryRun, updateOptions.forceConflicts, updateOptions.fieldValidationDirective); err != nil { + logger.Debug("Error patching resource", slog.Any("error", err)) + return err + } + + logger.Debug("Patched resource") + + return nil + } + } + + slog.Debug("using client-side apply for resource update", slog.Bool("threeWayMergeForUnstructured", updateOptions.threeWayMergeForUnstructured)) + return func(original, target *resource.Info) error { + return patchResourceClientSide(original.Object, target, updateOptions.threeWayMergeForUnstructured) + } + } + + return c.update(originals, targets, makeUpdateApplyFunc()) } // Delete deletes Kubernetes resources specified in the resources list with @@ -503,7 +774,7 @@ func (c *Client) Update(original, target ResourceList, force bool) (*Result, err // if one or more fail and collect any errors. All successfully deleted items // will be returned in the `Deleted` ResourceList that is part of the result. func (c *Client) Delete(resources ResourceList) (*Result, []error) { - return rdelete(c, resources, metav1.DeletePropagationBackground) + return deleteResources(resources, metav1.DeletePropagationBackground) } // Delete deletes Kubernetes resources specified in the resources list with @@ -511,23 +782,23 @@ func (c *Client) Delete(resources ResourceList) (*Result, []error) { // if one or more fail and collect any errors. All successfully deleted items // will be returned in the `Deleted` ResourceList that is part of the result. func (c *Client) DeleteWithPropagationPolicy(resources ResourceList, policy metav1.DeletionPropagation) (*Result, []error) { - return rdelete(c, resources, policy) + return deleteResources(resources, policy) } -func rdelete(_ *Client, resources ResourceList, propagation metav1.DeletionPropagation) (*Result, []error) { +func deleteResources(resources ResourceList, propagation metav1.DeletionPropagation) (*Result, []error) { var errs []error res := &Result{} mtx := sync.Mutex{} - err := perform(resources, func(info *resource.Info) error { - slog.Debug("starting delete resource", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind) - err := deleteResource(info, propagation) + err := perform(resources, func(target *resource.Info) error { + slog.Debug("starting delete resource", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind) + err := deleteResource(target, propagation) if err == nil || apierrors.IsNotFound(err) { if err != nil { - slog.Debug("ignoring delete failure", "namespace", info.Namespace, "name", info.Name, "kind", info.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) + slog.Debug("ignoring delete failure", "namespace", target.Namespace, "name", target.Name, "kind", target.Mapping.GroupVersionKind.Kind, slog.Any("error", err)) } mtx.Lock() defer mtx.Unlock() - res.Deleted = append(res.Deleted, info) + res.Deleted = append(res.Deleted, target) return nil } mtx.Lock() @@ -548,6 +819,17 @@ func rdelete(_ *Client, resources ResourceList, propagation metav1.DeletionPropa return res, nil } +// https://github.com/kubernetes/kubectl/blob/197123726db24c61aa0f78d1f0ba6e91a2ec2f35/pkg/cmd/apply/apply.go#L439 +func isIncompatibleServerError(err error) bool { + // 415: Unsupported media type means we're talking to a server which doesn't + // support server-side apply. + if _, ok := err.(*apierrors.StatusError); !ok { + // Non-StatusError means the error isn't because the server is incompatible. + return false + } + return err.(*apierrors.StatusError).Status().Code == http.StatusUnsupportedMediaType +} + // getManagedFieldsManager returns the manager string. If one was set it will be returned. // Otherwise, one is calculated based on the name of the binary. func getManagedFieldsManager() string { @@ -568,18 +850,41 @@ func getManagedFieldsManager() string { return filepath.Base(os.Args[0]) } +func perform(infos ResourceList, fn func(*resource.Info) error) error { + var result error + + if len(infos) == 0 { + return ErrNoObjectsVisited + } + + errs := make(chan error) + go batchPerform(infos, fn, errs) + + for range infos { + err := <-errs + if err != nil { + result = errors.Join(result, err) + } + } + + return result +} + func batchPerform(infos ResourceList, fn func(*resource.Info) error, errs chan<- error) { var kind string var wg sync.WaitGroup + defer wg.Wait() + for _, info := range infos { currentKind := info.Object.GetObjectKind().GroupVersionKind().Kind if kind != currentKind { wg.Wait() kind = currentKind } + wg.Add(1) - go func(i *resource.Info) { - errs <- fn(i) + go func(info *resource.Info) { + errs <- fn(info) wg.Done() }(info) } @@ -597,6 +902,7 @@ func createResource(info *resource.Info) error { if err != nil { return err } + return info.Refresh(obj, true) }) } @@ -611,8 +917,8 @@ func deleteResource(info *resource.Info, policy metav1.DeletionPropagation) erro }) } -func createPatch(target *resource.Info, current runtime.Object, threeWayMergeForUnstructured bool) ([]byte, types.PatchType, error) { - oldData, err := json.Marshal(current) +func createPatch(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) ([]byte, types.PatchType, error) { + oldData, err := json.Marshal(original) if err != nil { return nil, types.StrategicMergePatchType, fmt.Errorf("serializing current configuration: %w", err) } @@ -674,46 +980,150 @@ func createPatch(target *resource.Info, current runtime.Object, threeWayMergeFor return patch, types.StrategicMergePatchType, err } -func updateResource(_ *Client, target *resource.Info, currentObj runtime.Object, force, threeWayMergeForUnstructured bool) error { - var ( - obj runtime.Object - helper = resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) - kind = target.Mapping.GroupVersionKind.Kind - ) +func replaceResource(target *resource.Info, fieldValidationDirective FieldValidationDirective) error { - // if --force is applied, attempt to replace the existing resource with the new object. - if force { - var err error - obj, err = helper.Replace(target.Namespace, target.Name, true, target.Object) - if err != nil { - return fmt.Errorf("failed to replace object: %w", err) - } - slog.Debug("replace succeeded", "name", target.Name, "initialKind", currentObj.GetObjectKind().GroupVersionKind().Kind, "kind", kind) - } else { - patch, patchType, err := createPatch(target, currentObj, threeWayMergeForUnstructured) - if err != nil { - return fmt.Errorf("failed to create patch: %w", err) + helper := resource.NewHelper(target.Client, target.Mapping). + WithFieldValidation(string(fieldValidationDirective)). + WithFieldManager(getManagedFieldsManager()) + + obj, err := helper.Replace(target.Namespace, target.Name, true, target.Object) + if err != nil { + return fmt.Errorf("failed to replace object: %w", err) + } + + if err := target.Refresh(obj, true); err != nil { + return fmt.Errorf("failed to refresh object after replace: %w", err) + } + + return nil + +} + +func patchResourceClientSide(original runtime.Object, target *resource.Info, threeWayMergeForUnstructured bool) error { + + patch, patchType, err := createPatch(original, target, threeWayMergeForUnstructured) + if err != nil { + return fmt.Errorf("failed to create patch: %w", err) + } + + kind := target.Mapping.GroupVersionKind.Kind + if patch == nil || string(patch) == "{}" { + slog.Debug("no changes detected", "kind", kind, "name", target.Name) + // This needs to happen to make sure that Helm has the latest info from the API + // Otherwise there will be no labels and other functions that use labels will panic + if err := target.Get(); err != nil { + return fmt.Errorf("failed to refresh resource information: %w", err) } + return nil + } + + // send patch to server + slog.Debug("patching resource", "kind", kind, "name", target.Name, "namespace", target.Namespace) + helper := resource.NewHelper(target.Client, target.Mapping).WithFieldManager(getManagedFieldsManager()) + obj, err := helper.Patch(target.Namespace, target.Name, patchType, patch, nil) + if err != nil { + return fmt.Errorf("cannot patch %q with kind %s: %w", target.Name, kind, err) + } + + target.Refresh(obj, true) + + return nil +} + +// upgradeClientSideFieldManager is simply a wrapper around csaupgrade.UpgradeManagedFields +// that ugrade CSA managed fields to SSA apply +// see: https://github.com/kubernetes/kubernetes/pull/112905 +func upgradeClientSideFieldManager(info *resource.Info, dryRun bool, fieldValidationDirective FieldValidationDirective) (bool, error) { - if patch == nil || string(patch) == "{}" { - slog.Debug("no changes detected", "kind", kind, "name", target.Name) - // This needs to happen to make sure that Helm has the latest info from the API - // Otherwise there will be no labels and other functions that use labels will panic - if err := target.Get(); err != nil { - return fmt.Errorf("failed to refresh resource information: %w", err) + fieldManagerName := getManagedFieldsManager() + + patched := false + err := retry.RetryOnConflict( + retry.DefaultRetry, + func() error { + + if err := info.Get(); err != nil { + return fmt.Errorf("failed to get object %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) } - return nil + + helper := resource.NewHelper( + info.Client, + info.Mapping). + DryRun(dryRun). + WithFieldManager(fieldManagerName). + WithFieldValidation(string(fieldValidationDirective)) + + patchData, err := csaupgrade.UpgradeManagedFieldsPatch( + info.Object, + sets.New(fieldManagerName), + fieldManagerName) + if err != nil { + return fmt.Errorf("failed to upgrade managed fields for object %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) + } + + if len(patchData) == 0 { + return nil + } + + obj, err := helper.Patch( + info.Namespace, + info.Name, + types.JSONPatchType, + patchData, + nil) + + if err == nil { + patched = true + return info.Refresh(obj, true) + } + + if !apierrors.IsConflict(err) { + return fmt.Errorf("failed to patch object to upgrade CSA field manager %s/%s %s: %w", info.Namespace, info.Name, info.Mapping.GroupVersionKind.String(), err) + } + + return err + }) + + return patched, err +} + +// Patch reource using server-side apply +func patchResourceServerSide(target *resource.Info, dryRun bool, forceConflicts bool, fieldValidationDirective FieldValidationDirective) error { + helper := resource.NewHelper( + target.Client, + target.Mapping). + DryRun(dryRun). + WithFieldManager(getManagedFieldsManager()). + WithFieldValidation(string(fieldValidationDirective)) + + // Send the full object to be applied on the server side. + data, err := runtime.Encode(unstructured.UnstructuredJSONScheme, target.Object) + if err != nil { + return fmt.Errorf("failed to encode object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) + } + options := metav1.PatchOptions{ + Force: &forceConflicts, + } + obj, err := helper.Patch( + target.Namespace, + target.Name, + types.ApplyPatchType, + data, + &options, + ) + if err != nil { + if isIncompatibleServerError(err) { + return fmt.Errorf("server-side apply not available on the server: %v", err) } - // send patch to server - slog.Debug("patching resource", "kind", kind, "name", target.Name, "namespace", target.Namespace) - obj, err = helper.Patch(target.Namespace, target.Name, patchType, patch, nil) - if err != nil { - return fmt.Errorf("cannot patch %q with kind %s: %w", target.Name, kind, err) + + if apierrors.IsConflict(err) { + return fmt.Errorf("conflict occurred while applying object %s/%s %s: %w", target.Namespace, target.Name, target.Mapping.GroupVersionKind.String(), err) } + + return err } - target.Refresh(obj, true) - return nil + return target.Refresh(obj, true) } // GetPodList uses the kubernetes interface to get the list of pods filtered by listOptions diff --git a/pkg/kube/client_test.go b/pkg/kube/client_test.go index 5ffa0972b..a8a8668c7 100644 --- a/pkg/kube/client_test.go +++ b/pkg/kube/client_test.go @@ -19,15 +19,19 @@ package kube import ( "bytes" "errors" + "fmt" "io" "net/http" "strings" + "sync" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -117,210 +121,209 @@ func newTestClient(t *testing.T) *Client { t.Cleanup(testFactory.Cleanup) return &Client{ - Factory: testFactory.WithNamespace("default"), + Factory: testFactory.WithNamespace(v1.NamespaceDefault), } } -func TestCreate(t *testing.T) { - // Note: c.Create with the fake client can currently only test creation of a single pod in the same list. When testing - // with more than one pod, c.Create will run into a data race as it calls perform->batchPerform which performs creation - // in batches. The first data race is on accessing var actions and can be fixed easily with a mutex lock in the Client - // function. The second data race though is something in the fake client itself in func (c *RESTClient) do(...) - // when it stores the req: c.Req = req and cannot (?) be fixed easily. - listA := newPodList("starfish") - listB := newPodList("dolphin") +type RequestResponseAction struct { + Request http.Request + Response http.Response + Error error +} - var actions []string - var iterationCounter int +type RoundTripperTestFunc func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) - c := newTestClient(t) - c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ - NegotiatedSerializer: unstructuredSerializer, - Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { - path, method := req.URL.Path, req.Method - bodyReader := new(strings.Builder) - _, _ = io.Copy(bodyReader, req.Body) - body := bodyReader.String() - actions = append(actions, path+":"+method) - t.Logf("got request %s %s", path, method) - switch { - case path == "/namespaces/default/pods" && method == http.MethodPost: - if strings.Contains(body, "starfish") { - if iterationCounter < 2 { - iterationCounter++ - return newResponseJSON(http.StatusConflict, resourceQuotaConflict) - } - return newResponse(http.StatusOK, &listA.Items[0]) - } - return newResponseJSON(http.StatusConflict, resourceQuotaConflict) - default: - t.Fatalf("unexpected request: %s %s", method, path) - return nil, nil - } - }), +func NewRequestResponseLogClient(t *testing.T, cb RoundTripperTestFunc) RequestResponseLogClient { + t.Helper() + return RequestResponseLogClient{ + t: t, + cb: cb, } +} - t.Run("Create success", func(t *testing.T) { - list, err := c.Build(objBody(&listA), false) - if err != nil { - t.Fatal(err) - } +// RequestResponseLogClient is a test client that logs requests and responses +// Satifying http.RoundTripper interface, it can be used to mock HTTP requests in tests. +// Forwarding requests to a callback function (cb) that can be used to simulate server responses. +type RequestResponseLogClient struct { + t *testing.T + cb RoundTripperTestFunc + actionsLock sync.Mutex + Actions []RequestResponseAction +} - result, err := c.Create(list) - if err != nil { - t.Fatal(err) - } +func (r *RequestResponseLogClient) Do(req *http.Request) (*http.Response, error) { + t := r.t + t.Helper() - if len(result.Created) != 1 { - t.Errorf("expected 1 resource created, got %d", len(result.Created)) + readBodyBytes := func(body io.ReadCloser) []byte { + if body == nil { + return []byte{} } - expectedActions := []string{ - "/namespaces/default/pods:POST", - "/namespaces/default/pods:POST", - "/namespaces/default/pods:POST", - } - if len(expectedActions) != len(actions) { - t.Fatalf("unexpected number of requests, expected %d, got %d", len(expectedActions), len(actions)) - } - for k, v := range expectedActions { - if actions[k] != v { - t.Errorf("expected %s request got %s", v, actions[k]) - } - } - }) + defer body.Close() + bodyBytes, err := io.ReadAll(body) + require.NoError(t, err) - t.Run("Create failure", func(t *testing.T) { - list, err := c.Build(objBody(&listB), false) - if err != nil { - t.Fatal(err) - } + return bodyBytes + } - _, err = c.Create(list) - if err == nil { - t.Errorf("expected error") - } + reqBytes := readBodyBytes(req.Body) - expectedString := "Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; " + - "please apply your changes to the latest version and try again" - if !strings.Contains(err.Error(), expectedString) { - t.Errorf("Unexpected error message: %q", err) - } + t.Logf("Request: %s %s %s", req.Method, req.URL.String(), reqBytes) + if req.Body != nil { + req.Body = io.NopCloser(bytes.NewReader(reqBytes)) + } - expectedActions := []string{ - "/namespaces/default/pods:POST", - } - for k, v := range actions { - if expectedActions[0] != v { - t.Errorf("expected %s request got %s", v, actions[k]) - } - } + resp, err := r.cb(r.Actions, req) + + respBytes := readBodyBytes(resp.Body) + t.Logf("Response: %d %s", resp.StatusCode, string(respBytes)) + if resp.Body != nil { + resp.Body = io.NopCloser(bytes.NewReader(respBytes)) + } + + r.actionsLock.Lock() + defer r.actionsLock.Unlock() + r.Actions = append(r.Actions, RequestResponseAction{ + Request: *req, + Response: *resp, + Error: err, }) + + return resp, err } -func testUpdate(t *testing.T, threeWayMerge bool) { - t.Helper() - listA := newPodList("starfish", "otter", "squid") - listB := newPodList("starfish", "otter", "dolphin") - listC := newPodList("starfish", "otter", "dolphin") - listB.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} - listC.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} +func TestCreate(t *testing.T) { + // Note: c.Create with the fake client can currently only test creation of a single pod/object in the same list. When testing + // with more than one pod, c.Create will run into a data race as it calls perform->batchPerform which performs creation + // in batches. The race is something in the fake client itself in `func (c *RESTClient) do(...)` + // when it stores the req: c.Req = req and cannot (?) be fixed easily. - var actions []string - var iterationCounter int + type testCase struct { + Name string + Pods v1.PodList + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ServerSideApply bool + ExpectedActions []string + ExpectedErrorContains string + } - c := newTestClient(t) - c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ - NegotiatedSerializer: unstructuredSerializer, - Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { - p, m := req.URL.Path, req.Method - actions = append(actions, p+":"+m) - t.Logf("got request %s %s", p, m) - switch { - case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: - return newResponse(http.StatusOK, &listA.Items[0]) - case p == "/namespaces/default/pods/otter" && m == http.MethodGet: - return newResponse(http.StatusOK, &listA.Items[1]) - case p == "/namespaces/default/pods/otter" && m == http.MethodPatch: - data, err := io.ReadAll(req.Body) - if err != nil { - t.Fatalf("could not dump request: %s", err) - } - req.Body.Close() - expected := `{}` - if string(data) != expected { - t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data)) - } - return newResponse(http.StatusOK, &listB.Items[0]) - case p == "/namespaces/default/pods/dolphin" && m == http.MethodGet: - return newResponse(http.StatusNotFound, notFoundBody()) - case p == "/namespaces/default/pods/starfish" && m == http.MethodPatch: - data, err := io.ReadAll(req.Body) - if err != nil { - t.Fatalf("could not dump request: %s", err) - } - req.Body.Close() - expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}` - if string(data) != expected { - t.Errorf("expected patch\n%s\ngot\n%s", expected, string(data)) - } - return newResponse(http.StatusOK, &listB.Items[0]) - case p == "/namespaces/default/pods" && m == http.MethodPost: - if iterationCounter < 2 { - iterationCounter++ + testCases := map[string]testCase{ + "Create success (client-side apply)": { + Pods: newPodList("starfish"), + ServerSideApply: false, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + if len(previous) < 2 { // simulate a conflict return newResponseJSON(http.StatusConflict, resourceQuotaConflict) } - return newResponse(http.StatusOK, &listB.Items[1]) - case p == "/namespaces/default/pods/squid" && m == http.MethodDelete: - return newResponse(http.StatusOK, &listB.Items[1]) - case p == "/namespaces/default/pods/squid" && m == http.MethodGet: - return newResponse(http.StatusOK, &listB.Items[2]) - default: - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.Path) - return nil, nil - } - }), - } - first, err := c.Build(objBody(&listA), false) - if err != nil { - t.Fatal(err) - } - second, err := c.Build(objBody(&listB), false) - if err != nil { - t.Fatal(err) - } - var result *Result - if threeWayMerge { - result, err = c.UpdateThreeWayMerge(first, second, false) - } else { - result, err = c.Update(first, second, false) - } - if err != nil { - t.Fatal(err) - } + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + ExpectedActions: []string{ + "/namespaces/default/pods:POST", + "/namespaces/default/pods:POST", + "/namespaces/default/pods:POST", + }, + }, + "Create success (server-side apply)": { + Pods: newPodList("whale"), + ServerSideApply: true, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() - if len(result.Created) != 1 { - t.Errorf("expected 1 resource created, got %d", len(result.Created)) + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + ExpectedActions: []string{ + "/namespaces/default/pods/whale:PATCH", + }, + }, + "Create fail: incompatible server (server-side apply)": { + Pods: newPodList("lobster"), + ServerSideApply: true, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusUnsupportedMediaType, + Request: req, + }, nil + }, + ExpectedErrorContains: "server-side apply not available on the server:", + ExpectedActions: []string{ + "/namespaces/default/pods/lobster:PATCH", + }, + }, + "Create fail: quota (server-side apply)": { + Pods: newPodList("dolphin"), + ServerSideApply: true, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, _ *http.Request) (*http.Response, error) { + t.Helper() + + return newResponseJSON(http.StatusConflict, resourceQuotaConflict) + }, + ExpectedErrorContains: "Operation cannot be fulfilled on resourcequotas \"quota\": the object has been modified; " + + "please apply your changes to the latest version and try again", + ExpectedActions: []string{ + "/namespaces/default/pods/dolphin:PATCH", + }, + }, } - if len(result.Updated) != 2 { - t.Errorf("expected 2 resource updated, got %d", len(result.Updated)) + + c := newTestClient(t) + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + list, err := c.Build(objBody(&tc.Pods), false) + require.NoError(t, err) + if err != nil { + t.Fatal(err) + } + + result, err := c.Create( + list, + ClientCreateOptionServerSideApply(tc.ServerSideApply, false)) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + + // See note above about limitations in supporting more than a single object + assert.Len(t, result.Created, 1, "expected 1 object created, got %d", len(result.Created)) + } + + actions := []string{} + for _, action := range client.Actions { + path, method := action.Request.URL.Path, action.Request.Method + actions = append(actions, path+":"+method) + } + + assert.Equal(t, tc.ExpectedActions, actions) + + }) } - if len(result.Deleted) != 1 { - t.Errorf("expected 1 resource deleted, got %d", len(result.Deleted)) +} + +func TestUpdate(t *testing.T) { + type testCase struct { + OriginalPods v1.PodList + TargetPods v1.PodList + ThreeWayMergeForUnstructured bool + ServerSideApply bool + ExpectedActions []string } - // TODO: Find a way to test methods that use Client Set - // Test with a wait - // if err := c.Update("test", objBody(codec, &listB), objBody(codec, &listC), false, 300, true); err != nil { - // t.Fatal(err) - // } - // Test with a wait should fail - // TODO: A way to make this not based off of an extremely short timeout? - // if err := c.Update("test", objBody(codec, &listC), objBody(codec, &listA), false, 2, true); err != nil { - // t.Fatal(err) - // } - expectedActions := []string{ + expectedActionsClientSideApply := []string{ "/namespaces/default/pods/starfish:GET", "/namespaces/default/pods/starfish:GET", "/namespaces/default/pods/starfish:PATCH", @@ -334,22 +337,155 @@ func testUpdate(t *testing.T, threeWayMerge bool) { "/namespaces/default/pods/squid:GET", "/namespaces/default/pods/squid:DELETE", } - if len(expectedActions) != len(actions) { - t.Fatalf("unexpected number of requests, expected %d, got %d", len(expectedActions), len(actions)) + + expectedActionsServerSideApply := []string{ + "/namespaces/default/pods/starfish:GET", + "/namespaces/default/pods/starfish:GET", + "/namespaces/default/pods/starfish:PATCH", + "/namespaces/default/pods/otter:GET", + "/namespaces/default/pods/otter:GET", + "/namespaces/default/pods/otter:PATCH", + "/namespaces/default/pods/dolphin:GET", + "/namespaces/default/pods:POST", // create dolphin + "/namespaces/default/pods:POST", // retry due to 409 + "/namespaces/default/pods:POST", // retry due to 409 + "/namespaces/default/pods/squid:GET", + "/namespaces/default/pods/squid:DELETE", } - for k, v := range expectedActions { - if actions[k] != v { - t.Errorf("expected %s request got %s", v, actions[k]) - } + + testCases := map[string]testCase{ + "client-side apply": { + OriginalPods: newPodList("starfish", "otter", "squid"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: false, + ExpectedActions: expectedActionsClientSideApply, + }, + "client-side apply (three-way merge for unstructured)": { + OriginalPods: newPodList("starfish", "otter", "squid"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: true, + ServerSideApply: false, + ExpectedActions: expectedActionsClientSideApply, + }, + "serverSideApply": { + OriginalPods: newPodList("starfish", "otter", "squid"), + TargetPods: func() v1.PodList { + listTarget := newPodList("starfish", "otter", "dolphin") + listTarget.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return listTarget + }(), + ThreeWayMergeForUnstructured: false, + ServerSideApply: true, + ExpectedActions: expectedActionsServerSideApply, + }, } -} -func TestUpdate(t *testing.T) { - testUpdate(t, false) -} + c := newTestClient(t) + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + listOriginal := tc.OriginalPods + listTarget := tc.TargetPods + + iterationCounter := 0 + cb := func(_ []RequestResponseAction, req *http.Request) (*http.Response, error) { + p, m := req.URL.Path, req.Method + + switch { + case p == "/namespaces/default/pods/starfish" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[0]) + case p == "/namespaces/default/pods/otter" && m == http.MethodGet: + return newResponse(http.StatusOK, &listOriginal.Items[1]) + case p == "/namespaces/default/pods/otter" && m == http.MethodPatch: + if !tc.ServerSideApply { + defer req.Body.Close() + data, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.Equal(t, `{}`, string(data)) + } + + return newResponse(http.StatusOK, &listTarget.Items[0]) + case p == "/namespaces/default/pods/dolphin" && m == http.MethodGet: + return newResponse(http.StatusNotFound, notFoundBody()) + case p == "/namespaces/default/pods/starfish" && m == http.MethodPatch: + if !tc.ServerSideApply { + // Ensure client-side apply specifies correct patch + defer req.Body.Close() + data, err := io.ReadAll(req.Body) + require.NoError(t, err) + + expected := `{"spec":{"$setElementOrder/containers":[{"name":"app:v4"}],"containers":[{"$setElementOrder/ports":[{"containerPort":443}],"name":"app:v4","ports":[{"containerPort":443,"name":"https"},{"$patch":"delete","containerPort":80}]}]}}` + assert.Equal(t, expected, string(data)) + } + + return newResponse(http.StatusOK, &listTarget.Items[0]) + case p == "/namespaces/default/pods" && m == http.MethodPost: + if iterationCounter < 2 { + iterationCounter++ + return newResponseJSON(http.StatusConflict, resourceQuotaConflict) + } + + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/squid" && m == http.MethodDelete: + return newResponse(http.StatusOK, &listTarget.Items[1]) + case p == "/namespaces/default/pods/squid" && m == http.MethodGet: + return newResponse(http.StatusOK, &listTarget.Items[2]) + default: + } + + t.Fail() + return nil, nil + } + + client := NewRequestResponseLogClient(t, cb) -func TestUpdateThreeWayMerge(t *testing.T) { - testUpdate(t, true) + c.Factory.(*cmdtesting.TestFactory).UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + first, err := c.Build(objBody(&listOriginal), false) + require.NoError(t, err) + + second, err := c.Build(objBody(&listTarget), false) + require.NoError(t, err) + + result, err := c.Update( + first, + second, + ClientUpdateOptionThreeWayMergeForUnstructured(tc.ThreeWayMergeForUnstructured), + ClientUpdateOptionForceReplace(false), + ClientUpdateOptionServerSideApply(tc.ServerSideApply, false), + ClientUpdateOptionUpgradeClientSideFieldManager(true)) + require.NoError(t, err) + + assert.Len(t, result.Created, 1, "expected 1 resource created, got %d", len(result.Created)) + assert.Len(t, result.Updated, 2, "expected 2 resource updated, got %d", len(result.Updated)) + assert.Len(t, result.Deleted, 1, "expected 1 resource deleted, got %d", len(result.Deleted)) + + actions := []string{} + for _, action := range client.Actions { + path, method := action.Request.URL.Path, action.Request.Method + actions = append(actions, path+":"+method) + } + + assert.Equal(t, tc.ExpectedActions, actions) + }) + } } func TestBuild(t *testing.T) { @@ -548,7 +684,11 @@ func TestWait(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := c.Create(resources) + + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + if err != nil { t.Fatal(err) } @@ -605,7 +745,10 @@ func TestWaitJob(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := c.Create(resources) + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) + if err != nil { t.Fatal(err) } @@ -664,7 +807,9 @@ func TestWaitDelete(t *testing.T) { if err != nil { t.Fatal(err) } - result, err := c.Create(resources) + result, err := c.Create( + resources, + ClientCreateOptionServerSideApply(false, false)) if err != nil { t.Fatal(err) } @@ -941,8 +1086,8 @@ type createPatchTestCase struct { // The target state. target *unstructured.Unstructured - // The current state as it exists in the release. - current *unstructured.Unstructured + // The state as it exists in the release. + original *unstructured.Unstructured // The actual state as it exists in the cluster. actual *unstructured.Unstructured @@ -990,15 +1135,15 @@ func (c createPatchTestCase) run(t *testing.T) { }, } - patch, patchType, err := createPatch(targetInfo, c.current, c.threeWayMergeForUnstructured) + patch, patchType, err := createPatch(c.original, targetInfo, c.threeWayMergeForUnstructured) if err != nil { t.Fatalf("Failed to create patch: %v", err) } if c.expectedPatch != string(patch) { - t.Errorf("Unexpected patch.\nTarget:\n%s\nCurrent:\n%s\nActual:\n%s\n\nExpected:\n%s\nGot:\n%s", + t.Errorf("Unexpected patch.\nTarget:\n%s\nOriginal:\n%s\nActual:\n%s\n\nExpected:\n%s\nGot:\n%s", c.target, - c.current, + c.original, c.actual, c.expectedPatch, string(patch), @@ -1040,9 +1185,9 @@ func TestCreatePatchCustomResourceMetadata(t *testing.T) { "objectset.rio.cattle.io/id": "default-foo-simple", }, nil) testCase := createPatchTestCase{ - name: "take ownership of resource", - target: target, - current: target, + name: "take ownership of resource", + target: target, + original: target, actual: newTestCustomResourceData(nil, map[string]interface{}{ "color": "red", }), @@ -1064,9 +1209,9 @@ func TestCreatePatchCustomResourceSpec(t *testing.T) { "size": "large", }) testCase := createPatchTestCase{ - name: "merge with spec of existing custom resource", - target: target, - current: target, + name: "merge with spec of existing custom resource", + target: target, + original: target, actual: newTestCustomResourceData(nil, map[string]interface{}{ "color": "red", "weight": "heavy", @@ -1184,3 +1329,426 @@ func TestIsReachable(t *testing.T) { }) } } + +func TestIsIncompatibleServerError(t *testing.T) { + testCases := map[string]struct { + Err error + Want bool + }{ + "Unsupported media type": { + Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusUnsupportedMediaType}}, + Want: true, + }, + "Not found error": { + Err: &apierrors.StatusError{ErrStatus: metav1.Status{Code: http.StatusNotFound}}, + Want: false, + }, + "Generic error": { + Err: fmt.Errorf("some generic error"), + Want: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + if got := isIncompatibleServerError(tc.Err); got != tc.Want { + t.Errorf("isIncompatibleServerError() = %v, want %v", got, tc.Want) + } + }) + } +} + +func TestReplaceResource(t *testing.T) { + type testCase struct { + Pods v1.PodList + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + Pods: newPodList("whale"), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + case 1: + assert.Equal(t, "PUT", req.Method) + } + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "conflict": { + Pods: newPodList("whale"), + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + }, + ExpectedErrorContains: "failed to replace object: the server reported a conflict", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.Pods), nil) + require.NoError(t, err) + + require.Len(t, resourceList, 1) + info := resourceList[0] + + err = replaceResource(info, FieldValidationDirectiveStrict) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, info.Object) + } + }) + } +} + +func TestPatchResourceClientSide(t *testing.T) { + type testCase struct { + OriginalPods v1.PodList + TargetPods v1.PodList + ThreeWayMergeForUnstructured bool + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + ThreeWayMergeForUnstructured: false, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type")) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil + }, + }, + "three way merge for unstructured": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + ThreeWayMergeForUnstructured: true, + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + t.Logf("patcher: %+v", req.Header) + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/strategic-merge-patch+json", req.Header.Get("Content-Type")) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil + }, + }, + "conflict": { + OriginalPods: newPodList("whale"), + TargetPods: func() v1.PodList { + pods := newPodList("whale") + pods.Items[0].Spec.Containers[0].Ports = []v1.ContainerPort{{Name: "https", ContainerPort: 443}} + + return pods + }(), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "PATCH", req.Method) + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + } + + t.Fail() + return nil, nil + + }, + ExpectedErrorContains: "cannot patch \"whale\" with kind Pod: the server reported a conflict", + }, + "no patch": { + OriginalPods: newPodList("whale"), + TargetPods: newPodList("whale"), + Callback: func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + switch len(previous) { + case 0: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.OriginalPods.Items[0]) + case 1: + assert.Equal(t, "GET", req.Method) + return newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + } + + t.Fail() + return nil, nil // newResponse(http.StatusOK, &tc.TargetPods.Items[0]) + + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceListOriginal, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.OriginalPods), nil) + require.NoError(t, err) + require.Len(t, resourceListOriginal, 1) + + resourceListTarget, err := buildResourceList(testFactory, v1.NamespaceDefault, FieldValidationDirectiveStrict, objBody(&tc.TargetPods), nil) + require.NoError(t, err) + require.Len(t, resourceListTarget, 1) + + original := resourceListOriginal[0] + target := resourceListTarget[0] + + err = patchResourceClientSide(original.Object, target, tc.ThreeWayMergeForUnstructured) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, target.Object) + } + }) + } +} + +func TestPatchResourceServerSide(t *testing.T) { + type testCase struct { + Pods v1.PodList + DryRun bool + ForceConflicts bool + FieldValidationDirective FieldValidationDirective + Callback func(t *testing.T, tc testCase, previous []RequestResponseAction, req *http.Request) (*http.Response, error) + ExpectedErrorContains string + } + + testCases := map[string]testCase{ + "normal": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "dry run": { + Pods: newPodList("whale"), + DryRun: true, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "All", req.URL.Query().Get("dryRun")) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "force conflicts": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: true, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "true", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "dry run + force conflicts": { + Pods: newPodList("whale"), + DryRun: true, + ForceConflicts: true, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "All", req.URL.Query().Get("dryRun")) + assert.Equal(t, "true", req.URL.Query().Get("force")) + assert.Equal(t, "Strict", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "field validation ignore": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveIgnore, + Callback: func(t *testing.T, tc testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + assert.Equal(t, "PATCH", req.Method) + assert.Equal(t, "application/apply-patch+yaml", req.Header.Get("Content-Type")) + assert.Equal(t, "/namespaces/default/pods/whale", req.URL.Path) + assert.Equal(t, "false", req.URL.Query().Get("force")) + assert.Equal(t, "Ignore", req.URL.Query().Get("fieldValidation")) + + return newResponse(http.StatusOK, &tc.Pods.Items[0]) + }, + }, + "incompatible server": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusUnsupportedMediaType, + Request: req, + }, nil + }, + ExpectedErrorContains: "server-side apply not available on the server:", + }, + "conflict": { + Pods: newPodList("whale"), + DryRun: false, + ForceConflicts: false, + FieldValidationDirective: FieldValidationDirectiveStrict, + Callback: func(t *testing.T, _ testCase, _ []RequestResponseAction, req *http.Request) (*http.Response, error) { + t.Helper() + + return &http.Response{ + StatusCode: http.StatusConflict, + Request: req, + }, nil + }, + ExpectedErrorContains: "the server reported a conflict", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + + testFactory := cmdtesting.NewTestFactory() + t.Cleanup(testFactory.Cleanup) + + client := NewRequestResponseLogClient(t, func(previous []RequestResponseAction, req *http.Request) (*http.Response, error) { + return tc.Callback(t, tc, previous, req) + }) + + testFactory.UnstructuredClient = &fake.RESTClient{ + NegotiatedSerializer: unstructuredSerializer, + Client: fake.CreateHTTPClient(client.Do), + } + + resourceList, err := buildResourceList(testFactory, v1.NamespaceDefault, tc.FieldValidationDirective, objBody(&tc.Pods), nil) + require.NoError(t, err) + + require.Len(t, resourceList, 1) + info := resourceList[0] + + err = patchResourceServerSide(info, tc.DryRun, tc.ForceConflicts, tc.FieldValidationDirective) + if tc.ExpectedErrorContains != "" { + require.ErrorContains(t, err, tc.ExpectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, info.Object) + } + }) + } +} + +func TestDetermineFieldValidationDirective(t *testing.T) { + + assert.Equal(t, FieldValidationDirectiveIgnore, determineFieldValidationDirective(false)) + assert.Equal(t, FieldValidationDirectiveStrict, determineFieldValidationDirective(true)) +} diff --git a/pkg/kube/fake/fake.go b/pkg/kube/fake/fake.go index a543a0f73..588bba83d 100644 --- a/pkg/kube/fake/fake.go +++ b/pkg/kube/fake/fake.go @@ -60,11 +60,11 @@ type FailingKubeWaiter struct { } // Create returns the configured error if set or prints -func (f *FailingKubeClient) Create(resources kube.ResourceList) (*kube.Result, error) { +func (f *FailingKubeClient) Create(resources kube.ResourceList, options ...kube.ClientCreateOption) (*kube.Result, error) { if f.CreateError != nil { return nil, f.CreateError } - return f.PrintingKubeClient.Create(resources) + return f.PrintingKubeClient.Create(resources, options...) } // Get returns the configured error if set or prints @@ -117,19 +117,11 @@ func (f *FailingKubeWaiter) WatchUntilReady(resources kube.ResourceList, d time. } // Update returns the configured error if set or prints -func (f *FailingKubeClient) Update(r, modified kube.ResourceList, ignoreMe bool) (*kube.Result, error) { +func (f *FailingKubeClient) Update(r, modified kube.ResourceList, options ...kube.ClientUpdateOption) (*kube.Result, error) { if f.UpdateError != nil { return &kube.Result{}, f.UpdateError } - return f.PrintingKubeClient.Update(r, modified, ignoreMe) -} - -// Update returns the configured error if set or prints -func (f *FailingKubeClient) UpdateThreeWayMerge(r, modified kube.ResourceList, ignoreMe bool) (*kube.Result, error) { - if f.UpdateError != nil { - return &kube.Result{}, f.UpdateError - } - return f.PrintingKubeClient.Update(r, modified, ignoreMe) + return f.PrintingKubeClient.Update(r, modified, options...) } // Build returns the configured error if set or prints diff --git a/pkg/kube/fake/printer.go b/pkg/kube/fake/printer.go index f6659a904..16c93615a 100644 --- a/pkg/kube/fake/printer.go +++ b/pkg/kube/fake/printer.go @@ -49,7 +49,7 @@ func (p *PrintingKubeClient) IsReachable() error { } // Create prints the values of what would be created with a real KubeClient. -func (p *PrintingKubeClient) Create(resources kube.ResourceList) (*kube.Result, error) { +func (p *PrintingKubeClient) Create(resources kube.ResourceList, _ ...kube.ClientCreateOption) (*kube.Result, error) { _, err := io.Copy(p.Out, bufferize(resources)) if err != nil { return nil, err @@ -98,7 +98,7 @@ func (p *PrintingKubeClient) Delete(resources kube.ResourceList) (*kube.Result, } // Update implements KubeClient Update. -func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ bool) (*kube.Result, error) { +func (p *PrintingKubeClient) Update(_, modified kube.ResourceList, _ ...kube.ClientUpdateOption) (*kube.Result, error) { _, err := io.Copy(p.Out, bufferize(modified)) if err != nil { return nil, err diff --git a/pkg/kube/interface.go b/pkg/kube/interface.go index 6b945088e..7339ae0ff 100644 --- a/pkg/kube/interface.go +++ b/pkg/kube/interface.go @@ -30,14 +30,14 @@ import ( // A KubernetesClient must be concurrency safe. type Interface interface { // Create creates one or more resources. - Create(resources ResourceList) (*Result, error) + Create(resources ResourceList, options ...ClientCreateOption) (*Result, error) // Delete destroys one or more resources. Delete(resources ResourceList) (*Result, []error) // Update updates one or more resources or creates the resource // if it doesn't exist. - Update(original, target ResourceList, force bool) (*Result, error) + Update(original, target ResourceList, options ...ClientUpdateOption) (*Result, error) // Build creates a resource list from a Reader. // @@ -53,13 +53,6 @@ type Interface interface { GetWaiter(ws WaitStrategy) (Waiter, error) } -// InterfaceThreeWayMerge was introduced to avoid breaking backwards compatibility for Interface implementers. -// -// TODO Helm 4: Remove InterfaceThreeWayMerge and integrate its method(s) into the Interface. -type InterfaceThreeWayMerge interface { - UpdateThreeWayMerge(original, target ResourceList, force bool) (*Result, error) -} - // Waiter defines methods related to waiting for resource states. type Waiter interface { // Wait waits up to the given timeout for the specified resources to be ready. @@ -125,7 +118,6 @@ type InterfaceResources interface { } var _ Interface = (*Client)(nil) -var _ InterfaceThreeWayMerge = (*Client)(nil) var _ InterfaceLogs = (*Client)(nil) var _ InterfaceDeletionPropagation = (*Client)(nil) var _ InterfaceResources = (*Client)(nil) diff --git a/pkg/kube/wait.go b/pkg/kube/wait.go index 8a3bacdcc..9bfa1ef6d 100644 --- a/pkg/kube/wait.go +++ b/pkg/kube/wait.go @@ -18,7 +18,6 @@ package kube // import "helm.sh/helm/v4/pkg/kube" import ( "context" - "errors" "fmt" "log/slog" "net/http" @@ -223,26 +222,6 @@ func (hw *legacyWaiter) WatchUntilReady(resources ResourceList, timeout time.Dur return perform(resources, hw.watchTimeout(timeout)) } -func perform(infos ResourceList, fn func(*resource.Info) error) error { - var result error - - if len(infos) == 0 { - return ErrNoObjectsVisited - } - - errs := make(chan error) - go batchPerform(infos, fn, errs) - - for range infos { - err := <-errs - if err != nil { - result = errors.Join(result, err) - } - } - - return result -} - func (hw *legacyWaiter) watchUntilReady(timeout time.Duration, info *resource.Info) error { kind := info.Mapping.GroupVersionKind.Kind switch kind { diff --git a/pkg/lint/rules/chartfile.go b/pkg/lint/rules/chartfile.go index 724c3f2ea..103c28374 100644 --- a/pkg/lint/rules/chartfile.go +++ b/pkg/lint/rules/chartfile.go @@ -160,6 +160,9 @@ func validateChartVersion(cf *chart.Metadata) error { func validateChartMaintainer(cf *chart.Metadata) error { for _, maintainer := range cf.Maintainers { + if maintainer == nil { + return errors.New("a maintainer entry is empty") + } if maintainer.Name == "" { return errors.New("each maintainer requires a name") } else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) { diff --git a/pkg/lint/rules/chartfile_test.go b/pkg/lint/rules/chartfile_test.go index bbb14a5e8..1719a2011 100644 --- a/pkg/lint/rules/chartfile_test.go +++ b/pkg/lint/rules/chartfile_test.go @@ -142,6 +142,16 @@ func TestValidateChartMaintainer(t *testing.T) { t.Errorf("validateChartMaintainer(%s, %s) to return no error, got %s", test.Name, test.Email, err.Error()) } } + + // Testing for an empty maintainer + badChart.Maintainers = []*chart.Maintainer{nil} + err := validateChartMaintainer(badChart) + if err == nil { + t.Errorf("validateChartMaintainer did not return error for nil maintainer as expected") + } + if err.Error() != "a maintainer entry is empty" { + t.Errorf("validateChartMaintainer returned unexpected error for nil maintainer: %s", err.Error()) + } } func TestValidateChartSources(t *testing.T) { diff --git a/pkg/plugin/installer/local_installer.go b/pkg/plugin/installer/local_installer.go deleted file mode 100644 index 109f4f236..000000000 --- a/pkg/plugin/installer/local_installer.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright The Helm Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package installer // import "helm.sh/helm/v4/pkg/plugin/installer" - -import ( - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" -) - -// ErrPluginNotAFolder indicates that the plugin path is not a folder. -var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") - -// LocalInstaller installs plugins from the filesystem. -type LocalInstaller struct { - base -} - -// NewLocalInstaller creates a new LocalInstaller. -func NewLocalInstaller(source string) (*LocalInstaller, error) { - src, err := filepath.Abs(source) - if err != nil { - return nil, fmt.Errorf("unable to get absolute path to plugin: %w", err) - } - i := &LocalInstaller{ - base: newBase(src), - } - return i, nil -} - -// Install creates a symlink to the plugin directory. -// -// Implements Installer. -func (i *LocalInstaller) Install() error { - stat, err := os.Stat(i.Source) - if err != nil { - return err - } - if !stat.IsDir() { - return ErrPluginNotAFolder - } - - if !isPlugin(i.Source) { - return ErrMissingMetadata - } - slog.Debug("symlinking", "source", i.Source, "path", i.Path()) - return os.Symlink(i.Source, i.Path()) -} - -// Update updates a local repository -func (i *LocalInstaller) Update() error { - slog.Debug("local repository is auto-updated") - return nil -} diff --git a/pkg/plugin/installer/local_installer_test.go b/pkg/plugin/installer/local_installer_test.go deleted file mode 100644 index 9effcd2c4..000000000 --- a/pkg/plugin/installer/local_installer_test.go +++ /dev/null @@ -1,67 +0,0 @@ -/* -Copyright The Helm Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package installer // import "helm.sh/helm/v4/pkg/plugin/installer" - -import ( - "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" - i, err := NewForSource(source, "") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - if err := Install(i); err != nil { - t.Fatal(err) - } - - if i.Path() != helmpath.DataPath("plugins", "echo") { - t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/helm-env', got %q", i.Path()) - } - defer os.RemoveAll(filepath.Dir(helmpath.DataPath())) // helmpath.DataPath is like /tmp/helm013130971/helm -} - -func TestLocalInstallerNotAFolder(t *testing.T) { - source := "../testdata/plugdir/good/echo/plugin.yaml" - i, err := NewForSource(source, "") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - err = Install(i) - if err == nil { - t.Fatal("expected error") - } - if err != ErrPluginNotAFolder { - t.Fatalf("expected error to equal: %q", err) - } -} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go deleted file mode 100644 index 930bf3664..000000000 --- a/pkg/plugin/plugin.go +++ /dev/null @@ -1,370 +0,0 @@ -/* -Copyright The Helm Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package plugin // import "helm.sh/helm/v4/pkg/plugin" - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "runtime" - "strings" - "unicode" - - "sigs.k8s.io/yaml" - - "helm.sh/helm/v4/pkg/cli" -) - -const PluginFileName = "plugin.yaml" - -// Downloaders represents the plugins capability if it can retrieve -// charts from special sources -type Downloaders struct { - // Protocols are the list of schemes from the charts URL. - Protocols []string `json:"protocols"` - // Command is the executable path with which the plugin performs - // the actual download for the corresponding Protocols - Command string `json:"command"` -} - -// PlatformCommand represents a command for a particular operating system and architecture -type PlatformCommand struct { - OperatingSystem string `json:"os"` - Architecture string `json:"arch"` - Command string `json:"command"` - Args []string `json:"args"` -} - -// Metadata describes a plugin. -// -// This is the plugin equivalent of a chart.Metadata. -type Metadata struct { - // Name is the name of the plugin - Name string `json:"name"` - - // Version is a SemVer 2 version of the plugin. - Version string `json:"version"` - - // Usage is the single-line usage text shown in help - Usage string `json:"usage"` - - // Description is a long description shown in places like `helm help` - Description string `json:"description"` - - // PlatformCommand is the plugin command, with a platform selector and support for args. - // - // The command and args will be passed through environment expansion, so env vars can - // be present in this command. Unless IgnoreFlags is set, this will - // also merge the flags passed from Helm. - // - // Note that the command is not executed in a shell. To do so, we suggest - // pointing the command to a shell script. - // - // The following rules will apply to processing platform commands: - // - If PlatformCommand is present, it will be used - // - If both OS and Arch match the current platform, search will stop and the command will be executed - // - If OS matches and Arch is empty, the command will be executed - // - If no OS/Arch match is found, the default command will be executed - // - If no matches are found in platformCommand, Helm will exit with an error - PlatformCommand []PlatformCommand `json:"platformCommand"` - - // Command is the plugin command, as a single string. - // Providing a command will result in an error if PlatformCommand is also set. - // - // The command will be passed through environment expansion, so env vars can - // be present in this command. Unless IgnoreFlags is set, this will - // also merge the flags passed from Helm. - // - // Note that command is not executed in a shell. To do so, we suggest - // pointing the command to a shell script. - // - // DEPRECATED: Use PlatformCommand instead. Remove in Helm 4. - Command string `json:"command"` - - // IgnoreFlags ignores any flags passed in from Helm - // - // For example, if the plugin is invoked as `helm --debug myplugin`, if this - // is false, `--debug` will be appended to `--command`. If this is true, - // the `--debug` flag will be discarded. - IgnoreFlags bool `json:"ignoreFlags"` - - // PlatformHooks are commands that will run on plugin events, with a platform selector and support for args. - // - // The command and args will be passed through environment expansion, so env vars can - // be present in the command. - // - // Note that the command is not executed in a shell. To do so, we suggest - // pointing the command to a shell script. - // - // The following rules will apply to processing platform hooks: - // - If PlatformHooks is present, it will be used - // - If both OS and Arch match the current platform, search will stop and the command will be executed - // - If OS matches and Arch is empty, the command will be executed - // - If no OS/Arch match is found, the default command will be executed - // - If no matches are found in platformHooks, Helm will skip the event - PlatformHooks PlatformHooks `json:"platformHooks"` - - // Hooks are commands that will run on plugin events, as a single string. - // Providing a hooks will result in an error if PlatformHooks is also set. - // - // The command will be passed through environment expansion, so env vars can - // be present in this command. - // - // Note that the command is executed in the sh shell. - // - // DEPRECATED: Use PlatformHooks instead. Remove in Helm 4. - Hooks Hooks - - // Downloaders field is used if the plugin supply downloader mechanism - // for special protocols. - Downloaders []Downloaders `json:"downloaders"` -} - -// Plugin represents a plugin. -type Plugin struct { - // Metadata is a parsed representation of a plugin.yaml - Metadata *Metadata - // Dir is the string path to the directory that holds the plugin. - Dir string -} - -// 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 -} - -// PrepareCommand gets the correct command and arguments for a plugin. -// -// It merges extraArgs into any arguments supplied in the plugin. It returns the name of the command and an args array. -// -// The result is suitable to pass to exec.Command. -func (p *Plugin) PrepareCommand(extraArgs []string) (string, []string, error) { - var extraArgsIn []string - - if !p.Metadata.IgnoreFlags { - extraArgsIn = extraArgs - } - - cmds := p.Metadata.PlatformCommand - if len(cmds) == 0 && len(p.Metadata.Command) > 0 { - cmds = []PlatformCommand{{Command: p.Metadata.Command}} - } - - return PrepareCommands(cmds, true, extraArgsIn) -} - -// validPluginName is a regular expression that validates plugin names. -// -// Plugin names can only contain the ASCII characters a-z, A-Z, 0-9, ​_​ and ​-. -var validPluginName = regexp.MustCompile("^[A-Za-z0-9_-]+$") - -// validatePluginData validates a plugin's YAML data. -func validatePluginData(plug *Plugin, filepath string) error { - // When metadata section missing, initialize with no data - if plug.Metadata == nil { - plug.Metadata = &Metadata{} - } - if !validPluginName.MatchString(plug.Metadata.Name) { - return fmt.Errorf("invalid plugin name at %q", filepath) - } - plug.Metadata.Usage = sanitizeString(plug.Metadata.Usage) - - if len(plug.Metadata.PlatformCommand) > 0 && len(plug.Metadata.Command) > 0 { - return fmt.Errorf("both platformCommand and command are set in %q", filepath) - } - - if len(plug.Metadata.PlatformHooks) > 0 && len(plug.Metadata.Hooks) > 0 { - return fmt.Errorf("both platformHooks and hooks are set in %q", filepath) - } - - // We could also validate SemVer, executable, and other fields should we so choose. - return nil -} - -// sanitizeString normalize spaces and removes non-printable characters. -func sanitizeString(str string) string { - return strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return ' ' - } - if unicode.IsPrint(r) { - return r - } - return -1 - }, str) -} - -func detectDuplicates(plugs []*Plugin) error { - names := map[string]string{} - - for _, plug := range plugs { - if oldpath, ok := names[plug.Metadata.Name]; ok { - return fmt.Errorf( - "two plugins claim the name %q at %q and %q", - plug.Metadata.Name, - oldpath, - plug.Dir, - ) - } - names[plug.Metadata.Name] = plug.Dir - } - - return nil -} - -// LoadDir loads a plugin from the given directory. -func LoadDir(dirname string) (*Plugin, error) { - pluginfile := filepath.Join(dirname, PluginFileName) - data, err := os.ReadFile(pluginfile) - if err != nil { - return nil, fmt.Errorf("failed to read plugin at %q: %w", pluginfile, err) - } - - plug := &Plugin{Dir: dirname} - if err := yaml.UnmarshalStrict(data, &plug.Metadata); err != nil { - return nil, fmt.Errorf("failed to load plugin at %q: %w", pluginfile, err) - } - return plug, validatePluginData(plug, pluginfile) -} - -// LoadAll loads all plugins found beneath the base directory. -// -// This scans only one directory level. -func LoadAll(basedir string) ([]*Plugin, error) { - plugins := []*Plugin{} - // We want basedir/*/plugin.yaml - scanpath := filepath.Join(basedir, "*", PluginFileName) - matches, err := filepath.Glob(scanpath) - if err != nil { - return plugins, fmt.Errorf("failed to find plugins in %q: %w", scanpath, err) - } - - if matches == nil { - return plugins, nil - } - - for _, yaml := range matches { - dir := filepath.Dir(yaml) - p, err := LoadDir(dir) - if err != nil { - return plugins, err - } - plugins = append(plugins, p) - } - return plugins, detectDuplicates(plugins) -} - -// FindPlugins returns a list of YAML files that describe plugins. -func FindPlugins(plugdirs string) ([]*Plugin, error) { - found := []*Plugin{} - // Let's get all UNIXy and allow path separators - for _, p := range filepath.SplitList(plugdirs) { - matches, err := LoadAll(p) - if err != nil { - return matches, err - } - found = append(found, matches...) - } - return found, nil -} - -// SetupPluginEnv prepares os.Env for plugins. It operates on os.Env because -// the plugin subsystem itself needs access to the environment variables -// created here. -func SetupPluginEnv(settings *cli.EnvSettings, name, base string) { - env := settings.EnvVars() - env["HELM_PLUGIN_NAME"] = name - env["HELM_PLUGIN_DIR"] = base - for key, val := range env { - os.Setenv(key, val) - } -} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go deleted file mode 100644 index b96428f6b..000000000 --- a/pkg/plugin/plugin_test.go +++ /dev/null @@ -1,545 +0,0 @@ -/* -Copyright The Helm Authors. -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package plugin // import "helm.sh/helm/v4/pkg/plugin" - -import ( - "fmt" - "os" - "path/filepath" - "reflect" - "runtime" - "testing" - - "helm.sh/helm/v4/pkg/cli" -) - -func TestPrepareCommand(t *testing.T) { - cmdMain := "sh" - cmdArgs := []string{"-c", "echo \"test\""} - - p := &Plugin{ - Dir: "/tmp", // Unused - Metadata: &Metadata{ - Name: "test", - Command: "echo \"error\"", - PlatformCommand: []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 := p.PrepareCommand([]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\""} - extraArgs := []string{"--debug", "--foo", "bar"} - - p := &Plugin{ - Dir: "/tmp", // Unused - Metadata: &Metadata{ - Name: "test", - Command: "echo \"error\"", - PlatformCommand: []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\""}}, - }, - }, - } - - expectedArgs := append(cmdArgs, extraArgs...) - - cmd, args, err := p.PrepareCommand(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 TestPrepareCommandExtraArgsIgnored(t *testing.T) { - cmdMain := "sh" - cmdArgs := []string{"-c", "echo \"test\""} - extraArgs := []string{"--debug", "--foo", "bar"} - - p := &Plugin{ - Dir: "/tmp", // Unused - Metadata: &Metadata{ - Name: "test", - Command: "echo \"error\"", - PlatformCommand: []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\""}}, - }, - IgnoreFlags: true, - }, - } - - cmd, args, err := p.PrepareCommand(extraArgs) - 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 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) - } -} - -func TestLoadDir(t *testing.T) { - dirname := "testdata/plugdir/good/hello" - plug, err := LoadDir(dirname) - if err != nil { - t.Fatalf("error loading Hello plugin: %s", err) - } - - if plug.Dir != dirname { - t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir) - } - - expect := &Metadata{ - Name: "hello", - Version: "0.1.0", - Usage: "usage", - Description: "description", - PlatformCommand: []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"}}, - }, - IgnoreFlags: true, - 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...\""}}, - }, - }, - } - - if !reflect.DeepEqual(expect, plug.Metadata) { - t.Fatalf("Expected plugin metadata %v, got %v", expect, plug.Metadata) - } -} - -func TestLoadDirDuplicateEntries(t *testing.T) { - dirname := "testdata/plugdir/bad/duplicate-entries" - if _, err := LoadDir(dirname); err == nil { - t.Errorf("successfully loaded plugin with duplicate entries when it should've failed") - } -} - -func TestDownloader(t *testing.T) { - dirname := "testdata/plugdir/good/downloader" - plug, err := LoadDir(dirname) - if err != nil { - t.Fatalf("error loading Hello plugin: %s", err) - } - - if plug.Dir != dirname { - t.Fatalf("Expected dir %q, got %q", dirname, plug.Dir) - } - - expect := &Metadata{ - Name: "downloader", - Version: "1.2.3", - Usage: "usage", - Description: "download something", - Command: "echo Hello", - Downloaders: []Downloaders{ - { - Protocols: []string{"myprotocol", "myprotocols"}, - Command: "echo Download", - }, - }, - } - - if !reflect.DeepEqual(expect, plug.Metadata) { - t.Fatalf("Expected metadata %v, got %v", expect, plug.Metadata) - } -} - -func TestLoadAll(t *testing.T) { - // Verify that empty dir loads: - if plugs, err := LoadAll("testdata"); err != nil { - t.Fatalf("error loading dir with no plugins: %s", err) - } else if len(plugs) > 0 { - t.Fatalf("expected empty dir to have 0 plugins") - } - - basedir := "testdata/plugdir/good" - plugs, err := LoadAll(basedir) - if err != nil { - t.Fatalf("Could not load %q: %s", basedir, err) - } - - if l := len(plugs); l != 3 { - t.Fatalf("expected 3 plugins, found %d", l) - } - - if plugs[0].Metadata.Name != "downloader" { - t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name) - } - if plugs[1].Metadata.Name != "echo" { - t.Errorf("Expected first plugin to be echo, got %q", plugs[0].Metadata.Name) - } - if plugs[2].Metadata.Name != "hello" { - t.Errorf("Expected second plugin to be hello, got %q", plugs[1].Metadata.Name) - } -} - -func TestFindPlugins(t *testing.T) { - cases := []struct { - name string - plugdirs string - expected int - }{ - { - name: "plugdirs is empty", - plugdirs: "", - expected: 0, - }, - { - name: "plugdirs isn't dir", - plugdirs: "./plugin_test.go", - expected: 0, - }, - { - name: "plugdirs doesn't have plugin", - plugdirs: ".", - expected: 0, - }, - { - name: "normal", - plugdirs: "./testdata/plugdir/good", - expected: 3, - }, - } - for _, c := range cases { - t.Run(t.Name(), func(t *testing.T) { - plugin, _ := FindPlugins(c.plugdirs) - if len(plugin) != c.expected { - t.Errorf("expected: %v, got: %v", c.expected, len(plugin)) - } - }) - } -} - -func TestSetupEnv(t *testing.T) { - name := "pequod" - base := filepath.Join("testdata/helmhome/helm/plugins", name) - - s := cli.New() - s.PluginsDirectory = "testdata/helmhome/helm/plugins" - - SetupPluginEnv(s, name, base) - for _, tt := range []struct { - name, expect string - }{ - {"HELM_PLUGIN_NAME", name}, - {"HELM_PLUGIN_DIR", base}, - } { - if got := os.Getenv(tt.name); got != tt.expect { - t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) - } - } -} - -func TestSetupEnvWithSpace(t *testing.T) { - name := "sureshdsk" - base := filepath.Join("testdata/helm home/helm/plugins", name) - - s := cli.New() - s.PluginsDirectory = "testdata/helm home/helm/plugins" - - SetupPluginEnv(s, name, base) - for _, tt := range []struct { - name, expect string - }{ - {"HELM_PLUGIN_NAME", name}, - {"HELM_PLUGIN_DIR", base}, - } { - if got := os.Getenv(tt.name); got != tt.expect { - t.Errorf("Expected $%s=%q, got %q", tt.name, tt.expect, got) - } - } -} - -func TestValidatePluginData(t *testing.T) { - // A mock plugin missing any metadata. - mockMissingMeta := &Plugin{ - Dir: "no-such-dir", - } - - // A mock plugin with no commands - mockNoCommand := mockPlugin("foo") - mockNoCommand.Metadata.PlatformCommand = []PlatformCommand{} - mockNoCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{} - - // A mock plugin with legacy commands - mockLegacyCommand := mockPlugin("foo") - mockLegacyCommand.Metadata.PlatformCommand = []PlatformCommand{} - mockLegacyCommand.Metadata.Command = "echo \"mock plugin\"" - mockLegacyCommand.Metadata.PlatformHooks = map[string][]PlatformCommand{} - mockLegacyCommand.Metadata.Hooks = map[string]string{ - Install: "echo installing...", - } - - // A mock plugin with a command also set - mockWithCommand := mockPlugin("foo") - mockWithCommand.Metadata.Command = "echo \"mock plugin\"" - - // A mock plugin with a hooks also set - mockWithHooks := mockPlugin("foo") - mockWithHooks.Metadata.Hooks = map[string]string{ - Install: "echo installing...", - } - - for i, item := range []struct { - pass bool - plug *Plugin - }{ - {true, mockPlugin("abcdefghijklmnopqrstuvwxyz0123456789_-ABC")}, - {true, mockPlugin("foo-bar-FOO-BAR_1234")}, - {false, mockPlugin("foo -bar")}, - {false, mockPlugin("$foo -bar")}, // Test leading chars - {false, mockPlugin("foo -bar ")}, // Test trailing chars - {false, mockPlugin("foo\nbar")}, // Test newline - {false, mockMissingMeta}, // Test if the metadata section missing - {true, mockNoCommand}, // Test no command metadata works - {true, mockLegacyCommand}, // Test legacy command metadata works - {false, mockWithCommand}, // Test platformCommand and command both set fails - {false, mockWithHooks}, // Test platformHooks and hooks both set fails - } { - err := validatePluginData(item.plug, fmt.Sprintf("test-%d", i)) - if item.pass && err != nil { - t.Errorf("failed to validate case %d: %s", i, err) - } else if !item.pass && err == nil { - t.Errorf("expected case %d to fail", i) - } - } -} - -func TestDetectDuplicates(t *testing.T) { - plugs := []*Plugin{ - mockPlugin("foo"), - mockPlugin("bar"), - } - if err := detectDuplicates(plugs); err != nil { - t.Error("no duplicates in the first set") - } - plugs = append(plugs, mockPlugin("foo")) - if err := detectDuplicates(plugs); err == nil { - t.Error("duplicates in the second set") - } -} - -func mockPlugin(name string) *Plugin { - return &Plugin{ - Metadata: &Metadata{ - Name: name, - Version: "v0.1.2", - Usage: "Mock plugin", - Description: "Mock plugin for testing", - PlatformCommand: []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...\""}}, - }, - }, - }, - Dir: "no-such-dir", - } -} diff --git a/pkg/postrender/exec.go b/pkg/postrender/exec.go deleted file mode 100644 index 16d9c09ce..000000000 --- a/pkg/postrender/exec.go +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package postrender - -import ( - "bytes" - "fmt" - "io" - "os/exec" - "path/filepath" -) - -type execRender struct { - binaryPath string - args []string -} - -// NewExec returns a PostRenderer implementation that calls the provided binary. -// It returns an error if the binary cannot be found. If the path does not -// contain any separators, it will search in $PATH, otherwise it will resolve -// any relative paths to a fully qualified path -func NewExec(binaryPath string, args ...string) (PostRenderer, error) { - fullPath, err := getFullPath(binaryPath) - if err != nil { - return nil, err - } - return &execRender{fullPath, args}, nil -} - -// Run the configured binary for the post render -func (p *execRender) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { - cmd := exec.Command(p.binaryPath, p.args...) - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - - var postRendered = &bytes.Buffer{} - var stderr = &bytes.Buffer{} - cmd.Stdout = postRendered - cmd.Stderr = stderr - - go func() { - defer stdin.Close() - io.Copy(stdin, renderedManifests) - }() - err = cmd.Run() - if err != nil { - return nil, fmt.Errorf("error while running command %s. error output:\n%s: %w", p.binaryPath, stderr.String(), err) - } - - // If the binary returned almost nothing, it's likely that it didn't - // successfully render anything - if len(bytes.TrimSpace(postRendered.Bytes())) == 0 { - return nil, fmt.Errorf("post-renderer %q produced empty output", p.binaryPath) - } - - return postRendered, nil -} - -// getFullPath returns the full filepath to the binary to execute. If the path -// does not contain any separators, it will search in $PATH, otherwise it will -// resolve any relative paths to a fully qualified path -func getFullPath(binaryPath string) (string, error) { - // NOTE(thomastaylor312): I am leaving this code commented out here. During - // the implementation of post-render, it was brought up that if we are - // relying on plugins, we should actually use the plugin system so it can - // properly handle multiple OSs. This will be a feature add in the future, - // so I left this code for reference. It can be deleted or reused once the - // feature is implemented - - // Manually check the plugin dir first - // if !strings.Contains(binaryPath, string(filepath.Separator)) { - // // First check the plugin dir - // pluginDir := helmpath.DataPath("plugins") // Default location - // // If location for plugins is explicitly set, check there - // if v, ok := os.LookupEnv("HELM_PLUGINS"); ok { - // pluginDir = v - // } - // // The plugins variable can actually contain multiple paths, so loop through those - // for _, p := range filepath.SplitList(pluginDir) { - // _, err := os.Stat(filepath.Join(p, binaryPath)) - // if err != nil && !errors.Is(err, fs.ErrNotExist) { - // return "", err - // } else if err == nil { - // binaryPath = filepath.Join(p, binaryPath) - // break - // } - // } - // } - - // Now check for the binary using the given path or check if it exists in - // the path and is executable - checkedPath, err := exec.LookPath(binaryPath) - if err != nil { - return "", fmt.Errorf("unable to find binary at %s: %w", binaryPath, err) - } - - return filepath.Abs(checkedPath) -} diff --git a/pkg/postrender/exec_test.go b/pkg/postrender/exec_test.go deleted file mode 100644 index a10ad2cc4..000000000 --- a/pkg/postrender/exec_test.go +++ /dev/null @@ -1,193 +0,0 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package postrender - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const testingScript = `#!/bin/sh -if [ $# -eq 0 ]; then -sed s/FOOTEST/BARTEST/g <&0 -else -sed s/FOOTEST/"$*"/g <&0 -fi -` - -func TestGetFullPath(t *testing.T) { - is := assert.New(t) - t.Run("full path resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - fullPath, err := getFullPath(testpath) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - t.Run("relative path resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - currentDir, err := os.Getwd() - require.NoError(t, err) - relative, err := filepath.Rel(currentDir, testpath) - require.NoError(t, err) - fullPath, err := getFullPath(relative) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - t.Run("binary in PATH resolves correctly", func(t *testing.T) { - testpath := setupTestingScript(t) - - t.Setenv("PATH", filepath.Dir(testpath)) - - fullPath, err := getFullPath(filepath.Base(testpath)) - is.NoError(err) - is.Equal(testpath, fullPath) - }) - - // NOTE(thomastaylor312): See note in getFullPath for more details why this - // is here - - // t.Run("binary in plugin path resolves correctly", func(t *testing.T) { - // testpath, cleanup := setupTestingScript(t) - // defer cleanup() - - // realPath := os.Getenv("HELM_PLUGINS") - // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)) - // defer func() { - // os.Setenv("HELM_PLUGINS", realPath) - // }() - - // fullPath, err := getFullPath(filepath.Base(testpath)) - // is.NoError(err) - // is.Equal(testpath, fullPath) - // }) - - // t.Run("binary in multiple plugin paths resolves correctly", func(t *testing.T) { - // testpath, cleanup := setupTestingScript(t) - // defer cleanup() - - // realPath := os.Getenv("HELM_PLUGINS") - // os.Setenv("HELM_PLUGINS", filepath.Dir(testpath)+string(os.PathListSeparator)+"/another/dir") - // defer func() { - // os.Setenv("HELM_PLUGINS", realPath) - // }() - - // fullPath, err := getFullPath(filepath.Base(testpath)) - // is.NoError(err) - // is.Equal(testpath, fullPath) - // }) -} - -func TestExecRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath) - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "BARTEST") -} - -func TestExecRunWithNoOutput(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath) - require.NoError(t, err) - - _, err = renderer.Run(bytes.NewBufferString("")) - is.Error(err) -} - -func TestNewExecWithOneArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath, "ARG1") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1") -} - -func TestNewExecWithTwoArgsRun(t *testing.T) { - if runtime.GOOS == "windows" { - // the actual Run test uses a basic sed example, so skip this test on windows - t.Skip("skipping on windows") - } - is := assert.New(t) - testpath := setupTestingScript(t) - - renderer, err := NewExec(testpath, "ARG1", "ARG2") - require.NoError(t, err) - - output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) - is.NoError(err) - is.Contains(output.String(), "ARG1 ARG2") -} - -func setupTestingScript(t *testing.T) (filepath string) { - t.Helper() - - tempdir := t.TempDir() - - f, err := os.CreateTemp(tempdir, "post-render-test.sh") - if err != nil { - t.Fatalf("unable to create tempfile for testing: %s", err) - } - - _, err = f.WriteString(testingScript) - if err != nil { - t.Fatalf("unable to write tempfile for testing: %s", err) - } - - err = f.Chmod(0o755) - if err != nil { - t.Fatalf("unable to make tempfile executable for testing: %s", err) - } - - err = f.Close() - if err != nil { - t.Fatalf("unable to close tempfile after writing: %s", err) - } - - return f.Name() -} diff --git a/pkg/postrenderer/postrenderer.go b/pkg/postrenderer/postrenderer.go new file mode 100644 index 000000000..2107cc465 --- /dev/null +++ b/pkg/postrenderer/postrenderer.go @@ -0,0 +1,85 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postrenderer + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + + "helm.sh/helm/v4/internal/plugin/schema" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/pkg/cli" +) + +// PostRenderer is an interface different plugin runtimes +// it may be also be used without the factory for custom post-renderers +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) +} + +// NewPostRendererPlugin creates a PostRenderer that uses the plugin's Runtime +func NewPostRendererPlugin(settings *cli.EnvSettings, pluginName string, args ...string) (PostRenderer, error) { + descriptor := plugin.Descriptor{ + Name: pluginName, + Type: "postrenderer/v1", + } + p, err := plugin.FindPlugin(filepath.SplitList(settings.PluginsDirectory), descriptor) + if err != nil { + return nil, err + } + + return &postRendererPlugin{ + plugin: p, + args: args, + settings: settings, + }, nil +} + +// postRendererPlugin implements PostRenderer by delegating to the plugin's Runtime +type postRendererPlugin struct { + plugin plugin.Plugin + args []string + settings *cli.EnvSettings +} + +// Run implements PostRenderer by using the plugin's Runtime +func (r *postRendererPlugin) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer, error) { + input := &plugin.Input{ + Message: schema.InputMessagePostRendererV1{ + ExtraArgs: r.args, + Manifests: renderedManifests, + Settings: r.settings, + }, + } + output, err := r.plugin.Invoke(context.Background(), input) + if err != nil { + return nil, fmt.Errorf("failed to invoke post-renderer plugin %q: %w", r.plugin.Metadata().Name, err) + } + + outputMessage := output.Message.(*schema.OutputMessagePostRendererV1) + + // If the binary returned almost nothing, it's likely that it didn't + // successfully render anything + if len(bytes.TrimSpace(outputMessage.Manifests.Bytes())) == 0 { + return nil, fmt.Errorf("post-renderer %q produced empty output", r.plugin.Metadata().Name) + } + + return outputMessage.Manifests, nil +} diff --git a/pkg/postrenderer/postrenderer_test.go b/pkg/postrenderer/postrenderer_test.go new file mode 100644 index 000000000..9addd481d --- /dev/null +++ b/pkg/postrenderer/postrenderer_test.go @@ -0,0 +1,89 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package postrenderer + +import ( + "bytes" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/pkg/cli" +) + +func TestNewPostRenderPluginRunWithNoOutput(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + base := filepath.Join(s.PluginsDirectory, name) + plugin.SetupPluginEnv(s, name, base) + + renderer, err := NewPostRendererPlugin(s, name, "") + require.NoError(t, err) + + _, err = renderer.Run(bytes.NewBufferString("")) + is.Error(err) +} + +func TestNewPostRenderPluginWithOneArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + base := filepath.Join(s.PluginsDirectory, name) + plugin.SetupPluginEnv(s, name, base) + + renderer, err := NewPostRendererPlugin(s, name, "ARG1") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1") +} + +func TestNewPostRenderPluginWithTwoArgsRun(t *testing.T) { + if runtime.GOOS == "windows" { + // the actual Run test uses a basic sed example, so skip this test on windows + t.Skip("skipping on windows") + } + is := assert.New(t) + s := cli.New() + s.PluginsDirectory = "testdata/plugins" + name := "postrenderer-v1" + base := filepath.Join(s.PluginsDirectory, name) + plugin.SetupPluginEnv(s, name, base) + + renderer, err := NewPostRendererPlugin(s, name, "ARG1", "ARG2") + require.NoError(t, err) + + output, err := renderer.Run(bytes.NewBufferString("FOOTEST")) + is.NoError(err) + is.Contains(output.String(), "ARG1 ARG2") +} diff --git a/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml new file mode 100644 index 000000000..30f1599b4 --- /dev/null +++ b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml @@ -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" diff --git a/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh b/pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh new file mode 100755 index 000000000..a016e398f --- /dev/null +++ b/pkg/postrenderer/testdata/plugins/postrenderer-v1/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 diff --git a/pkg/provenance/doc.go b/pkg/provenance/doc.go index 883c0e724..dd14568d9 100644 --- a/pkg/provenance/doc.go +++ b/pkg/provenance/doc.go @@ -14,15 +14,15 @@ limitations under the License. */ /* -Package provenance provides tools for establishing the authenticity of a chart. +Package provenance provides tools for establishing the authenticity of packages. In Helm, provenance is established via several factors. The primary factor is the -cryptographic signature of a chart. Chart authors may sign charts, which in turn -provide the necessary metadata to ensure the integrity of the chart file, the -Chart.yaml, and the referenced Docker images. +cryptographic signature of a package. Package authors may sign packages, which in turn +provide the necessary metadata to ensure the integrity of the package file, the +metadata, and the referenced Docker images. A provenance file is clear-signed. This provides cryptographic verification that -a particular block of information (Chart.yaml, archive file, images) have not +a particular block of information (metadata, archive file, images) have not been tampered with or altered. To learn more, read the GnuPG documentation on clear signatures: https://www.gnupg.org/gph/en/manual/x135.html diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index 504bc6aa1..3ffad2765 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -23,16 +23,12 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" "golang.org/x/crypto/openpgp" //nolint "golang.org/x/crypto/openpgp/clearsign" //nolint "golang.org/x/crypto/openpgp/packet" //nolint "sigs.k8s.io/yaml" - - hapi "helm.sh/helm/v4/pkg/chart/v2" - "helm.sh/helm/v4/pkg/chart/v2/loader" ) var defaultPGPConfig = packet.Config{ @@ -58,7 +54,7 @@ type SumCollection struct { // Verification contains information about a verification operation. type Verification struct { - // SignedBy contains the entity that signed a chart. + // SignedBy contains the entity that signed a package. SignedBy *openpgp.Entity // FileHash is the hash, prepended with the scheme, for the file that was verified. FileHash string @@ -68,11 +64,11 @@ type Verification struct { // Signatory signs things. // -// Signatories can be constructed from a PGP private key file using NewFromFiles +// Signatories can be constructed from a PGP private key file using NewFromFiles, // or they can be constructed manually by setting the Entity to a valid // PGP entity. // -// The same Signatory can be used to sign or validate multiple charts. +// The same Signatory can be used to sign or validate multiple packages. type Signatory struct { // The signatory for this instance of Helm. This is used for signing. Entity *openpgp.Entity @@ -197,28 +193,20 @@ func (s *Signatory) DecryptKey(fn PassphraseFetcher) error { return s.Entity.PrivateKey.Decrypt(p) } -// ClearSign signs a chart with the given key. -// -// This takes the path to a chart archive file and a key, and it returns a clear signature. +// ClearSign signs package data with the given key and pre-marshalled metadata. // -// The Signatory must have a valid Entity.PrivateKey for this to work. If it does -// not, an error will be returned. -func (s *Signatory) ClearSign(chartpath string) (string, error) { +// This is the core signing method that works with data in memory. +// The Signatory must have a valid Entity.PrivateKey for this to work. +func (s *Signatory) ClearSign(archiveData []byte, filename string, metadataBytes []byte) (string, error) { if s.Entity == nil { return "", errors.New("private key not found") } else if s.Entity.PrivateKey == nil { return "", errors.New("provided key is not a private key. Try providing a keyring with secret keys") } - if fi, err := os.Stat(chartpath); err != nil { - return "", err - } else if fi.IsDir() { - return "", errors.New("cannot sign a directory") - } - out := bytes.NewBuffer(nil) - b, err := messageBlock(chartpath) + b, err := messageBlock(archiveData, filename, metadataBytes) if err != nil { return "", err } @@ -248,69 +236,47 @@ func (s *Signatory) ClearSign(chartpath string) (string, error) { return out.String(), nil } -// Verify checks a signature and verifies that it is legit for a chart. -func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { +// Verify checks a signature and verifies that it is legit for package data. +// This is the core verification method that works with data in memory. +func (s *Signatory) Verify(archiveData, provData []byte, filename string) (*Verification, error) { ver := &Verification{} - for _, fname := range []string{chartpath, sigpath} { - if fi, err := os.Stat(fname); err != nil { - return ver, err - } else if fi.IsDir() { - return ver, fmt.Errorf("%s cannot be a directory", fname) - } - } // First verify the signature - sig, err := s.decodeSignature(sigpath) - if err != nil { - return ver, fmt.Errorf("failed to decode signature: %w", err) + block, _ := clearsign.Decode(provData) + if block == nil { + return ver, errors.New("signature block not found") } - by, err := s.verifySignature(sig) + by, err := s.verifySignature(block) if err != nil { return ver, err } ver.SignedBy = by - // Second, verify the hash of the tarball. - sum, err := DigestFile(chartpath) + // Second, verify the hash of the data. + sum, err := Digest(bytes.NewBuffer(archiveData)) if err != nil { return ver, err } - _, sums, err := parseMessageBlock(sig.Plaintext) + sums, err := parseMessageBlock(block.Plaintext) if err != nil { return ver, err } sum = "sha256:" + sum - basename := filepath.Base(chartpath) - if sha, ok := sums.Files[basename]; !ok { - return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", basename) + if sha, ok := sums.Files[filename]; !ok { + return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", filename) } else if sha != sum { - return ver, fmt.Errorf("sha256 sum does not match for %s: %q != %q", basename, sha, sum) + return ver, fmt.Errorf("sha256 sum does not match for %s: %q != %q", filename, sha, sum) } ver.FileHash = sum - ver.FileName = basename + ver.FileName = filename // TODO: when image signing is added, verify that here. return ver, nil } -func (s *Signatory) decodeSignature(filename string) (*clearsign.Block, error) { - data, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - block, _ := clearsign.Decode(data) - if block == nil { - // There was no sig in the file. - return nil, errors.New("signature block not found") - } - - return block, nil -} - // verifySignature verifies that the given block is validly signed, and returns the signer. func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, error) { return openpgp.CheckDetachedSignature( @@ -320,64 +286,63 @@ func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, er ) } -func messageBlock(chartpath string) (*bytes.Buffer, error) { - var b *bytes.Buffer - // Checksum the archive - chash, err := DigestFile(chartpath) +// messageBlock creates a message block from archive data and pre-marshalled metadata +func messageBlock(archiveData []byte, filename string, metadataBytes []byte) (*bytes.Buffer, error) { + // Checksum the archive data + chash, err := Digest(bytes.NewBuffer(archiveData)) if err != nil { - return b, err + return nil, err } - base := filepath.Base(chartpath) sums := &SumCollection{ Files: map[string]string{ - base: "sha256:" + chash, + filename: "sha256:" + chash, }, } - // Load the archive into memory. - chart, err := loader.LoadFile(chartpath) - if err != nil { - return b, err - } - - // Buffer a hash + checksums YAML file - data, err := yaml.Marshal(chart.Metadata) - if err != nil { - return b, err - } - + // Buffer the metadata + checksums YAML file // FIXME: YAML uses ---\n as a file start indicator, but this is not legal in a PGP // clearsign block. So we use ...\n, which is the YAML document end marker. // http://yaml.org/spec/1.2/spec.html#id2800168 - b = bytes.NewBuffer(data) + b := bytes.NewBuffer(metadataBytes) b.WriteString("\n...\n") - data, err = yaml.Marshal(sums) + data, err := yaml.Marshal(sums) if err != nil { - return b, err + return nil, err } b.Write(data) return b, nil } -// parseMessageBlock -func parseMessageBlock(data []byte) (*hapi.Metadata, *SumCollection, error) { - // This sucks. +// parseMessageBlock parses a message block and returns only checksums (metadata ignored like upstream) +func parseMessageBlock(data []byte) (*SumCollection, error) { + sc := &SumCollection{} + + // We ignore metadata, just like upstream - only need checksums for verification + if err := ParseMessageBlock(data, nil, sc); err != nil { + return sc, err + } + return sc, nil +} + +// ParseMessageBlock parses a message block containing metadata and checksums. +// +// This is the generic version that can work with any metadata type. +// The metadata parameter should be a pointer to a struct that can be unmarshaled from YAML. +func ParseMessageBlock(data []byte, metadata interface{}, sums *SumCollection) error { parts := bytes.Split(data, []byte("\n...\n")) if len(parts) < 2 { - return nil, nil, errors.New("message block must have at least two parts") + return errors.New("message block must have at least two parts") } - md := &hapi.Metadata{} - sc := &SumCollection{} - - if err := yaml.Unmarshal(parts[0], md); err != nil { - return md, sc, err + if metadata != nil { + if err := yaml.Unmarshal(parts[0], metadata); err != nil { + return err + } } - err := yaml.Unmarshal(parts[1], sc) - return md, sc, err + return yaml.Unmarshal(parts[1], sums) } // loadKey loads a GPG key found at a particular path. @@ -406,7 +371,7 @@ func loadKeyRing(ringpath string) (openpgp.EntityList, error) { // It takes the path to the archive file, and returns a string representation of // the SHA256 sum. // -// The intended use of this function is to generate a sum of a chart TGZ file. +// This function can be used to generate a sum of any package archive file. func DigestFile(filename string) (string, error) { f, err := os.Open(filename) if err != nil { diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 9a60fd19c..4f2fc7298 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -25,6 +25,9 @@ import ( "testing" pgperrors "golang.org/x/crypto/openpgp/errors" //nolint + "sigs.k8s.io/yaml" + + "helm.sh/helm/v4/pkg/chart/v2/loader" ) const ( @@ -75,8 +78,33 @@ files: hashtest-1.2.3.tgz: sha256:c6841b3a895f1444a6738b5d04564a57e860ce42f8519c3be807fb6d9bee7888 ` +// loadChartMetadataForSigning is a test helper that loads chart metadata and marshals it to YAML bytes +func loadChartMetadataForSigning(t *testing.T, chartPath string) []byte { + t.Helper() + + chart, err := loader.LoadFile(chartPath) + if err != nil { + t.Fatal(err) + } + + metadataBytes, err := yaml.Marshal(chart.Metadata) + if err != nil { + t.Fatal(err) + } + + return metadataBytes +} + func TestMessageBlock(t *testing.T) { - out, err := messageBlock(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + out, err := messageBlock(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -88,14 +116,12 @@ func TestMessageBlock(t *testing.T) { } func TestParseMessageBlock(t *testing.T) { - md, sc, err := parseMessageBlock([]byte(testMessageBlock)) + sc, err := parseMessageBlock([]byte(testMessageBlock)) if err != nil { t.Fatal(err) } - if md.Name != "hashtest" { - t.Errorf("Expected name %q, got %q", "hashtest", md.Name) - } + // parseMessageBlock only returns checksums, not metadata (like upstream) if lsc := len(sc.Files); lsc != 1 { t.Errorf("Expected 1 file, got %d", lsc) @@ -221,7 +247,15 @@ func TestClearSign(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err != nil { t.Fatal(err) } @@ -252,7 +286,15 @@ func TestClearSignError(t *testing.T) { // ensure that signing always fails signer.Entity.PrivateKey.PrivateKey = failSigner{} - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) + if err != nil { + t.Fatal(err) + } + + sig, err := signer.ClearSign(archiveData, filepath.Base(testChartfile), metadataBytes) if err == nil { t.Fatal("didn't get an error from ClearSign but expected one") } @@ -262,54 +304,25 @@ func TestClearSignError(t *testing.T) { } } -func TestDecodeSignature(t *testing.T) { - // Unlike other tests, this does a round-trip test, ensuring that a signature - // generated by the library can also be verified by the library. - +func TestVerify(t *testing.T) { signer, err := NewFromFiles(testKeyfile, testPubfile) if err != nil { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) - if err != nil { - t.Fatal(err) - } - - f, err := os.CreateTemp(t.TempDir(), "helm-test-sig-") - if err != nil { - t.Fatal(err) - } - - tname := f.Name() - defer func() { - os.Remove(tname) - }() - f.WriteString(sig) - f.Close() - - sig2, err := signer.decodeSignature(tname) - if err != nil { - t.Fatal(err) - } - - by, err := signer.verifySignature(sig2) + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) if err != nil { t.Fatal(err) } - if _, ok := by.Identities[testKeyName]; !ok { - t.Errorf("Expected identity %q", testKeyName) - } -} - -func TestVerify(t *testing.T) { - signer, err := NewFromFiles(testKeyfile, testPubfile) + // Read the signature file data + sigData, err := os.ReadFile(testSigBlock) if err != nil { t.Fatal(err) } - if ver, err := signer.Verify(testChartfile, testSigBlock); err != nil { + if ver, err := signer.Verify(archiveData, sigData, filepath.Base(testChartfile)); err != nil { t.Errorf("Failed to pass verify. Err: %s", err) } else if len(ver.FileHash) == 0 { t.Error("Verification is missing hash.") @@ -319,7 +332,13 @@ func TestVerify(t *testing.T) { t.Errorf("FileName is unexpectedly %q", ver.FileName) } - if _, err = signer.Verify(testChartfile, testTamperedSigBlock); err == nil { + // Read the tampered signature file data + tamperedSigData, err := os.ReadFile(testTamperedSigBlock) + if err != nil { + t.Fatal(err) + } + + if _, err = signer.Verify(archiveData, tamperedSigData, filepath.Base(testChartfile)); err == nil { t.Errorf("Expected %s to fail.", testTamperedSigBlock) } diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 0c9f256d3..7ba26ac5c 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -29,13 +29,11 @@ import ( "os" "sort" "strings" - "sync" "github.com/Masterminds/semver/v3" "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/memory" "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" @@ -128,18 +126,30 @@ func NewClient(options ...ClientOption) (*Client, error) { } authorizer.SetUserAgent(version.GetUserAgent()) - authorizer.Credential = credentials.Credential(client.credentialsStore) + if client.username != "" && client.password != "" { + authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) { + return auth.Credential{Username: client.username, Password: client.password}, nil + } + } else { + authorizer.Credential = credentials.Credential(client.credentialsStore) + } if client.enableCache { authorizer.Cache = auth.NewCache() } + authorizer.ForceAttemptOAuth2 = true client.authorizer = &authorizer } return client, nil } +// Generic returns a GenericClient for low-level OCI operations +func (c *Client) Generic() *GenericClient { + return NewGenericClient(c) +} + // ClientOptDebug returns a function that sets the debug setting on client options set func ClientOptDebug(debug bool) ClientOption { return func(client *Client) { @@ -411,84 +421,31 @@ type ( } ) -// Pull downloads a chart from a registry -func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { - parsedRef, err := newReference(ref) - if err != nil { - return nil, err - } +// processChartPull handles chart-specific processing of a generic pull result +func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) { + var err error - operation := &pullOperation{ - withChart: true, // By default, always download the chart layer - } - for _, option := range options { - option(operation) - } - if !operation.withChart && !operation.withProv { - return nil, errors.New( - "must specify at least one layer to pull (chart/prov)") - } - memoryStore := memory.New() - allowedMediaTypes := []string{ - ocispec.MediaTypeImageManifest, - ConfigMediaType, - } + // Chart-specific validation minNumDescriptors := 1 // 1 for the config if operation.withChart { minNumDescriptors++ - allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType) - } - if operation.withProv { - if !operation.ignoreMissingProv { - minNumDescriptors++ - } - allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType) - } - - var descriptors, layers []ocispec.Descriptor - - repository, err := remote.NewRepository(parsedRef.String()) - if err != nil { - return nil, err } - repository.PlainHTTP = c.plainHTTP - repository.Client = c.authorizer - - ctx := context.Background() - - sort.Strings(allowedMediaTypes) - - var mu sync.Mutex - manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{ - CopyGraphOptions: oras.CopyGraphOptions{ - PreCopy: func(_ context.Context, desc ocispec.Descriptor) error { - mediaType := desc.MediaType - if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType { - return oras.SkipNode - } - - mu.Lock() - layers = append(layers, desc) - mu.Unlock() - return nil - }, - }, - }) - if err != nil { - return nil, err + if operation.withProv && !operation.ignoreMissingProv { + minNumDescriptors++ } - descriptors = append(descriptors, layers...) - - numDescriptors := len(descriptors) + numDescriptors := len(genericResult.Descriptors) if numDescriptors < minNumDescriptors { return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d", minNumDescriptors, numDescriptors) } + + // Find chart-specific descriptors var configDescriptor *ocispec.Descriptor var chartDescriptor *ocispec.Descriptor var provDescriptor *ocispec.Descriptor - for _, descriptor := range descriptors { + + for _, descriptor := range genericResult.Descriptors { d := descriptor switch d.MediaType { case ConfigMediaType: @@ -502,6 +459,8 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType) } } + + // Chart-specific validation if configDescriptor == nil { return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType) } @@ -509,6 +468,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s", ChartLayerMediaType) } + var provMissing bool if operation.withProv && provDescriptor == nil { if operation.ignoreMissingProv { @@ -518,10 +478,12 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { ProvLayerMediaType) } } + + // Build chart-specific result result := &PullResult{ Manifest: &DescriptorPullSummary{ - Digest: manifest.Digest.String(), - Size: manifest.Size, + Digest: genericResult.Manifest.Digest.String(), + Size: genericResult.Manifest.Size, }, Config: &DescriptorPullSummary{ Digest: configDescriptor.Digest.String(), @@ -529,15 +491,18 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { }, Chart: &DescriptorPullSummaryWithMeta{}, Prov: &DescriptorPullSummary{}, - Ref: parsedRef.String(), + Ref: genericResult.Ref, } - result.Manifest.Data, err = content.FetchAll(ctx, memoryStore, manifest) + // Fetch data using generic client + genericClient := c.Generic() + + result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest) if err != nil { - return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", manifest.Digest, err) + return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err) } - result.Config.Data, err = content.FetchAll(ctx, memoryStore, *configDescriptor) + result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor) if err != nil { return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err) } @@ -547,7 +512,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { } if operation.withChart { - result.Chart.Data, err = content.FetchAll(ctx, memoryStore, *chartDescriptor) + result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor) if err != nil { return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err) } @@ -556,7 +521,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { } if operation.withProv && !provMissing { - result.Prov.Data, err = content.FetchAll(ctx, memoryStore, *provDescriptor) + result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor) if err != nil { return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err) } @@ -575,6 +540,44 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { return result, nil } +// Pull downloads a chart from a registry +func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) { + operation := &pullOperation{ + withChart: true, // By default, always download the chart layer + } + for _, option := range options { + option(operation) + } + if !operation.withChart && !operation.withProv { + return nil, errors.New( + "must specify at least one layer to pull (chart/prov)") + } + + // Build allowed media types for chart pull + allowedMediaTypes := []string{ + ocispec.MediaTypeImageManifest, + ConfigMediaType, + } + if operation.withChart { + allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType) + } + if operation.withProv { + allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType) + } + + // Use generic client for the pull operation + genericClient := c.Generic() + genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ + AllowedMediaTypes: allowedMediaTypes, + }) + if err != nil { + return nil, err + } + + // Process the result with chart-specific logic + return c.processChartPull(genericResult, operation) +} + // PullOptWithChart returns a function that sets the withChart setting on pull func PullOptWithChart(withChart bool) PullOption { return func(operation *pullOperation) { @@ -811,6 +814,7 @@ func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) { return desc, err } remoteRepository.PlainHTTP = c.plainHTTP + remoteRepository.Client = c.authorizer parsedReference, err := newReference(ref) if err != nil { @@ -823,12 +827,12 @@ func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) { } // ValidateReference for path and version -func (c *Client) ValidateReference(ref, version string, u *url.URL) (*url.URL, error) { +func (c *Client) ValidateReference(ref, version string, u *url.URL) (string, *url.URL, error) { var tag string registryReference, err := newReference(u.Host + u.Path) if err != nil { - return nil, err + return "", nil, err } if version == "" { @@ -836,14 +840,14 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (*url.URL, e version = registryReference.Tag } else { if registryReference.Tag != "" && registryReference.Tag != version { - return nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag) + return "", nil, fmt.Errorf("chart reference and version mismatch: %s is not %s", version, registryReference.Tag) } } if registryReference.Digest != "" { if version == "" { // Install by digest only - return u, nil + return "", u, nil } u.Path = fmt.Sprintf("%s@%s", registryReference.Repository, registryReference.Digest) @@ -852,12 +856,12 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (*url.URL, e desc, err := c.Resolve(path) if err != nil { // The resource does not have to be tagged when digest is specified - return u, nil + return "", u, nil } if desc.Digest.String() != registryReference.Digest { - return nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest) + return "", nil, fmt.Errorf("chart reference digest mismatch: %s is not %s", desc.Digest.String(), registryReference.Digest) } - return u, nil + return registryReference.Digest, u, nil } // Evaluate whether an explicit version has been provided. Otherwise, determine version to use @@ -868,10 +872,10 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (*url.URL, e // Retrieve list of repository tags tags, err := c.Tags(strings.TrimPrefix(ref, fmt.Sprintf("%s://", OCIScheme))) if err != nil { - return nil, err + return "", nil, err } if len(tags) == 0 { - return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref) + return "", nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref) } // Determine if version provided @@ -880,13 +884,14 @@ func (c *Client) ValidateReference(ref, version string, u *url.URL) (*url.URL, e // If semver constraint string, try to find a match tag, err = GetTagMatchingVersionOrConstraint(tags, version) if err != nil { - return nil, err + return "", nil, err } } u.Path = fmt.Sprintf("%s:%s", registryReference.Repository, tag) + // desc, err := c.Resolve(u.Path) - return u, err + return "", u, err } // tagManifest prepares and tags a manifest in memory storage diff --git a/pkg/registry/generic.go b/pkg/registry/generic.go new file mode 100644 index 000000000..b82132338 --- /dev/null +++ b/pkg/registry/generic.go @@ -0,0 +1,162 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "context" + "io" + "net/http" + "sort" + "sync" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" +) + +// GenericClient provides low-level OCI operations without artifact-specific assumptions +type GenericClient struct { + debug bool + enableCache bool + credentialsFile string + username string + password string + out io.Writer + authorizer *auth.Client + registryAuthorizer RemoteClient + credentialsStore credentials.Store + httpClient *http.Client + plainHTTP bool +} + +// GenericPullOptions configures a generic pull operation +type GenericPullOptions struct { + // MediaTypes to include in the pull (empty means all) + AllowedMediaTypes []string + // Skip descriptors with these media types + SkipMediaTypes []string + // Custom PreCopy function for filtering + PreCopy func(context.Context, ocispec.Descriptor) error +} + +// GenericPullResult contains the result of a generic pull operation +type GenericPullResult struct { + Manifest ocispec.Descriptor + Descriptors []ocispec.Descriptor + MemoryStore *memory.Store + Ref string +} + +// NewGenericClient creates a new generic OCI client from an existing Client +func NewGenericClient(client *Client) *GenericClient { + return &GenericClient{ + debug: client.debug, + enableCache: client.enableCache, + credentialsFile: client.credentialsFile, + username: client.username, + password: client.password, + out: client.out, + authorizer: client.authorizer, + registryAuthorizer: client.registryAuthorizer, + credentialsStore: client.credentialsStore, + httpClient: client.httpClient, + plainHTTP: client.plainHTTP, + } +} + +// PullGeneric performs a generic OCI pull without artifact-specific assumptions +func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) { + parsedRef, err := newReference(ref) + if err != nil { + return nil, err + } + + memoryStore := memory.New() + var descriptors []ocispec.Descriptor + + // Set up repository with authentication and configuration + repository, err := remote.NewRepository(parsedRef.String()) + if err != nil { + return nil, err + } + repository.PlainHTTP = c.plainHTTP + repository.Client = c.authorizer + + ctx := context.Background() + + // Prepare allowed media types for filtering + var allowedMediaTypes []string + if len(options.AllowedMediaTypes) > 0 { + allowedMediaTypes = make([]string, len(options.AllowedMediaTypes)) + copy(allowedMediaTypes, options.AllowedMediaTypes) + sort.Strings(allowedMediaTypes) + } + + var mu sync.Mutex + manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{ + CopyGraphOptions: oras.CopyGraphOptions{ + PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error { + // Apply custom PreCopy function if provided + if options.PreCopy != nil { + if err := options.PreCopy(ctx, desc); err != nil { + return err + } + } + + mediaType := desc.MediaType + + // Skip media types if specified + for _, skipType := range options.SkipMediaTypes { + if mediaType == skipType { + return oras.SkipNode + } + } + + // Filter by allowed media types if specified + if len(allowedMediaTypes) > 0 { + if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType { + return oras.SkipNode + } + } + + mu.Lock() + descriptors = append(descriptors, desc) + mu.Unlock() + return nil + }, + }, + }) + if err != nil { + return nil, err + } + + return &GenericPullResult{ + Manifest: manifest, + Descriptors: descriptors, + MemoryStore: memoryStore, + Ref: parsedRef.String(), + }, nil +} + +// GetDescriptorData retrieves the data for a specific descriptor +func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) { + return content.FetchAll(context.Background(), store, desc) +} diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go new file mode 100644 index 000000000..991bace76 --- /dev/null +++ b/pkg/registry/plugin.go @@ -0,0 +1,212 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "encoding/json" + "fmt" + "strings" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// Plugin-specific constants +const ( + // PluginArtifactType is the artifact type for Helm plugins + PluginArtifactType = "application/vnd.helm.plugin.v1+json" +) + +// PluginPullOptions configures a plugin pull operation +type PluginPullOptions struct { + // PluginName specifies the expected plugin name for layer validation + PluginName string +} + +// PluginPullResult contains the result of a plugin pull operation +type PluginPullResult struct { + Manifest ocispec.Descriptor + PluginData []byte + Prov struct { + Data []byte + } + Ref string + PluginName string +} + +// PullPlugin downloads a plugin from an OCI registry using artifact type +func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) { + operation := &pluginPullOperation{ + pluginName: pluginName, + } + for _, option := range options { + option(operation) + } + + // Use generic client for the pull operation with artifact type filtering + genericClient := c.Generic() + genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{ + // Allow manifests and all layer types - we'll validate artifact type after download + AllowedMediaTypes: []string{ + ocispec.MediaTypeImageManifest, + "application/vnd.oci.image.layer.v1.tar", + "application/vnd.oci.image.layer.v1.tar+gzip", + }, + }) + if err != nil { + return nil, err + } + + // Process the result with plugin-specific logic + return c.processPluginPull(genericResult, operation.pluginName) +} + +// processPluginPull handles plugin-specific processing of a generic pull result using artifact type +func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) { + // First validate that this is actually a plugin artifact + manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest) + if err != nil { + return nil, fmt.Errorf("unable to retrieve manifest: %w", err) + } + + // Parse the manifest to check artifact type + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, fmt.Errorf("unable to parse manifest: %w", err) + } + + // Validate artifact type (for OCI v1.1+ manifests) + if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType { + return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType) + } + + // For backwards compatibility, also check config media type if no artifact type + if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType { + return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType) + } + + // Find the plugin tarball and optional provenance using NAME-VERSION.tgz format + var pluginDescriptor *ocispec.Descriptor + var provenanceDescriptor *ocispec.Descriptor + var foundProvenanceName string + + // Look for layers with the expected titles/annotations + for _, layer := range manifest.Layers { + d := layer + // Check for title annotation + if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists { + // Check if this looks like a plugin tarball: {pluginName}-{version}.tgz + if pluginDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz") { + pluginDescriptor = &d + } + // Check if this looks like a plugin provenance: {pluginName}-{version}.tgz.prov + if provenanceDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz.prov") { + provenanceDescriptor = &d + foundProvenanceName = title + } + } + } + + // Plugin tarball is required + if pluginDescriptor == nil { + return nil, fmt.Errorf("required layer matching pattern %s-VERSION.tgz not found in manifest", pluginName) + } + + // Build plugin-specific result + result := &PluginPullResult{ + Manifest: genericResult.Manifest, + Ref: genericResult.Ref, + PluginName: pluginName, + } + + // Fetch plugin data using generic client + genericClient := c.Generic() + result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err) + } + + // Fetch provenance data if available + if provenanceDescriptor != nil { + result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor) + if err != nil { + return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err) + } + } + + fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref) + fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) + if result.Prov.Data != nil { + fmt.Fprintf(c.out, "Provenance: %s\n", foundProvenanceName) + } + + if strings.Contains(result.Ref, "_") { + fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) + fmt.Fprint(c.out, registryUnderscoreMessage+"\n") + } + + return result, nil +} + +// Plugin pull operation types and options +type ( + pluginPullOperation struct { + pluginName string + withProv bool + } + + // PluginPullOption allows customizing plugin pull operations + PluginPullOption func(*pluginPullOperation) +) + +// PluginPullOptWithPluginName sets the plugin name for validation +func PluginPullOptWithPluginName(name string) PluginPullOption { + return func(operation *pluginPullOperation) { + operation.pluginName = name + } +} + +// GetPluginName extracts the plugin name from an OCI reference using proper reference parsing +func GetPluginName(source string) (string, error) { + ref, err := newReference(source) + if err != nil { + return "", fmt.Errorf("invalid OCI reference: %w", err) + } + + // Extract plugin name from the repository path + // e.g., "ghcr.io/user/plugin-name:v1.0.0" -> Repository: "user/plugin-name" + repository := ref.Repository + if repository == "" { + return "", fmt.Errorf("invalid OCI reference: missing repository") + } + + // Get the last part of the repository path as the plugin name + parts := strings.Split(repository, "/") + pluginName := parts[len(parts)-1] + + if pluginName == "" { + return "", fmt.Errorf("invalid OCI reference: cannot determine plugin name from repository %s", repository) + } + + return pluginName, nil +} + +// PullPluginOptWithProv configures the pull to fetch provenance data +func PullPluginOptWithProv(withProv bool) PluginPullOption { + return func(operation *pluginPullOperation) { + operation.withProv = withProv + } +} diff --git a/pkg/registry/plugin_test.go b/pkg/registry/plugin_test.go new file mode 100644 index 000000000..f8525829c --- /dev/null +++ b/pkg/registry/plugin_test.go @@ -0,0 +1,93 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package registry + +import ( + "testing" +) + +func TestGetPluginName(t *testing.T) { + tests := []struct { + name string + source string + expected string + expectErr bool + }{ + { + name: "valid OCI reference with tag", + source: "oci://ghcr.io/user/plugin-name:v1.0.0", + expected: "plugin-name", + }, + { + name: "valid OCI reference with digest", + source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef", + expected: "plugin-name", + }, + { + name: "valid OCI reference without tag", + source: "oci://ghcr.io/user/plugin-name", + expected: "plugin-name", + }, + { + name: "valid OCI reference with multiple path segments", + source: "oci://registry.example.com/org/team/plugin-name:latest", + expected: "plugin-name", + }, + { + name: "valid OCI reference with plus signs in tag", + source: "oci://registry.example.com/user/plugin-name:v1.0.0+build.1", + expected: "plugin-name", + }, + { + name: "valid OCI reference - single path segment", + source: "oci://registry.example.com/plugin", + expected: "plugin", + }, + { + name: "invalid OCI reference - no repository", + source: "oci://registry.example.com", + expectErr: true, + }, + { + name: "invalid OCI reference - malformed", + source: "not-an-oci-reference", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pluginName, err := GetPluginName(tt.source) + + if tt.expectErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if pluginName != tt.expected { + t.Errorf("expected plugin name %q, got %q", tt.expected, pluginName) + } + }) + } +} diff --git a/pkg/registry/transport.go b/pkg/registry/transport.go index a82229e2f..9d6a37326 100644 --- a/pkg/registry/transport.go +++ b/pkg/registry/transport.go @@ -32,7 +32,7 @@ import ( var ( // requestCount records the number of logged request-response pairs and will // be used as the unique id for the next pair. - requestCount uint64 + requestCount atomic.Uint64 // toScrub is a set of headers that should be scrubbed from the log. toScrub = []string{ @@ -79,7 +79,7 @@ func NewTransport(debug bool) *retry.Transport { // RoundTrip calls base round trip while keeping track of the current request. func (t *LoggingTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { - id := atomic.AddUint64(&requestCount, 1) - 1 + id := requestCount.Add(1) - 1 slog.Debug(req.Method, "id", id, "url", req.URL, "header", logHeader(req.Header)) resp, err = t.RoundTripper.RoundTrip(req) diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go index f4ff5bd58..b46317fc6 100644 --- a/pkg/registry/utils_test.go +++ b/pkg/registry/utils_test.go @@ -121,7 +121,7 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { pwBytes, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) suite.Nil(err, "no error generating bcrypt password for test htpasswd file") htpasswdPath := filepath.Join(suite.WorkspaceDir, testHtpasswdFileBasename) - err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0644) + err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0644) suite.Nil(err, "no error creating test htpasswd file") // Registry config diff --git a/pkg/release/util/sorter.go b/pkg/release/util/sorter.go index 949adbda9..1b09d0f3b 100644 --- a/pkg/release/util/sorter.go +++ b/pkg/release/util/sorter.go @@ -22,35 +22,6 @@ import ( rspb "helm.sh/helm/v4/pkg/release/v1" ) -type list []*rspb.Release - -func (s list) Len() int { return len(s) } -func (s list) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// ByName sorts releases by name -type ByName struct{ list } - -// Less compares to releases -func (s ByName) Less(i, j int) bool { return s.list[i].Name < s.list[j].Name } - -// ByDate sorts releases by date -type ByDate struct{ list } - -// Less compares to releases -func (s ByDate) Less(i, j int) bool { - ti := s.list[i].Info.LastDeployed.Unix() - tj := s.list[j].Info.LastDeployed.Unix() - return ti < tj -} - -// ByRevision sorts releases by revision number -type ByRevision struct{ list } - -// Less compares to releases -func (s ByRevision) Less(i, j int) bool { - return s.list[i].Version < s.list[j].Version -} - // Reverse reverses the list of releases sorted by the sort func. func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { sortFn(list) @@ -62,17 +33,25 @@ func Reverse(list []*rspb.Release, sortFn func([]*rspb.Release)) { // SortByName returns the list of releases sorted // in lexicographical order. func SortByName(list []*rspb.Release) { - sort.Sort(ByName{list}) + sort.Slice(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) } // SortByDate returns the list of releases sorted by a // release's last deployed time (in seconds). func SortByDate(list []*rspb.Release) { - sort.Sort(ByDate{list}) + sort.Slice(list, func(i, j int) bool { + ti := list[i].Info.LastDeployed.Unix() + tj := list[j].Info.LastDeployed.Unix() + return ti < tj + }) } // SortByRevision returns the list of releases sorted by a // release's revision number (release.Version). func SortByRevision(list []*rspb.Release) { - sort.Sort(ByRevision{list}) + sort.Slice(list, func(i, j int) bool { + return list[i].Version < list[j].Version + }) } diff --git a/pkg/release/v1/release.go b/pkg/release/v1/release.go index 74e834f7b..a7f076e04 100644 --- a/pkg/release/v1/release.go +++ b/pkg/release/v1/release.go @@ -19,6 +19,11 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" ) +type ApplyMethod string + +const ApplyMethodClientSideApply ApplyMethod = "csa" +const ApplyMethodServerSideApply ApplyMethod = "ssa" + // Release describes a deployment of a chart, together with the chart // and the variables used to deploy that chart. type Release struct { @@ -42,6 +47,9 @@ type Release struct { // Labels of the release. // Disabled encoding into Json cause labels are stored in storage driver metadata field. Labels map[string]string `json:"-"` + // ApplyMethod stores whether server-side or client-side apply was used for the release + // Unset (empty string) should be treated as the default of client-side apply + ApplyMethod string `json:"apply_method,omitempty"` // "ssa" | "csa" } // SetStatus is a helper for setting the status on a release. diff --git a/pkg/repo/index.go b/pkg/repo/index.go index c26d7581c..4de8bb463 100644 --- a/pkg/repo/index.go +++ b/pkg/repo/index.go @@ -355,7 +355,8 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { for name, cvs := range i.Entries { for idx := len(cvs) - 1; idx >= 0; idx-- { if cvs[idx] == nil { - slog.Warn("skipping loading invalid entry for chart %q from %s: empty entry", name, source) + slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q from %s: empty entry", name, source)) + cvs = append(cvs[:idx], cvs[idx+1:]...) continue } // When metadata section missing, initialize with no data @@ -366,7 +367,7 @@ func loadIndex(data []byte, source string) (*IndexFile, error) { cvs[idx].APIVersion = chart.APIVersionV1 } if err := cvs[idx].Validate(); ignoreSkippableChartValidationError(err) != nil { - slog.Warn("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err) + slog.Warn(fmt.Sprintf("skipping loading invalid entry for chart %q %q from %s: %s", name, cvs[idx].Version, source, err)) cvs = append(cvs[:idx], cvs[idx+1:]...) } } diff --git a/pkg/repo/index_test.go b/pkg/repo/index_test.go index d40719b12..a8aadadec 100644 --- a/pkg/repo/index_test.go +++ b/pkg/repo/index_test.go @@ -68,6 +68,7 @@ entries: grafana: - apiVersion: v2 name: grafana + - null foo: - bar: @@ -159,7 +160,6 @@ func TestLoadIndex(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.Name, func(t *testing.T) { t.Parallel() i, err := LoadIndexFile(tc.Filename) diff --git a/pkg/repo/repotest/server.go b/pkg/repo/repotest/server.go index 7ff028b90..8f9f82281 100644 --- a/pkg/repo/repotest/server.go +++ b/pkg/repo/repotest/server.go @@ -169,7 +169,7 @@ func NewOCIServer(t *testing.T, dir string) (*OCIServer, error) { t.Fatal("error generating bcrypt password for test htpasswd file") } htpasswdPath := filepath.Join(dir, testHtpasswdFileBasename) - err = os.WriteFile(htpasswdPath, []byte(fmt.Sprintf("%s:%s\n", testUsername, string(pwBytes))), 0o644) + err = os.WriteFile(htpasswdPath, fmt.Appendf(nil, "%s:%s\n", testUsername, string(pwBytes)), 0o644) if err != nil { t.Fatalf("error creating test htpasswd file") }