From 0dae3d6e886dd2007ca447c85582a7faabf72eb1 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 7 Aug 2025 12:34:26 +0100 Subject: [PATCH 01/55] chore: check if go modules are tidy before build Signed-off-by: Evans Mungai --- .github/workflows/build-test.yml | 2 ++ Makefile | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 11a5c49ec..0c3ff6596 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -28,6 +28,8 @@ jobs: check-latest: true - name: Test source headers are present run: make test-source-headers + - name: Check if go mod is tidy + run: go mod tidy -diff - name: Run unit tests run: make test-coverage - name: Test build diff --git a/Makefile b/Makefile index 0785fdb2e..6624c12bb 100644 --- a/Makefile +++ b/Makefile @@ -246,3 +246,7 @@ info: @echo "Git Tag: ${GIT_TAG}" @echo "Git Commit: ${GIT_COMMIT}" @echo "Git Tree State: ${GIT_DIRTY}" + +.PHONY: tidy +tidy: + go mod tidy From 0b367e8404b5679737a7898d06b2a858e21aaf0a Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 7 Aug 2025 12:47:35 +0100 Subject: [PATCH 02/55] Run go mod tidy Signed-off-by: Evans Mungai --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e2f8536a1..b0fef95bc 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/fatih/color v1.13.0 github.com/fluxcd/cli-utils v0.36.0-flux.14 github.com/foxcpp/go-mockdns v1.1.0 github.com/gobwas/glob v0.2.3 @@ -70,7 +71,6 @@ require ( github.com/docker/go-metrics v0.0.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/fatih/color v1.13.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/go-errors/errors v1.5.1 // indirect From af1c9570f518c0a2631d21e88170b41dfbafe8de Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 7 Aug 2025 13:42:27 +0100 Subject: [PATCH 03/55] Rename go mod tidy check task Signed-off-by: Evans Mungai --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 0c3ff6596..5456b143f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -28,7 +28,7 @@ jobs: check-latest: true - name: Test source headers are present run: make test-source-headers - - name: Check if go mod is tidy + - name: Check if go modules need to be tidied run: go mod tidy -diff - name: Run unit tests run: make test-coverage From 46c8caa4103a4cf19e80004abb552046db6e505c Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 7 Aug 2025 19:11:17 +0300 Subject: [PATCH 04/55] Add info target as part of build Signed-off-by: Evans Mungai --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6624c12bb..64780b0d8 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ all: build # build .PHONY: build -build: $(BINDIR)/$(BINNAME) +build: $(BINDIR)/$(BINNAME) info $(BINDIR)/$(BINNAME): $(SRC) CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/helm From 064a18ff79c74f1d0ffa0a68ea501fdb97b42d11 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Thu, 7 Aug 2025 17:50:26 +0100 Subject: [PATCH 05/55] Update Makefile Co-authored-by: Terry Howe Signed-off-by: Evans Mungai --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 64780b0d8..d1d6d0982 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ all: build # build .PHONY: build -build: $(BINDIR)/$(BINNAME) info +build: $(BINDIR)/$(BINNAME) tidy $(BINDIR)/$(BINNAME): $(SRC) CGO_ENABLED=$(CGO_ENABLED) go build $(GOFLAGS) -trimpath -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)'/$(BINNAME) ./cmd/helm From 533eddc57d4727f1422d6e8a3e5d8fa6fbf8697e Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Fri, 22 Aug 2025 15:41:47 -0400 Subject: [PATCH 06/55] Add content cache to helm env Signed-off-by: Matt Farina --- pkg/cli/environment.go | 1 + pkg/cmd/testdata/output/env-comp.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 19563cba3..106d24336 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -249,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/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 From c8e51b40c23e388646ba6987f32e68af19c1850e Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Mon, 25 Aug 2025 13:25:38 -0700 Subject: [PATCH 07/55] Plugin extism/v1 runtime Signed-off-by: George Jenkins --- go.mod | 7 +- go.sum | 10 + internal/plugin/config.go | 1 - internal/plugin/loader.go | 23 +- internal/plugin/metadata.go | 6 + internal/plugin/plugin_type_registry.go | 100 ++++++ internal/plugin/plugin_type_registry_test.go | 38 +++ internal/plugin/runtime.go | 28 +- internal/plugin/runtime_extismv1.go | 297 ++++++++++++++++++ internal/plugin/runtime_extismv1_test.go | 124 ++++++++ internal/plugin/runtime_test.go | 63 ++++ internal/plugin/schema/test.go | 28 ++ .../testdata/src/extismv1-test/.gitignore | 1 + .../testdata/src/extismv1-test/Makefile | 12 + .../plugin/testdata/src/extismv1-test/go.mod | 5 + .../plugin/testdata/src/extismv1-test/go.sum | 2 + .../plugin/testdata/src/extismv1-test/main.go | 61 ++++ .../testdata/src/extismv1-test/plugin.yaml | 6 + 18 files changed, 806 insertions(+), 6 deletions(-) create mode 100644 internal/plugin/plugin_type_registry.go create mode 100644 internal/plugin/plugin_type_registry_test.go create mode 100644 internal/plugin/runtime_extismv1.go create mode 100644 internal/plugin/runtime_extismv1_test.go create mode 100644 internal/plugin/runtime_test.go create mode 100644 internal/plugin/schema/test.go create mode 100644 internal/plugin/testdata/src/extismv1-test/.gitignore create mode 100644 internal/plugin/testdata/src/extismv1-test/Makefile create mode 100644 internal/plugin/testdata/src/extismv1-test/go.mod create mode 100644 internal/plugin/testdata/src/extismv1-test/go.sum create mode 100644 internal/plugin/testdata/src/extismv1-test/main.go create mode 100644 internal/plugin/testdata/src/extismv1-test/plugin.yaml diff --git a/go.mod b/go.mod index c28405240..8cff102c9 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,13 +26,14 @@ 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 // indirect + 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.11.0 + 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 @@ -71,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 @@ -95,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 @@ -130,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 diff --git a/go.sum b/go.sum index f4f54ecdc..9b41a7c39 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= @@ -311,6 +317,10 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/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= diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 812dba7f6..83a2e0b25 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -23,7 +23,6 @@ import ( // Config interface defines the methods that all plugin type configurations must implement type Config interface { - GetType() string Validate() error } diff --git a/internal/plugin/loader.go b/internal/plugin/loader.go index eb05cb722..a58a84126 100644 --- a/internal/plugin/loader.go +++ b/internal/plugin/loader.go @@ -22,7 +22,11 @@ import ( "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) { @@ -101,12 +105,22 @@ type prototypePluginManager struct { runtimes map[string]Runtime } -func newPrototypePluginManager() *prototypePluginManager { +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) { @@ -135,7 +149,10 @@ func LoadDir(dirname string) (Plugin, error) { return nil, fmt.Errorf("failed to load plugin %q: %w", dirname, err) } - pm := newPrototypePluginManager() + pm, err := newPrototypePluginManager() + if err != nil { + return nil, fmt.Errorf("failed to create plugin manager: %w", err) + } return pm.CreatePlugin(dirname, m) } diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index 48741474e..bb7e9409f 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -18,6 +18,8 @@ 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 @@ -183,6 +185,8 @@ func convertMetadataConfig(pluginType string, configRaw map[string]any) (Config, 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": @@ -205,6 +209,8 @@ func convertMetdataRuntimeConfig(runtimeType string, runtimeConfigRaw map[string 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) } 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 index 8add92dea..a9c01a380 100644 --- a/internal/plugin/runtime.go +++ b/internal/plugin/runtime.go @@ -15,7 +15,11 @@ limitations under the License. package plugin -import "go.yaml.in/yaml/v3" +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 @@ -47,3 +51,25 @@ func remarshalRuntimeConfig[T RuntimeConfig](runtimeData map[string]any) (Runtim 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..d3ecff182 --- /dev/null +++ b/internal/plugin/runtime_extismv1.go @@ -0,0 +1,297 @@ +/* +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 ExtistmV1WasmBinaryFilename = "plugin.wasm" + +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"` +} + +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"` + + // // An optional set of mappings between the host's filesystem and the paths a plugin can access. + // TODO: shuld Helm expose this? + //AllowedPaths map[string]string `yaml:"allowedPaths,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) + } + + fmt.Printf("Creating extism/v1 plugin %q with config: %+v\n", metadata.Name, rc) + + wasmFile := filepath.Join(pluginDir, ExtistmV1WasmBinaryFilename) + 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 { + tmpDir, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*") + slog.Debug("created plugin temp dir", slog.String("dir", tmpDir), 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())) + } + }() + } + + manifest, err := buildManifest(p.dir, tmpDir, p.rc) + if err != nil { + return nil, err + } + + config, err := buildPluginConfig(input, p.r) + if err != nil { + return nil, err + } + + 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 marshel 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 marshel 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, ExtistmV1WasmBinaryFilename) + + 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, error) { + + 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, nil +} + +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_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/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/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..40311329d --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/main.go @@ -0,0 +1,61 @@ +package main + +import ( + _ "embed" + "fmt" + + 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 + return &OutputMessageTestV1{ + Greeting: fmt.Sprintf("Hello, %s! (%d)", name, len(name)), + }, 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..2d3694fe6 --- /dev/null +++ b/internal/plugin/testdata/src/extismv1-test/plugin.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: v1 +type: test/v1 +name: extismv1-test +version: 0.1.0 +runtime: extism/v1 \ No newline at end of file From b6545e903a1184423a09b4728d5e8a6215c651e1 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Wed, 27 Aug 2025 08:18:36 -0700 Subject: [PATCH 08/55] code review + bug fixes Signed-off-by: George Jenkins --- internal/plugin/runtime_extismv1.go | 33 ++++++++----------- .../plugin/testdata/src/extismv1-test/main.go | 9 ++++- .../testdata/src/extismv1-test/plugin.yaml | 5 ++- pkg/getter/plugingetter.go | 2 +- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go index d3ecff182..a39fc2d48 100644 --- a/internal/plugin/runtime_extismv1.go +++ b/internal/plugin/runtime_extismv1.go @@ -28,8 +28,9 @@ import ( "github.com/tetratelabs/wazero" ) -const ExtistmV1WasmBinaryFilename = "plugin.wasm" +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. @@ -45,15 +46,13 @@ type RuntimeConfigExtismV1Memory struct { 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"` - - // // An optional set of mappings between the host's filesystem and the paths a plugin can access. - // TODO: shuld Helm expose this? - //AllowedPaths map[string]string `yaml:"allowedPaths,omitempty"` } // RuntimeConfigExtismV1 defines the user-configurable options the plugin's Extism runtime @@ -106,9 +105,7 @@ func (r *RuntimeExtismV1) CreatePlugin(pluginDir string, metadata *Metadata) (Pl return nil, fmt.Errorf("invalid extism/v1 plugin runtime config type: %T", metadata.RuntimeConfig) } - fmt.Printf("Creating extism/v1 plugin %q with config: %+v\n", metadata.Name, rc) - - wasmFile := filepath.Join(pluginDir, ExtistmV1WasmBinaryFilename) + 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) @@ -145,7 +142,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp var tmpDir string if p.rc.FileSystem.CreateTempDir { - tmpDir, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*") + tmpDirInner, err := os.MkdirTemp(os.TempDir(), "helm-plugin-*") slog.Debug("created plugin temp dir", slog.String("dir", tmpDir), slog.String("plugin", p.metadata.Name)) if err != nil { return nil, fmt.Errorf("failed to create temp dir for extism compilation cache: %w", err) @@ -155,6 +152,8 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp 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) @@ -162,10 +161,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp return nil, err } - config, err := buildPluginConfig(input, p.r) - if err != nil { - return nil, err - } + config := buildPluginConfig(input, p.r) hostFunctions, err := buildHostFunctions(p.r.HostFunctions, p.rc) if err != nil { @@ -183,7 +179,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp inputData, err := json.Marshal(input.Message) if err != nil { - return nil, fmt.Errorf("failed to json marshel plugin input message: %T: %w", input.Message, err) + 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))) @@ -208,7 +204,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp outputMessage := reflect.New(pluginTypesIndex[p.metadata.Type].outputType) if err := json.Unmarshal(outputData, outputMessage.Interface()); err != nil { - return nil, fmt.Errorf("failed to json marshel plugin output message: %T: %w", outputMessage, err) + return nil, fmt.Errorf("failed to json marshal plugin output message: %T: %w", outputMessage, err) } output := &Output{ @@ -219,7 +215,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp } func buildManifest(pluginDir string, tmpDir string, rc *RuntimeConfigExtismV1) (extism.Manifest, error) { - wasmFile := filepath.Join(pluginDir, ExtistmV1WasmBinaryFilename) + wasmFile := filepath.Join(pluginDir, ExtismV1WasmBinaryFilename) allowedHosts := rc.AllowedHosts if allowedHosts == nil { @@ -250,8 +246,7 @@ func buildManifest(pluginDir string, tmpDir string, rc *RuntimeConfigExtismV1) ( }, nil } -func buildPluginConfig(input *Input, r *RuntimeExtismV1) (extism.PluginConfig, error) { - +func buildPluginConfig(input *Input, r *RuntimeExtismV1) extism.PluginConfig { mc := wazero.NewModuleConfig(). WithSysWalltime() if input.Stdin != nil { @@ -279,7 +274,7 @@ func buildPluginConfig(input *Input, r *RuntimeExtismV1) (extism.PluginConfig, e EnableHttpResponseHeaders: true, } - return config, nil + return config } func buildHostFunctions(hostFunctions map[string]extism.HostFunction, rc *RuntimeConfigExtismV1) ([]extism.HostFunction, error) { diff --git a/internal/plugin/testdata/src/extismv1-test/main.go b/internal/plugin/testdata/src/extismv1-test/main.go index 40311329d..31c739a5b 100644 --- a/internal/plugin/testdata/src/extismv1-test/main.go +++ b/internal/plugin/testdata/src/extismv1-test/main.go @@ -3,6 +3,7 @@ package main import ( _ "embed" "fmt" + "os" pdk "github.com/extism/go-pdk" ) @@ -19,8 +20,14 @@ 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: fmt.Sprintf("Hello, %s! (%d)", name, len(name)), + Greeting: greeting, }, nil } diff --git a/internal/plugin/testdata/src/extismv1-test/plugin.yaml b/internal/plugin/testdata/src/extismv1-test/plugin.yaml index 2d3694fe6..fea1e3f66 100644 --- a/internal/plugin/testdata/src/extismv1-test/plugin.yaml +++ b/internal/plugin/testdata/src/extismv1-test/plugin.yaml @@ -3,4 +3,7 @@ apiVersion: v1 type: test/v1 name: extismv1-test version: 0.1.0 -runtime: extism/v1 \ No newline at end of file +runtime: extism/v1 +runtimeConfig: + fileSystem: + createTempDir: true \ No newline at end of file diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index 2b7669f23..b2dfb3e42 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -116,7 +116,7 @@ func (g *getterPlugin) Get(href string, options ...Option) (*bytes.Buffer, error return nil, fmt.Errorf("plugin %q failed to invoke: %w", g.plg, err) } - outputMessage, ok := output.Message.(*schema.OutputMessageGetterV1) + outputMessage, ok := output.Message.(schema.OutputMessageGetterV1) if !ok { return nil, fmt.Errorf("invalid output message type from plugin %q", g.plg.Metadata().Name) } From 284bd980b67f2be59b989abcc30ed2549936f2a6 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Wed, 27 Aug 2025 04:52:18 -0600 Subject: [PATCH 09/55] fix: replace pkg/engine regular expressions with parser Signed-off-by: Terry Howe --- pkg/engine/engine.go | 131 ++++++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 46 deletions(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 6e47a0e39..b42fa3219 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -34,18 +34,6 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) -// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 -// > "template: %s: executing %q at <%s>: %s" -var execErrFmt = regexp.MustCompile(`^template: (?P(?U).+): executing (?P(?U).+) at (?P(?U).+): (?P(?U).+)(?P( template:.*)?)$`) - -// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 -// > "template: %s: %s" -var execErrFmtWithoutTemplate = regexp.MustCompile(`^template: (?P(?U).+): (?P.*)(?P( template:.*)?)$`) - -// taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 -// > "template: no template %q associated with template %q" -var execErrNoTemplateAssociated = regexp.MustCompile(`^template: no template (?P.*) associated with template (?P(.*)?)$`) - // Engine is an implementation of the Helm rendering implementation for templates. type Engine struct { // If strict is enabled, template rendering will fail if a template references @@ -338,7 +326,7 @@ func cleanupParseError(filename string, err error) error { location := tokens[1] // The remaining tokens make up a stacktrace-like chain, ending with the relevant error errMsg := tokens[len(tokens)-1] - return fmt.Errorf("parse error at (%s): %s", string(location), errMsg) + return fmt.Errorf("parse error at (%s): %s", location, errMsg) } type TraceableError struct { @@ -350,25 +338,97 @@ type TraceableError struct { func (t TraceableError) String() string { var errorString strings.Builder if t.location != "" { - fmt.Fprintf(&errorString, "%s\n ", t.location) + _, _ = fmt.Fprintf(&errorString, "%s\n ", t.location) } if t.executedFunction != "" { - fmt.Fprintf(&errorString, "%s\n ", t.executedFunction) + _, _ = fmt.Fprintf(&errorString, "%s\n ", t.executedFunction) } if t.message != "" { - fmt.Fprintf(&errorString, "%s\n", t.message) + _, _ = fmt.Fprintf(&errorString, "%s\n", t.message) } return errorString.String() } +// parseTemplateExecErrorString parses a template execution error string from text/template +// without using regular expressions. It returns a TraceableError and true if parsing succeeded. +func parseTemplateExecErrorString(s string) (TraceableError, bool) { + const prefix = "template: " + if !strings.HasPrefix(s, prefix) { + return TraceableError{}, false + } + remainder := s[len(prefix):] + + // Special case: "template: no template %q associated with template %q" + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 + if strings.HasPrefix(remainder, "no template ") { + return TraceableError{message: s}, true + } + + // Executing form: ": executing \"\" at <>: [ template:...]" + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 + if idx := strings.Index(remainder, ": executing "); idx != -1 { + templateName := remainder[:idx] + after := remainder[idx+len(": executing "):] + if len(after) == 0 || after[0] != '"' { + return TraceableError{}, false + } + // find closing quote for function name + endQuote := strings.IndexByte(after[1:], '"') + if endQuote == -1 { + return TraceableError{}, false + } + endQuote++ // account for offset we started at 1 + functionName := after[1:endQuote] + afterFunc := after[endQuote+1:] + + // expect: " at <" then location then ">: " then message + const atPrefix = " at <" + if !strings.HasPrefix(afterFunc, atPrefix) { + return TraceableError{}, false + } + afterAt := afterFunc[len(atPrefix):] + endLoc := strings.Index(afterAt, ">: ") + if endLoc == -1 { + return TraceableError{}, false + } + locationName := afterAt[:endLoc] + errMsg := afterAt[endLoc+len(">: "):] + + // trim chained next error starting with space + "template:" if present + if cut := strings.Index(errMsg, " template:"); cut != -1 { + errMsg = errMsg[:cut] + } + return TraceableError{ + location: templateName, + message: errMsg, + executedFunction: "executing \"" + functionName + "\" at <" + locationName + ">:", + }, true + } + + // Simple form: ": " + // Use LastIndex to avoid splitting colons within line:col info. + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 + if sep := strings.LastIndex(remainder, ": "); sep != -1 { + templateName := remainder[:sep] + errMsg := remainder[sep+2:] + if cut := strings.Index(errMsg, " template:"); cut != -1 { + errMsg = errMsg[:cut] + } + return TraceableError{location: templateName, message: errMsg}, true + } + + return TraceableError{}, false +} + // reformatExecErrorMsg takes an error message for template rendering and formats it into a formatted // multi-line error string func reformatExecErrorMsg(filename string, err error) error { - // This function matches the error message against regex's for the text/template package. - // If the regex's can parse out details from that error message such as the line number, template it failed on, + // This function parses the error message produced by text/template package. + // If it can parse out details from that error message such as the line number, template it failed on, // and error description, then it will construct a new error that displays these details in a structured way. // If there are issues with parsing the error message, the err passed into the function should return instead. - if _, isExecError := err.(template.ExecError); !isExecError { + var execError template.ExecError + if !errors.As(err, &execError) { return err } @@ -384,45 +444,24 @@ func reformatExecErrorMsg(filename string, err error) error { parts := warnRegex.FindStringSubmatch(tokens[2]) if len(parts) >= 2 { - return fmt.Errorf("execution error at (%s): %s", string(location), parts[1]) + return fmt.Errorf("execution error at (%s): %s", location, parts[1]) } current := err - fileLocations := []TraceableError{} + var fileLocations []TraceableError for current != nil { - var traceable TraceableError - if matches := execErrFmt.FindStringSubmatch(current.Error()); matches != nil { - templateName := matches[execErrFmt.SubexpIndex("templateName")] - functionName := matches[execErrFmt.SubexpIndex("functionName")] - locationName := matches[execErrFmt.SubexpIndex("location")] - errMsg := matches[execErrFmt.SubexpIndex("errMsg")] - traceable = TraceableError{ - location: templateName, - message: errMsg, - executedFunction: "executing " + functionName + " at " + locationName + ":", - } - } else if matches := execErrFmtWithoutTemplate.FindStringSubmatch(current.Error()); matches != nil { - templateName := matches[execErrFmt.SubexpIndex("templateName")] - errMsg := matches[execErrFmt.SubexpIndex("errMsg")] - traceable = TraceableError{ - location: templateName, - message: errMsg, - } - } else if matches := execErrNoTemplateAssociated.FindStringSubmatch(current.Error()); matches != nil { - traceable = TraceableError{ - message: current.Error(), + if tr, ok := parseTemplateExecErrorString(current.Error()); ok { + if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != tr { + fileLocations = append(fileLocations, tr) } } else { return err } - if len(fileLocations) == 0 || fileLocations[len(fileLocations)-1] != traceable { - fileLocations = append(fileLocations, traceable) - } current = errors.Unwrap(current) } var finalErrorString strings.Builder for _, fileLocation := range fileLocations { - fmt.Fprintf(&finalErrorString, "%s", fileLocation.String()) + _, _ = fmt.Fprintf(&finalErrorString, "%s", fileLocation.String()) } return errors.New(strings.TrimSpace(finalErrorString.String())) From 6273f9b38e22632b3b36b3cd142ad461f97c5e96 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Wed, 27 Aug 2025 08:18:26 -0600 Subject: [PATCH 10/55] fix: flaky registry data race on mockdns close Signed-off-by: Terry Howe --- pkg/registry/client_http_test.go | 5 +---- pkg/registry/client_insecure_tls_test.go | 5 +---- pkg/registry/client_tls_test.go | 5 +---- pkg/registry/utils_test.go | 20 ++++++++++---------- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/pkg/registry/client_http_test.go b/pkg/registry/client_http_test.go index 043fd4205..dddd29ee9 100644 --- a/pkg/registry/client_http_test.go +++ b/pkg/registry/client_http_test.go @@ -32,10 +32,7 @@ type HTTPRegistryClientTestSuite struct { func (suite *HTTPRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, false, false) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestSuite, false, false) } func (suite *HTTPRegistryClientTestSuite) TearDownSuite() { diff --git a/pkg/registry/client_insecure_tls_test.go b/pkg/registry/client_insecure_tls_test.go index accbf1670..03354475a 100644 --- a/pkg/registry/client_insecure_tls_test.go +++ b/pkg/registry/client_insecure_tls_test.go @@ -29,10 +29,7 @@ type InsecureTLSRegistryClientTestSuite struct { func (suite *InsecureTLSRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, true, true) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestSuite, true, true) } func (suite *InsecureTLSRegistryClientTestSuite) TearDownSuite() { diff --git a/pkg/registry/client_tls_test.go b/pkg/registry/client_tls_test.go index 0897858b5..2bf1750a9 100644 --- a/pkg/registry/client_tls_test.go +++ b/pkg/registry/client_tls_test.go @@ -31,10 +31,7 @@ type TLSRegistryClientTestSuite struct { func (suite *TLSRegistryClientTestSuite) SetupSuite() { // init test client - dockerRegistry := setup(&suite.TestSuite, true, false) - - // Start Docker registry - go dockerRegistry.ListenAndServe() + setup(&suite.TestSuite, true, false) } func (suite *TLSRegistryClientTestSuite) TearDownSuite() { diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go index b46317fc6..781f3dd75 100644 --- a/pkg/registry/utils_test.go +++ b/pkg/registry/utils_test.go @@ -29,7 +29,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" "github.com/distribution/distribution/v3/configuration" @@ -65,12 +64,13 @@ type TestSuite struct { CompromisedRegistryHost string WorkspaceDir string RegistryClient *Client + dockerRegistry *registry.Registry // A mock DNS server needed for TLS connection testing. srv *mockdns.Server } -func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { +func setup(suite *TestSuite, tlsEnabled, insecure bool) { suite.WorkspaceDir = testWorkspaceDir os.RemoveAll(suite.WorkspaceDir) os.Mkdir(suite.WorkspaceDir, 0700) @@ -166,20 +166,20 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) *registry.Registry { config.HTTP.TLS.ClientCAs = []string{tlsCA} } } - dockerRegistry, err := registry.NewRegistry(context.Background(), config) + suite.dockerRegistry, err = registry.NewRegistry(context.Background(), config) suite.Nil(err, "no error creating test registry") suite.CompromisedRegistryHost = initCompromisedRegistryTestServer() - return dockerRegistry + go func() { + _ = suite.dockerRegistry.ListenAndServe() + _ = suite.srv.Close() + mockdns.UnpatchNet(net.DefaultResolver) + }() } func teardown(suite *TestSuite) { - var lock sync.Mutex - lock.Lock() - defer lock.Unlock() - if suite.srv != nil { - mockdns.UnpatchNet(net.DefaultResolver) - suite.srv.Close() + if suite.dockerRegistry != nil { + _ = suite.dockerRegistry.Shutdown(context.Background()) } } From ce97a2449e24bc7985e1ad614b0a8b1713d10894 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Wed, 27 Aug 2025 10:41:46 -0600 Subject: [PATCH 11/55] fix: move mockdns to packge level Signed-off-by: Terry Howe --- pkg/registry/main_test.go | 51 ++++++++++++++++++++++++++++++++++++++ pkg/registry/utils_test.go | 13 ---------- 2 files changed, 51 insertions(+), 13 deletions(-) create mode 100644 pkg/registry/main_test.go diff --git a/pkg/registry/main_test.go b/pkg/registry/main_test.go new file mode 100644 index 000000000..4f6e11e4f --- /dev/null +++ b/pkg/registry/main_test.go @@ -0,0 +1,51 @@ +/* +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 ( + "net" + "os" + "testing" + + "github.com/foxcpp/go-mockdns" +) + +func TestMain(m *testing.M) { + // A mock DNS server needed for TLS connection testing. + var srv *mockdns.Server + var err error + + srv, err = mockdns.NewServer(map[string]mockdns.Zone{ + "helm-test-registry.": { + A: []string{"127.0.0.1"}, + }, + }, false) + if err != nil { + panic(err) + } + + saveDialFunction := net.DefaultResolver.Dial + srv.PatchNet(net.DefaultResolver) + + // Run all tests in the package + code := m.Run() + + net.DefaultResolver.Dial = saveDialFunction + _ = srv.Close() + + os.Exit(code) +} diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go index 781f3dd75..1da90566f 100644 --- a/pkg/registry/utils_test.go +++ b/pkg/registry/utils_test.go @@ -35,7 +35,6 @@ import ( "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/auth/htpasswd" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" - "github.com/foxcpp/go-mockdns" "github.com/stretchr/testify/suite" "golang.org/x/crypto/bcrypt" @@ -65,9 +64,6 @@ type TestSuite struct { WorkspaceDir string RegistryClient *Client dockerRegistry *registry.Registry - - // A mock DNS server needed for TLS connection testing. - srv *mockdns.Server } func setup(suite *TestSuite, tlsEnabled, insecure bool) { @@ -135,13 +131,6 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) { // host is localhost/127.0.0.1. port := ln.Addr().(*net.TCPAddr).Port suite.DockerRegistryHost = fmt.Sprintf("helm-test-registry:%d", port) - suite.srv, err = mockdns.NewServer(map[string]mockdns.Zone{ - "helm-test-registry.": { - A: []string{"127.0.0.1"}, - }, - }, false) - suite.Nil(err, "no error creating mock DNS server") - suite.srv.PatchNet(net.DefaultResolver) config.HTTP.Addr = ln.Addr().String() config.HTTP.DrainTimeout = time.Duration(10) * time.Second @@ -172,8 +161,6 @@ func setup(suite *TestSuite, tlsEnabled, insecure bool) { suite.CompromisedRegistryHost = initCompromisedRegistryTestServer() go func() { _ = suite.dockerRegistry.ListenAndServe() - _ = suite.srv.Close() - mockdns.UnpatchNet(net.DefaultResolver) }() } From e5b612626e21a7b90caa4df546a7628da06b7c51 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Wed, 27 Aug 2025 10:13:27 -0700 Subject: [PATCH 12/55] fixup slog tmpDirInner Signed-off-by: George Jenkins --- internal/plugin/runtime_extismv1.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go index a39fc2d48..c0122d08f 100644 --- a/internal/plugin/runtime_extismv1.go +++ b/internal/plugin/runtime_extismv1.go @@ -143,7 +143,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp 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", tmpDir), slog.String("plugin", p.metadata.Name)) + 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) } From 2658a00863a9dd13cb023b68707d1a82cbd1e9ed Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Wed, 27 Aug 2025 10:21:16 -0700 Subject: [PATCH 13/55] fix output message value Signed-off-by: George Jenkins --- internal/plugin/runtime_subprocess.go | 2 +- internal/plugin/runtime_subprocess_getter.go | 2 +- pkg/getter/plugingetter_test.go | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index 286c1abeb..163f0621f 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -212,7 +212,7 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { } return &Output{ - Message: &schema.OutputMessageCLIV1{}, + Message: schema.OutputMessageCLIV1{}, }, nil } diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go index 6f9bfea91..af2d0c572 100644 --- a/internal/plugin/runtime_subprocess_getter.go +++ b/internal/plugin/runtime_subprocess_getter.go @@ -85,7 +85,7 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { } return &Output{ - Message: &schema.OutputMessageGetterV1{ + Message: schema.OutputMessageGetterV1{ Data: buf.Bytes(), }, }, nil diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 1c0f5593f..8e0619635 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -95,16 +95,16 @@ func TestConvertOptions(t *testing.T) { assert.Equal(t, expected, opts) } -type TestPlugin struct { +type testPlugin struct { t *testing.T dir string } -func (t *TestPlugin) Dir() string { +func (t *testPlugin) Dir() string { return t.dir } -func (t *TestPlugin) Metadata() plugin.Metadata { +func (t *testPlugin) Metadata() plugin.Metadata { return plugin.Metadata{ Name: "fake-plugin", Type: "cli/v1", @@ -121,22 +121,22 @@ func (t *TestPlugin) Metadata() plugin.Metadata { } } -func (t *TestPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) { +func (t *testPlugin) Invoke(_ context.Context, _ *plugin.Input) (*plugin.Output, error) { // Simulate a plugin invocation output := &plugin.Output{ - Message: &schema.OutputMessageGetterV1{ + Message: schema.OutputMessageGetterV1{ Data: []byte("fake-plugin output"), }, } return output, nil } -var _ plugin.Plugin = (*TestPlugin)(nil) +var _ plugin.Plugin = (*testPlugin)(nil) func TestGetterPlugin(t *testing.T) { gp := getterPlugin{ options: []Option{}, - plg: &TestPlugin{t: t, dir: "fake/dir"}, + plg: &testPlugin{t: t, dir: "fake/dir"}, } buf, err := gp.Get("test://example.com", WithTimeout(5*time.Second)) From d985122a2686dc88d92558583c1de950d5890887 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:40:28 +0000 Subject: [PATCH 14/55] chore(deps): bump github.com/stretchr/testify from 1.11.0 to 1.11.1 Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.0 to 1.11.1. - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.11.0...v1.11.1) --- updated-dependencies: - dependency-name: github.com/stretchr/testify dependency-version: 1.11.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8cff102c9..7099e9d46 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( 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.11.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 diff --git a/go.sum b/go.sum index 9b41a7c39..1a1601366 100644 --- a/go.sum +++ b/go.sum @@ -315,8 +315,8 @@ 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.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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= From 15bbb4406c72ed5a9981a7eb67fbbfa334b9948a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 21:13:46 +0000 Subject: [PATCH 15/55] chore(deps): bump the k8s-io group with 7 updates Bumps the k8s-io group with 7 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.33.4` | `0.34.0` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.33.4` | `0.34.0` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.33.4` | `0.34.0` | | [k8s.io/apiserver](https://github.com/kubernetes/apiserver) | `0.33.4` | `0.34.0` | | [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `0.33.4` | `0.34.0` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.33.4` | `0.34.0` | | [k8s.io/kubectl](https://github.com/kubernetes/kubectl) | `0.33.4` | `0.34.0` | Updates `k8s.io/api` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/api/compare/v0.33.4...v0.34.0) Updates `k8s.io/apiextensions-apiserver` from 0.33.4 to 0.34.0 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.33.4...v0.34.0) Updates `k8s.io/apimachinery` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.33.4...v0.34.0) Updates `k8s.io/apiserver` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/apiserver/compare/v0.33.4...v0.34.0) Updates `k8s.io/cli-runtime` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/cli-runtime/compare/v0.33.4...v0.34.0) Updates `k8s.io/client-go` from 0.33.4 to 0.34.0 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.33.4...v0.34.0) Updates `k8s.io/kubectl` from 0.33.4 to 0.34.0 - [Commits](https://github.com/kubernetes/kubectl/compare/v0.33.4...v0.34.0) --- updated-dependencies: - dependency-name: k8s.io/api dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/apiextensions-apiserver dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/apimachinery dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/apiserver dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/cli-runtime dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/client-go dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io - dependency-name: k8s.io/kubectl dependency-version: 0.34.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: k8s-io ... Signed-off-by: dependabot[bot] --- go.mod | 45 ++++++++++++++-------------- go.sum | 92 ++++++++++++++++++++++++++++------------------------------ 2 files changed, 66 insertions(+), 71 deletions(-) diff --git a/go.mod b/go.mod index 7099e9d46..3c9992dce 100644 --- a/go.mod +++ b/go.mod @@ -39,14 +39,14 @@ require ( golang.org/x/term v0.34.0 golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.33.4 - k8s.io/apiextensions-apiserver v0.33.4 - k8s.io/apimachinery v0.33.4 - k8s.io/apiserver v0.33.4 - k8s.io/cli-runtime v0.33.4 - k8s.io/client-go v0.33.4 + k8s.io/api v0.34.0 + k8s.io/apiextensions-apiserver v0.34.0 + k8s.io/apimachinery v0.34.0 + k8s.io/apiserver v0.34.0 + k8s.io/cli-runtime v0.34.0 + k8s.io/client-go v0.34.0 k8s.io/klog/v2 v2.130.1 - k8s.io/kubectl v0.33.4 + k8s.io/kubectl v0.34.0 oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/kustomize/kyaml v0.20.1 @@ -61,7 +61,6 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/bshuster-repo/logrus-logstash-hook v1.0.0 // indirect - github.com/carapace-sh/carapace-shlex v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect @@ -77,7 +76,7 @@ require ( github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -94,7 +93,7 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -115,7 +114,7 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect @@ -146,8 +145,8 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect @@ -155,11 +154,11 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect go.opentelemetry.io/otel/log v0.8.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.33.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect @@ -168,18 +167,18 @@ require ( golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.35.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/grpc v1.68.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/grpc v1.72.1 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/component-base v0.33.4 // indirect - k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 // indirect + k8s.io/component-base v0.34.0 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect - sigs.k8s.io/kustomize/api v0.20.0 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) diff --git a/go.sum b/go.sum index 1a1601366..d9e7c3d3d 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,6 @@ github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdb github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c= -github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -97,8 +95,8 @@ github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7Dlme github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= @@ -142,7 +140,6 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -160,8 +157,8 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= @@ -233,8 +230,9 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -346,10 +344,10 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7Z go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= @@ -364,16 +362,16 @@ go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWer go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= -go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -488,12 +486,12 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= -google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= -google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= @@ -510,26 +508,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.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/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= +k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= +k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= +k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= +k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= +k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 h1:gAXU86Fmbr/ktY17lkHwSjw5aoThQvhnstGGIYKlKYc= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911/go.mod h1:GLOk5B+hDbRROvt0X2+hqX64v/zO3vXN7J78OUmBSKw= -k8s.io/kubectl v0.33.4 h1:nXEI6Vi+oB9hXxoAHyHisXolm/l1qutK3oZQMak4N98= -k8s.io/kubectl v0.33.4/go.mod h1:Xe7P9X4DfILvKmlBsVqUtzktkI56lEj22SJW7cFy6nE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs= +k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4= 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= @@ -538,15 +536,13 @@ sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytI sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.20.0 h1:xPLqcobHI0bThyRUteO+nCV8G4d1Rlo5HafO57VRcas= -sigs.k8s.io/kustomize/api v0.20.0/go.mod h1:F6CfaV27oevRCMJgehLqyX81dlUnRX/Fc13Uo7+OSo4= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From b12cd28503ffdc8fc28d14cd635690e38def629f Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Wed, 27 Aug 2025 04:02:28 -0600 Subject: [PATCH 16/55] fix: installer action goroutine count Signed-off-by: Terry Howe --- pkg/action/install.go | 11 ++++++++++- pkg/action/install_test.go | 29 +++++++++++++---------------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/action/install.go b/pkg/action/install.go index 276009b5c..b5b45bd42 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -30,6 +30,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "text/template" "time" @@ -126,7 +127,8 @@ type Install struct { TakeOwnership bool PostRenderer postrender.PostRenderer // Lock to control raceconditions when the process receives a SIGTERM - Lock sync.Mutex + Lock sync.Mutex + goroutineCount atomic.Int32 } // ChartPathOptions captures common options used for controlling chart paths @@ -446,8 +448,10 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t resultChan := make(chan Msg, 1) go func() { + i.goroutineCount.Add(1) rel, err := i.performInstall(rel, toBeAdopted, resources) resultChan <- Msg{rel, err} + i.goroutineCount.Add(-1) }() select { case <-ctx.Done(): @@ -458,6 +462,11 @@ func (i *Install) performInstallCtx(ctx context.Context, rel *release.Release, t } } +// getGoroutineCount return the number of running routines +func (i *Install) getGoroutineCount() int32 { + return i.goroutineCount.Load() +} + // isDryRun returns true if Upgrade is set to run as a DryRun func (i *Install) isDryRun() bool { if i.DryRun || i.DryRunOption == "client" || i.DryRunOption == "server" || i.DryRunOption == "true" { diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index f567b3df4..fa9cfb222 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -28,7 +28,6 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" "testing" "time" @@ -330,8 +329,8 @@ func TestInstallRelease_WithChartAndDependencyParentNotes(t *testing.T) { } rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) - is.Equal("with-notes", rel.Name) is.NoError(err) + is.Equal("with-notes", rel.Name) is.Equal("parent", rel.Info.Notes) is.Equal(rel.Info.Description, "Install complete") } @@ -349,8 +348,8 @@ func TestInstallRelease_WithChartAndDependencyAllNotes(t *testing.T) { } rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) - is.Equal("with-notes", rel.Name) is.NoError(err) + is.Equal("with-notes", rel.Name) // test run can return as either 'parent\nchild' or 'child\nparent' if !strings.Contains(rel.Info.Notes, "parent") && !strings.Contains(rel.Info.Notes, "child") { t.Fatalf("Expected 'parent\nchild' or 'child\nparent', got '%s'", rel.Info.Notes) @@ -454,9 +453,7 @@ func TestInstallReleaseIncorrectTemplate_DryRun(t *testing.T) { if err == nil { t.Fatalf("Install should fail containing error: %s", expectedErr) } - if err != nil { - is.Contains(err.Error(), expectedErr) - } + is.Contains(err.Error(), expectedErr) } func TestInstallRelease_NoHooks(t *testing.T) { @@ -541,14 +538,14 @@ func TestInstallRelease_Wait(t *testing.T) { instAction.WaitStrategy = kube.StatusWatcherStrategy vals := map[string]interface{}{} - goroutines := runtime.NumGoroutine() + goroutines := instAction.getGoroutineCount() res, err := instAction.Run(buildChart(), vals) is.Error(err) is.Contains(res.Info.Description, "I timed out") is.Equal(res.Info.Status, release.StatusFailed) - is.Equal(goroutines, runtime.NumGoroutine()) + is.Equal(goroutines, instAction.getGoroutineCount()) } func TestInstallRelease_Wait_Interrupted(t *testing.T) { is := assert.New(t) @@ -563,15 +560,15 @@ func TestInstallRelease_Wait_Interrupted(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) - goroutines := runtime.NumGoroutine() + goroutines := instAction.getGoroutineCount() _, err := instAction.RunWithContext(ctx, buildChart(), vals) is.Error(err) is.Contains(err.Error(), "context canceled") - is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background - time.Sleep(10 * time.Second) // wait for goroutine to finish - is.Equal(goroutines, runtime.NumGoroutine()) + is.Equal(goroutines+1, instAction.getGoroutineCount()) // installation goroutine still is in background + time.Sleep(10 * time.Second) // wait for goroutine to finish + is.Equal(goroutines, instAction.getGoroutineCount()) } func TestInstallRelease_WaitForJobs(t *testing.T) { is := assert.New(t) @@ -647,7 +644,7 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) time.AfterFunc(time.Second, cancel) - goroutines := runtime.NumGoroutine() + goroutines := instAction.getGoroutineCount() res, err := instAction.RunWithContext(ctx, buildChart(), vals) is.Error(err) @@ -659,9 +656,9 @@ func TestInstallRelease_RollbackOnFailure_Interrupted(t *testing.T) { _, err = instAction.cfg.Releases.Get(res.Name, res.Version) is.Error(err) is.Equal(err, driver.ErrReleaseNotFound) - is.Equal(goroutines+1, runtime.NumGoroutine()) // installation goroutine still is in background - time.Sleep(10 * time.Second) // wait for goroutine to finish - is.Equal(goroutines, runtime.NumGoroutine()) + is.Equal(goroutines+1, instAction.getGoroutineCount()) // installation goroutine still is in background + time.Sleep(10 * time.Second) // wait for goroutine to finish + is.Equal(goroutines, instAction.getGoroutineCount()) } func TestNameTemplate(t *testing.T) { From 9eafbc53dfb4b6b9ecaaaaa3c73bcffa58af397a Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Mon, 18 Aug 2025 10:54:03 -0600 Subject: [PATCH 17/55] fix: make file whitespace Signed-off-by: Terry Howe --- Makefile | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/Makefile b/Makefile index 0a20259bd..8dfa3344c 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,9 @@ 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 := ./... @@ -227,22 +227,19 @@ 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}" From 9ea35da0d0309b59b15c1a00cf619f3869512b61 Mon Sep 17 00:00:00 2001 From: Scott Rigby Date: Sat, 30 Aug 2025 13:25:28 -0400 Subject: [PATCH 18/55] [HIP-0026] Plugin packaging, signing, and verification (#31176) * Plugin packaging, signing and verification Signed-off-by: Scott Rigby * wrap keyring read error with more explicit message Co-authored-by: Jesse Simpson Signed-off-by: Scott Rigby * skip unnecessary check Co-authored-by: Evans Mungai Signed-off-by: Scott Rigby * Change behavior for installing plugin with missing .prov file (now warns and continues instead of failing) Signed-off-by: Scott Rigby * Add comprehensive plugin verification tests - Test missing .prov files (warns but continues) - Test invalid .prov file formats (fails verification) - Test hash mismatches in .prov files (fails verification) - Test .prov file access errors (fails appropriately) - Test directory plugins don't support verification - Test installation without verification enabled (succeeds) - Test with valid .prov files (fails on empty keyring as expected) --------- Signed-off-by: Scott Rigby Co-authored-by: Jesse Simpson Co-authored-by: Evans Mungai --- internal/plugin/installer/extractor.go | 195 ++++++++ internal/plugin/installer/http_installer.go | 215 +++------ .../plugin/installer/http_installer_test.go | 3 +- internal/plugin/installer/installer.go | 94 +++- internal/plugin/installer/local_installer.go | 84 +++- .../plugin/installer/local_installer_test.go | 83 +--- internal/plugin/installer/oci_installer.go | 97 +++- .../plugin/installer/oci_installer_test.go | 24 +- .../plugin/installer/vcs_installer_test.go | 5 +- .../plugin/installer/verification_test.go | 421 ++++++++++++++++++ internal/plugin/sign.go | 166 +++++++ internal/plugin/sign_test.go | 92 ++++ internal/plugin/signing_info.go | 178 ++++++++ internal/plugin/verify.go | 72 +++ internal/plugin/verify_test.go | 201 +++++++++ pkg/action/package.go | 16 +- pkg/cmd/plugin.go | 2 + pkg/cmd/plugin_install.go | 55 ++- pkg/cmd/plugin_list.go | 12 +- pkg/cmd/plugin_package.go | 209 +++++++++ pkg/cmd/plugin_package_test.go | 170 +++++++ pkg/cmd/plugin_verify.go | 88 ++++ pkg/cmd/plugin_verify_test.go | 264 +++++++++++ pkg/getter/ocigetter.go | 16 +- pkg/provenance/doc.go | 10 +- pkg/provenance/sign.go | 96 ++-- pkg/provenance/sign_test.go | 42 +- pkg/registry/plugin.go | 45 +- 28 files changed, 2599 insertions(+), 356 deletions(-) create mode 100644 internal/plugin/installer/extractor.go create mode 100644 internal/plugin/installer/verification_test.go create mode 100644 internal/plugin/sign.go create mode 100644 internal/plugin/sign_test.go create mode 100644 internal/plugin/signing_info.go create mode 100644 internal/plugin/verify.go create mode 100644 internal/plugin/verify_test.go create mode 100644 pkg/cmd/plugin_package.go create mode 100644 pkg/cmd/plugin_package_test.go create mode 100644 pkg/cmd/plugin_verify.go create mode 100644 pkg/cmd/plugin_verify_test.go diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go new file mode 100644 index 000000000..9417a0535 --- /dev/null +++ b/internal/plugin/installer/extractor.go @@ -0,0 +1,195 @@ +/* +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" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "slices" + "strings" + + securejoin "github.com/cyphar/filepath-securejoin" +) + +// TarGzExtractor extracts gzip compressed tar archives +type TarGzExtractor struct{} + +// Extractor provides an interface for extracting archives +type Extractor interface { + Extract(buffer *bytes.Buffer, targetDir string) error +} + +// Extractors contains a map of suffixes and matching implementations of extractor to return +var Extractors = map[string]Extractor{ + ".tar.gz": &TarGzExtractor{}, + ".tgz": &TarGzExtractor{}, +} + +// Convert a media type to an extractor extension. +// +// This should be refactored in Helm 4, combined with the extension-based mechanism. +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 + } +} + +// NewExtractor creates a new extractor matching the source file name +func NewExtractor(source string) (Extractor, error) { + for suffix, extractor := range Extractors { + if strings.HasSuffix(source, suffix) { + return extractor, nil + } + } + return nil, fmt.Errorf("no extractor implemented yet for %s", source) +} + +// cleanJoin resolves dest as a subpath of root. +// +// This function runs several security checks on the path, generating an error if +// the supplied `dest` looks suspicious or would result in dubious behavior on the +// filesystem. +// +// cleanJoin assumes that any attempt by `dest` to break out of the CWD is an attempt +// to be malicious. (If you don't care about this, use the securejoin-filepath library.) +// It will emit an error if it detects paths that _look_ malicious, operating on the +// assumption that we don't actually want to do anything with files that already +// appear to be nefarious. +// +// - The character `:` is considered illegal because it is a separator on UNIX and a +// drive designator on Windows. +// - The path component `..` is considered suspicions, and therefore illegal +// - The character \ (backslash) is treated as a path separator and is converted to /. +// - Beginning a path with a path separator is illegal +// - Rudimentary symlink protects are offered by SecureJoin. +func cleanJoin(root, dest string) (string, error) { + + // On Windows, this is a drive separator. On UNIX-like, this is the path list separator. + // In neither case do we want to trust a TAR that contains these. + if strings.Contains(dest, ":") { + return "", errors.New("path contains ':', which is illegal") + } + + // The Go tar library does not convert separators for us. + // We assume here, as we do elsewhere, that `\\` means a Windows path. + dest = strings.ReplaceAll(dest, "\\", "/") + + // We want to alert the user that something bad was attempted. Cleaning it + // is not a good practice. + if slices.Contains(strings.Split(dest, "/"), "..") { + return "", errors.New("path contains '..', which is illegal") + } + + // If a path is absolute, the creator of the TAR is doing something shady. + if path.IsAbs(dest) { + return "", errors.New("path is absolute, which is illegal") + } + + // SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks. + // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up + // being wrong or returning an error. This was introduced in v0.4.0. + root = filepath.Clean(root) + newpath, err := securejoin.SecureJoin(root, dest) + if err != nil { + return "", err + } + + return filepath.ToSlash(newpath), nil +} + +// Extract extracts compressed archives +// +// Implements Extractor. +func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { + uncompressedStream, err := gzip.NewReader(buffer) + if err != nil { + return err + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return err + } + + tarReader := tar.NewReader(uncompressedStream) + 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: + // 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 + } + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return err + } + outFile.Close() + // We don't want to process these extension header files. + case tar.TypeXGlobalHeader, tar.TypeXHeader: + continue + default: + return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name) + } + } + 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 index b68fc059a..a4687d8c9 100644 --- a/internal/plugin/installer/http_installer.go +++ b/internal/plugin/installer/http_installer.go @@ -16,22 +16,14 @@ limitations under the License. package installer // import "helm.sh/helm/v4/internal/plugin/installer" import ( - "archive/tar" "bytes" - "compress/gzip" - "errors" "fmt" - "io" "log/slog" "os" - "path" "path/filepath" - "regexp" - "slices" "strings" - securejoin "github.com/cyphar/filepath-securejoin" - + "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" @@ -46,45 +38,8 @@ type HTTPInstaller struct { base extractor Extractor getter getter.Getter -} - -// TarGzExtractor extracts gzip compressed tar archives -type TarGzExtractor struct{} - -// Extractor provides an interface for extracting archives -type Extractor interface { - Extract(buffer *bytes.Buffer, targetDir string) error -} - -// Extractors contains a map of suffixes and matching implementations of extractor to return -var Extractors = map[string]Extractor{ - ".tar.gz": &TarGzExtractor{}, - ".tgz": &TarGzExtractor{}, -} - -// Convert a media type to an extractor extension. -// -// This should be refactored in Helm 4, combined with the extension-based mechanism. -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 - } -} - -// NewExtractor creates a new extractor matching the source file name -func NewExtractor(source string) (Extractor, error) { - for suffix, extractor := range Extractors { - if strings.HasSuffix(source, suffix) { - return extractor, nil - } - } - return nil, fmt.Errorf("no extractor implemented yet for %s", source) + // Provenance data to save after installation + provData []byte } // NewHTTPInstaller creates a new HttpInstaller. @@ -114,19 +69,6 @@ func NewHTTPInstaller(source string) (*HTTPInstaller, error) { 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. // @@ -137,6 +79,31 @@ func (i *HTTPInstaller) Install() error { return err } + // Save the original tarball to plugins directory for verification + // Extract metadata to get the actual plugin name and version + pluginBytes := pluginData.Bytes() + metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + 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, pluginBytes, 0644); err != nil { + return fmt.Errorf("failed to save tarball: %w", err) + } + + // Try to download .prov file if it exists + provURL := i.Source + ".prov" + if provData, err := i.getter.Get(provURL); err == nil { + provPath := tarballPath + ".prov" + if err := os.WriteFile(provPath, provData.Bytes(), 0644); err != nil { + slog.Debug("failed to save provenance file", "error", err) + } + } + if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil { return fmt.Errorf("extracting files from archive: %w", err) } @@ -175,111 +142,57 @@ func (i HTTPInstaller) Path() string { 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 -// the supplied `dest` looks suspicious or would result in dubious behavior on the -// filesystem. -// -// cleanJoin assumes that any attempt by `dest` to break out of the CWD is an attempt -// to be malicious. (If you don't care about this, use the securejoin-filepath library.) -// It will emit an error if it detects paths that _look_ malicious, operating on the -// assumption that we don't actually want to do anything with files that already -// appear to be nefarious. -// -// - The character `:` is considered illegal because it is a separator on UNIX and a -// drive designator on Windows. -// - The path component `..` is considered suspicions, and therefore illegal -// - The character \ (backslash) is treated as a path separator and is converted to /. -// - Beginning a path with a path separator is illegal -// - Rudimentary symlink protects are offered by SecureJoin. -func cleanJoin(root, dest string) (string, error) { +// 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") +} - // On Windows, this is a drive separator. On UNIX-like, this is the path list separator. - // In neither case do we want to trust a TAR that contains these. - if strings.Contains(dest, ":") { - return "", errors.New("path contains ':', which is illegal") +// PrepareForVerification downloads the plugin and signature files for verification +func (i *HTTPInstaller) PrepareForVerification() (string, func(), error) { + if !i.SupportsVerification() { + return "", nil, fmt.Errorf("verification not supported for this source") } - // The Go tar library does not convert separators for us. - // We assume here, as we do elsewhere, that `\\` means a Windows path. - dest = strings.ReplaceAll(dest, "\\", "/") - - // We want to alert the user that something bad was attempted. Cleaning it - // is not a good practice. - if slices.Contains(strings.Split(dest, "/"), "..") { - return "", errors.New("path contains '..', which is illegal") + // Create temporary directory for downloads + tempDir, err := os.MkdirTemp("", "helm-plugin-verify-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) } - // If a path is absolute, the creator of the TAR is doing something shady. - if path.IsAbs(dest) { - return "", errors.New("path is absolute, which is illegal") + cleanup := func() { + os.RemoveAll(tempDir) } - // SecureJoin will do some cleaning, as well as some rudimentary checking of symlinks. - // The directory needs to be cleaned prior to passing to SecureJoin or the location may end up - // being wrong or returning an error. This was introduced in v0.4.0. - root = filepath.Clean(root) - newpath, err := securejoin.SecureJoin(root, dest) + // Download plugin tarball + pluginFile := filepath.Join(tempDir, filepath.Base(i.Source)) + + g, err := getter.All(new(cli.EnvSettings)).ByScheme("http") if err != nil { - return "", err + cleanup() + return "", nil, err } - return filepath.ToSlash(newpath), nil -} - -// Extract extracts compressed archives -// -// Implements Extractor. -func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { - uncompressedStream, err := gzip.NewReader(buffer) + data, err := g.Get(i.Source, getter.WithURL(i.Source)) if err != nil { - return err + cleanup() + return "", nil, fmt.Errorf("failed to download plugin: %w", err) } - if err := os.MkdirAll(targetDir, 0755); err != nil { - return err + if err := os.WriteFile(pluginFile, data.Bytes(), 0644); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to write plugin file: %w", err) } - tarReader := tar.NewReader(uncompressedStream) - 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: - // Ensure parent directory exists - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) - if err != nil { - return err - } - defer outFile.Close() - if _, err := io.Copy(outFile, tarReader); err != nil { - return err - } - // We don't want to process these extension header files. - case tar.TypeXGlobalHeader, tar.TypeXHeader: - continue - default: - return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name) + // Try to download signature file - don't fail if it doesn't exist + if provData, err := g.Get(i.Source+".prov", getter.WithURL(i.Source+".prov")); err == nil { + if err := os.WriteFile(pluginFile+".prov", provData.Bytes(), 0644); err == nil { + // Store the provenance data so we can save it after installation + i.provData = provData.Bytes() } } - return nil + // Note: We don't fail if .prov file can't be downloaded - the verification logic + // in InstallWithOptions will handle missing .prov files appropriately + + return pluginFile, cleanup, nil } diff --git a/internal/plugin/installer/http_installer_test.go b/internal/plugin/installer/http_installer_test.go index 453021b76..be40b1b90 100644 --- a/internal/plugin/installer/http_installer_test.go +++ b/internal/plugin/installer/http_installer_test.go @@ -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" { @@ -515,6 +515,7 @@ func TestExtractWithExistingDirectory(t *testing.T) { } func TestExtractPluginInSubdirectory(t *testing.T) { + ensure.HelmHome(t) source := "https://repo.localdomain/plugins/subdir-plugin-1.0.0.tar.gz" tempDir := t.TempDir() diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index 7900f6745..dd169397e 100644 --- a/internal/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/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,89 @@ 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 + // PrepareForVerification downloads necessary files for verification + PrepareForVerification() (pluginPath string, cleanup func(), 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") + } + + 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)") + } + + // Prepare for verification (download files if needed) + pluginPath, cleanup, err := verifier.PrepareForVerification() + if err != nil { + return nil, fmt.Errorf("failed to prepare for verification: %w", err) + } + if cleanup != nil { + defer cleanup() + } + + // Check if provenance file exists + provFile := pluginPath + ".prov" + if _, err := os.Stat(provFile); err != nil { + if os.IsNotExist(err) { + // 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 { + // Other error accessing .prov file + return nil, fmt.Errorf("failed to access provenance file: %w", err) + } + } else { + // Provenance file exists - verify the plugin + verification, err := plugin.VerifyPlugin(pluginPath, 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) + } + } } - return i.Install() + + if err := i.Install(); err != nil { + return nil, err + } + + return result, nil } // Update updates a plugin. @@ -62,6 +146,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) diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go index 87b9eaf97..0e00c93d0 100644 --- a/internal/plugin/installer/local_installer.go +++ b/internal/plugin/installer/local_installer.go @@ -24,7 +24,9 @@ import ( "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. @@ -35,6 +37,7 @@ type LocalInstaller struct { base isArchive bool extractor Extractor + provData []byte // Provenance data to save after installation } // NewLocalInstaller creates a new LocalInstaller. @@ -105,6 +108,30 @@ func (i *LocalInstaller) installFromArchive() error { 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.ExtractPluginMetadataFromReader(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 { @@ -118,31 +145,60 @@ func (i *LocalInstaller) installFromArchive() error { return fmt.Errorf("failed to extract archive: %w", err) } - // Detect where the plugin.yaml actually is - pluginRoot, err := detectPluginRoot(tempDir) - if err != nil { - return err + // 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", pluginRoot, "path", i.Path()) - return fs.CopyDir(pluginRoot, i.Path()) + 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 returns the path where the plugin will be installed. -// For archive sources, strips the version from the filename. +// 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 { - return filepath.Join(i.PluginsDirectory, stripPluginName(filepath.Base(i.Source))) + // Strip archive extension to get plugin name + pluginName = stripPluginName(pluginName) } - return filepath.Join(i.PluginsDirectory, filepath.Base(i.Source)) + + return helmpath.DataPath("plugins", pluginName) } -// Update updates a local repository -func (i *LocalInstaller) Update() error { - slog.Debug("local repository is auto-updated") - return nil +// 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 +} + +// PrepareForVerification returns the local path for verification +func (i *LocalInstaller) PrepareForVerification() (string, func(), error) { + if !i.SupportsVerification() { + return "", nil, fmt.Errorf("verification not supported for directories") + } + + // For local files, try to read the .prov file if it exists + provFile := i.Source + ".prov" + if provData, err := os.ReadFile(provFile); err == nil { + // Store the provenance data so we can save it after installation + i.provData = provData + } + // Note: We don't fail if .prov file doesn't exist - the verification logic + // in InstallWithOptions will handle missing .prov files appropriately + + // Return the source path directly, no cleanup needed + return i.Source, nil, nil } diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 05118e183..339028ef3 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -86,8 +86,8 @@ func TestLocalInstallerTarball(t *testing.T) { Body string Mode int64 }{ - {"plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644}, - {"bin/test-plugin", "#!/bin/bash\necho test", 0755}, + {"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 { @@ -146,82 +146,3 @@ func TestLocalInstallerTarball(t *testing.T) { t.Fatalf("plugin not found at %s: %v", i.Path(), err) } } - -func TestLocalInstallerTarballWithSubdirectory(t *testing.T) { - ensure.HelmHome(t) - - // Create a test tarball with subdirectory - tempDir := t.TempDir() - tarballPath := filepath.Join(tempDir, "subdir-plugin-1.0.0.tar.gz") - - // Create tarball content - var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - tw := tar.NewWriter(gw) - - files := []struct { - Name string - Body string - Mode int64 - IsDir bool - }{ - {"my-plugin/", "", 0755, true}, - {"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644, false}, - {"my-plugin/bin/", "", 0755, true}, - {"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, false}, - } - - for _, file := range files { - hdr := &tar.Header{ - Name: file.Name, - Mode: file.Mode, - } - if file.IsDir { - hdr.Typeflag = tar.TypeDir - } else { - hdr.Size = int64(len(file.Body)) - } - - if err := tw.WriteHeader(hdr); err != nil { - t.Fatal(err) - } - if !file.IsDir { - if _, err := tw.Write([]byte(file.Body)); err != nil { - t.Fatal(err) - } - } - } - - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := gw.Close(); err != nil { - t.Fatal(err) - } - - // Write tarball to file - if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil { - t.Fatal(err) - } - - // Test installation - i, err := NewForSource(tarballPath, "") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - - if err := Install(i); err != nil { - t.Fatal(err) - } - - expectedPath := helmpath.DataPath("plugins", "subdir-plugin") - if i.Path() != expectedPath { - t.Fatalf("expected path %q, got %q", expectedPath, i.Path()) - } - - // Verify plugin was installed from subdirectory - pluginYaml := filepath.Join(i.Path(), "plugin.yaml") - if _, err := os.Stat(pluginYaml); err != nil { - t.Fatalf("plugin.yaml not found at %s: %v", pluginYaml, err) - } -} diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go index a96a94ee1..c33ef13d5 100644 --- a/internal/plugin/installer/oci_installer.go +++ b/internal/plugin/installer/oci_installer.go @@ -25,6 +25,7 @@ import ( "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" @@ -33,6 +34,9 @@ import ( "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 @@ -85,17 +89,44 @@ func (i *OCIInstaller) Install() error { return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err) } - // Create cache directory - if err := os.MkdirAll(i.CacheDir, 0755); err != nil { - return fmt.Errorf("failed to create cache directory: %w", err) + // Save the original tarball to plugins directory for verification + // For OCI plugins, extract version from plugin.yaml inside the tarball + pluginBytes := pluginData.Bytes() + + // Extract metadata to get the actual plugin name and version + metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + 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, pluginBytes, 0644); err != nil { + return fmt.Errorf("failed to save tarball: %w", err) + } + + // Try to download and save .prov file alongside the tarball + provSource := i.Source + ".prov" + if provData, err := i.getter.Get(provSource); err == nil { + provPath := tarballPath + ".prov" + if err := os.WriteFile(provPath, provData.Bytes(), 0644); err != nil { + slog.Debug("failed to save provenance file", "error", err) + } } // Check if this is a gzip compressed file - pluginBytes := pluginData.Bytes() if len(pluginBytes) < 2 || pluginBytes[0] != 0x1f || pluginBytes[1] != 0x8b { return fmt.Errorf("plugin data is not a gzip compressed archive") } + // 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(pluginBytes), i.CacheDir); err != nil { return fmt.Errorf("failed to extract plugin: %w", err) @@ -214,3 +245,61 @@ func extractTar(r io.Reader, targetDir string) error { return nil } + +// SupportsVerification returns true since OCI plugins can be verified +func (i *OCIInstaller) SupportsVerification() bool { + return true +} + +// PrepareForVerification downloads the plugin tarball and provenance to a temporary directory +func (i *OCIInstaller) PrepareForVerification() (pluginPath string, cleanup func(), err error) { + slog.Debug("preparing OCI plugin for verification", "source", i.Source) + + // Create temporary directory for verification + tempDir, err := os.MkdirTemp("", "helm-oci-verify-") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanup = func() { + os.RemoveAll(tempDir) + } + + // Download the plugin tarball + pluginData, err := i.getter.Get(i.Source) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err) + } + + // Extract metadata to get the actual plugin name and version + pluginBytes := pluginData.Bytes() + metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) + } + filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version) + + // Save plugin tarball to temp directory + pluginTarball := filepath.Join(tempDir, filename) + if err := os.WriteFile(pluginTarball, pluginBytes, 0644); err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to save plugin tarball: %w", err) + } + + // Try to download the provenance file - don't fail if it doesn't exist + provSource := i.Source + ".prov" + if provData, err := i.getter.Get(provSource); err == nil { + // Save provenance to temp directory + provFile := filepath.Join(tempDir, filename+".prov") + if err := os.WriteFile(provFile, provData.Bytes(), 0644); err == nil { + slog.Debug("prepared plugin for verification", "plugin", pluginTarball, "provenance", provFile) + } + } + // Note: We don't fail if .prov file can't be downloaded - the verification logic + // in InstallWithOptions will handle missing .prov files appropriately + + slog.Debug("prepared plugin for verification", "plugin", pluginTarball) + return pluginTarball, cleanup, nil +} diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go index 1ed10ff8e..1280cf97d 100644 --- a/internal/plugin/installer/oci_installer_test.go +++ b/internal/plugin/installer/oci_installer_test.go @@ -34,6 +34,7 @@ import ( "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" @@ -125,7 +126,7 @@ func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest Digest: digest.Digest(layerDigest), Size: int64(len(pluginData)), Annotations: map[string]string{ - ocispec.AnnotationTitle: pluginName + ".tgz", // Layer named properly + ocispec.AnnotationTitle: pluginName + "-1.0.0.tgz", // Layer named with version }, }, }, @@ -316,9 +317,8 @@ func TestOCIInstaller_Path(t *testing.T) { } func TestOCIInstaller_Install(t *testing.T) { - // Set up isolated test environment FIRST - testPluginsDir := t.TempDir() - t.Setenv("HELM_PLUGINS", testPluginsDir) + // Set up isolated test environment + ensure.HelmHome(t) pluginName := "test-plugin-basic" server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName) @@ -333,15 +333,10 @@ func TestOCIInstaller_Install(t *testing.T) { t.Fatalf("Expected no error, got %v", err) } - // The OCI installer uses helmpath.DataPath, which now points to our test directory + // The OCI installer uses helmpath.DataPath, which is isolated by ensure.HelmHome(t) actualPath := installer.Path() t.Logf("Installer will use path: %s", actualPath) - // Verify the path is actually in our test directory - if !strings.HasPrefix(actualPath, testPluginsDir) { - t.Fatalf("Expected path %s to be under test directory %s", actualPath, testPluginsDir) - } - // Install the plugin if err := Install(installer); err != nil { t.Fatalf("Expected installation to succeed, got error: %v", err) @@ -399,8 +394,7 @@ func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Set up isolated test environment for each subtest - testPluginsDir := t.TempDir() - t.Setenv("HELM_PLUGINS", testPluginsDir) + ensure.HelmHome(t) server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName) defer server.Close() @@ -440,8 +434,7 @@ func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) { func TestOCIInstaller_Install_AlreadyExists(t *testing.T) { // Set up isolated test environment - testPluginsDir := t.TempDir() - t.Setenv("HELM_PLUGINS", testPluginsDir) + ensure.HelmHome(t) pluginName := "test-plugin-exists" server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName) @@ -474,8 +467,7 @@ func TestOCIInstaller_Install_AlreadyExists(t *testing.T) { func TestOCIInstaller_Update(t *testing.T) { // Set up isolated test environment - testPluginsDir := t.TempDir() - t.Setenv("HELM_PLUGINS", testPluginsDir) + ensure.HelmHome(t) pluginName := "test-plugin-update" server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName) diff --git a/internal/plugin/installer/vcs_installer_test.go b/internal/plugin/installer/vcs_installer_test.go index f024b4b40..d542a0f75 100644 --- a/internal/plugin/installer/vcs_installer_test.go +++ b/internal/plugin/installer/vcs_installer_test.go @@ -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/sign.go b/internal/plugin/sign.go new file mode 100644 index 000000000..134c640e7 --- /dev/null +++ b/internal/plugin/sign.go @@ -0,0 +1,166 @@ +/* +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" + "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. +// +// This is used when packaging and signing a plugin from a tarball file. +// It creates a signature that includes the tarball hash and plugin metadata, +// allowing verification of the original tarball later. +func SignPlugin(tarballPath string, signer *provenance.Signatory) (string, error) { + // Extract plugin metadata from tarball + pluginMeta, err := extractPluginMetadata(tarballPath) + 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(tarballPath, metadataBytes) +} + +// extractPluginMetadata extracts plugin metadata from a tarball +func extractPluginMetadata(tarballPath string) (*Metadata, error) { + f, err := os.Open(tarballPath) + if err != nil { + return nil, err + } + defer f.Close() + + return ExtractPluginMetadataFromReader(f) +} + +// ExtractPluginMetadataFromReader extracts plugin metadata from a tarball reader +func ExtractPluginMetadataFromReader(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..a60970cdc --- /dev/null +++ b/internal/plugin/sign_test.go @@ -0,0 +1,92 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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) + } + + // Sign the plugin tarball + sig, err := SignPlugin(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/verify.go b/internal/plugin/verify.go new file mode 100644 index 000000000..e9656a3a6 --- /dev/null +++ b/internal/plugin/verify.go @@ -0,0 +1,72 @@ +/* +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" + "os" + "path/filepath" + + "helm.sh/helm/v4/pkg/provenance" +) + +// VerifyPlugin verifies a plugin tarball against a signature. +// +// This function verifies that a plugin tarball has a valid provenance file +// and that the provenance file is signed by a trusted entity. +func VerifyPlugin(pluginPath, keyring string) (*provenance.Verification, error) { + // Verify the plugin path exists + fi, err := os.Stat(pluginPath) + if err != nil { + return nil, err + } + + // Only support tarball verification + if fi.IsDir() { + return nil, errors.New("directory verification not supported - only plugin tarballs can be verified") + } + + // Verify it's a tarball + if !isTarball(pluginPath) { + return nil, errors.New("plugin file must be a gzipped tarball (.tar.gz or .tgz)") + } + + // Look for provenance file + provFile := pluginPath + ".prov" + if _, err := os.Stat(provFile); err != nil { + return nil, fmt.Errorf("could not find provenance file %s: %w", provFile, err) + } + + // Create signatory from keyring + sig, err := provenance.NewFromKeyring(keyring, "") + if err != nil { + return nil, err + } + + return verifyPluginTarball(pluginPath, provFile, sig) +} + +// verifyPluginTarball verifies a plugin tarball against its signature +func verifyPluginTarball(pluginPath, provPath string, sig *provenance.Signatory) (*provenance.Verification, error) { + // Reuse chart verification logic from pkg/provenance + return sig.Verify(pluginPath, provPath) +} + +// 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..a09b35ec9 --- /dev/null +++ b/internal/plugin/verify_test.go @@ -0,0 +1,201 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "os" + "path/filepath" + "strings" + "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) + } + + sig, err := SignPlugin(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) + } + + // Now verify the plugin + verification, err := VerifyPlugin(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) + } + + // Try to verify - should fail + _, err = VerifyPlugin(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) + } + + // Try to verify without .prov file + _, err := VerifyPlugin(tarballPath, testPubFile) + if err == nil { + t.Error("Expected verification to fail without provenance file") + } +} + +func TestVerifyPluginDirectory(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) + } + + // Attempt to verify the directory - should fail + _, err := VerifyPlugin(pluginDir, testPubFile) + if err == nil { + t.Error("Expected directory verification to fail, but it succeeded") + } + + expectedError := "directory verification not supported" + if !containsString(err.Error(), expectedError) { + t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + strings.Contains(s, substr))) +} diff --git a/pkg/action/package.go b/pkg/action/package.go index e57ce4921..c59efcdb3 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -25,6 +25,7 @@ import ( "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 +144,20 @@ 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) + } + + // Use the generic provenance signing function + sig, err := signer.ClearSign(filename, metadataBytes) if err != nil { return err } diff --git a/pkg/cmd/plugin.go b/pkg/cmd/plugin.go index b03000ad4..393e9672c 100644 --- a/pkg/cmd/plugin.go +++ b/pkg/cmd/plugin.go @@ -38,6 +38,8 @@ func newPluginCmd(out io.Writer) *cobra.Command { newPluginListCmd(out), newPluginUninstallCmd(out), newPluginUpdateCmd(out), + newPluginPackageCmd(out), + newPluginVerifyCmd(out), ) return cmd } diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index 960404a76..0abefa76b 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -33,6 +33,9 @@ import ( type pluginInstallOptions struct { source string version string + // signing options + verify bool + keyring string // OCI-specific options certFile string keyFile string @@ -45,6 +48,13 @@ type pluginInstallOptions struct { 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 { @@ -71,6 +81,8 @@ 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") @@ -113,10 +125,51 @@ func (o *pluginInstallOptions) run(out io.Writer) error { 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 { diff --git a/pkg/cmd/plugin_list.go b/pkg/cmd/plugin_list.go index 31a76330d..9b2895441 100644 --- a/pkg/cmd/plugin_list.go +++ b/pkg/cmd/plugin_list.go @@ -46,15 +46,23 @@ func newPluginListCmd(out io.Writer) *cobra.Command { return err } + // Get signing info for all plugins + signingInfo := plugin.GetSigningInfoForPlugins(plugins) + table := uitable.New() - table.AddRow("NAME", "VERSION", "TYPE", "APIVERSION", "SOURCE") + table.AddRow("NAME", "VERSION", "TYPE", "APIVERSION", "PROVENANCE", "SOURCE") for _, p := range plugins { m := p.Metadata() sourceURL := m.SourceURL if sourceURL == "" { sourceURL = "unknown" } - table.AddRow(m.Name, m.Version, m.Type, m.APIVersion, sourceURL) + // 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 diff --git a/pkg/cmd/plugin_package.go b/pkg/cmd/plugin_package.go new file mode 100644 index 000000000..5da6c624e --- /dev/null +++ b/pkg/cmd/plugin_package.go @@ -0,0 +1,209 @@ +/* +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 { + // Sign the plugin tarball (not the source directory) + sig, err := plugin.SignPlugin(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_verify.go b/pkg/cmd/plugin_verify.go new file mode 100644 index 000000000..4772fcc33 --- /dev/null +++ b/pkg/cmd/plugin_verify.go @@ -0,0 +1,88 @@ +/* +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" + + "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 + verification, err := plugin.VerifyPlugin(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/getter/ocigetter.go b/pkg/getter/ocigetter.go index 121e000c8..24fc60c56 100644 --- a/pkg/getter/ocigetter.go +++ b/pkg/getter/ocigetter.go @@ -175,6 +175,12 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) { // 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, "/") @@ -190,10 +196,18 @@ func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffe pluginName = lastPart[:idx] } - result, err := client.PullPlugin(ref, pluginName) + 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/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..103c81fbb 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -30,9 +30,6 @@ import ( "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 +55,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 +65,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,20 +194,21 @@ func (s *Signatory) DecryptKey(fn PassphraseFetcher) error { return s.Entity.PrivateKey.Decrypt(p) } -// ClearSign signs a chart with the given key. +// ClearSign signs a package with the given key and pre-marshalled metadata. // -// This takes the path to a chart archive file and a key, and it returns a clear signature. +// This takes the path to a package archive file, a key, and marshalled metadata bytes. +// This allows both charts and plugins to use the same signing infrastructure. // // 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) { +func (s *Signatory) ClearSign(packagePath 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 { + if fi, err := os.Stat(packagePath); err != nil { return "", err } else if fi.IsDir() { return "", errors.New("cannot sign a directory") @@ -218,7 +216,7 @@ func (s *Signatory) ClearSign(chartpath string) (string, error) { out := bytes.NewBuffer(nil) - b, err := messageBlock(chartpath) + b, err := messageBlock(packagePath, metadataBytes) if err != nil { return "", err } @@ -248,10 +246,10 @@ 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 a package. +func (s *Signatory) Verify(packagePath, sigpath string) (*Verification, error) { ver := &Verification{} - for _, fname := range []string{chartpath, sigpath} { + for _, fname := range []string{packagePath, sigpath} { if fi, err := os.Stat(fname); err != nil { return ver, err } else if fi.IsDir() { @@ -272,17 +270,17 @@ func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { ver.SignedBy = by // Second, verify the hash of the tarball. - sum, err := DigestFile(chartpath) + sum, err := DigestFile(packagePath) if err != nil { return ver, err } - _, sums, err := parseMessageBlock(sig.Plaintext) + sums, err := parseMessageBlock(sig.Plaintext) if err != nil { return ver, err } sum = "sha256:" + sum - basename := filepath.Base(chartpath) + basename := filepath.Base(packagePath) if sha, ok := sums.Files[basename]; !ok { return ver, fmt.Errorf("provenance does not contain a SHA for a file named %q", basename) } else if sha != sum { @@ -320,64 +318,64 @@ func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, er ) } -func messageBlock(chartpath string) (*bytes.Buffer, error) { - var b *bytes.Buffer +// messageBlock creates a message block from a package path and pre-marshalled metadata +func messageBlock(packagePath string, metadataBytes []byte) (*bytes.Buffer, error) { // Checksum the archive - chash, err := DigestFile(chartpath) + chash, err := DigestFile(packagePath) if err != nil { - return b, err + return nil, err } - base := filepath.Base(chartpath) + base := filepath.Base(packagePath) sums := &SumCollection{ Files: map[string]string{ base: "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 +404,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..4594fac01 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,27 @@ 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) + + out, err := messageBlock(testChartfile, metadataBytes) if err != nil { t.Fatal(err) } @@ -88,14 +110,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 +241,9 @@ func TestClearSign(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + sig, err := signer.ClearSign(testChartfile, metadataBytes) if err != nil { t.Fatal(err) } @@ -252,7 +274,9 @@ func TestClearSignError(t *testing.T) { // ensure that signing always fails signer.Entity.PrivateKey.PrivateKey = failSigner{} - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + sig, err := signer.ClearSign(testChartfile, metadataBytes) if err == nil { t.Fatal("didn't get an error from ClearSign but expected one") } @@ -271,7 +295,9 @@ func TestDecodeSignature(t *testing.T) { t.Fatal(err) } - sig, err := signer.ClearSign(testChartfile) + metadataBytes := loadChartMetadataForSigning(t, testChartfile) + + sig, err := signer.ClearSign(testChartfile, metadataBytes) if err != nil { t.Fatal(err) } diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go index 5d22a99ee..991bace76 100644 --- a/pkg/registry/plugin.go +++ b/pkg/registry/plugin.go @@ -38,11 +38,13 @@ type PluginPullOptions struct { // PluginPullResult contains the result of a plugin pull operation type PluginPullResult struct { - Manifest ocispec.Descriptor - PluginData []byte - ProvenanceData []byte // Optional provenance data - Ref string - PluginName string + Manifest ocispec.Descriptor + PluginData []byte + Prov struct { + Data []byte + } + Ref string + PluginName string } // PullPlugin downloads a plugin from an OCI registry using artifact type @@ -96,30 +98,31 @@ func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType) } - // Find the required plugin tarball and optional provenance - expectedTarball := pluginName + ".tgz" - expectedProvenance := pluginName + ".tgz.prov" - + // 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 (preferred method) + // Check for title annotation if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists { - switch title { - case expectedTarball: + // Check if this looks like a plugin tarball: {pluginName}-{version}.tgz + if pluginDescriptor == nil && strings.HasPrefix(title, pluginName+"-") && strings.HasSuffix(title, ".tgz") { pluginDescriptor = &d - case expectedProvenance: + } + // 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 %s not found in manifest", expectedTarball) + return nil, fmt.Errorf("required layer matching pattern %s-VERSION.tgz not found in manifest", pluginName) } // Build plugin-specific result @@ -138,7 +141,7 @@ func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName // Fetch provenance data if available if provenanceDescriptor != nil { - result.ProvenanceData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor) + 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) } @@ -146,8 +149,8 @@ func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref) fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) - if result.ProvenanceData != nil { - fmt.Fprintf(c.out, "Provenance: %s\n", expectedProvenance) + if result.Prov.Data != nil { + fmt.Fprintf(c.out, "Provenance: %s\n", foundProvenanceName) } if strings.Contains(result.Ref, "_") { @@ -162,6 +165,7 @@ func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName type ( pluginPullOperation struct { pluginName string + withProv bool } // PluginPullOption allows customizing plugin pull operations @@ -199,3 +203,10 @@ func GetPluginName(source string) (string, error) { return pluginName, nil } + +// PullPluginOptWithProv configures the pull to fetch provenance data +func PullPluginOptWithProv(withProv bool) PluginPullOption { + return func(operation *pluginPullOperation) { + operation.withProv = withProv + } +} From e814ff3c38043a092b559ac449ef6286e8fb0790 Mon Sep 17 00:00:00 2001 From: Scott Rigby Date: Tue, 26 Aug 2025 23:19:54 -0400 Subject: [PATCH 19/55] Remove unnecessary file i/o operations from signing and verifying Signed-off-by: Scott Rigby --- internal/plugin/installer/http_installer.go | 95 +++++++--------- internal/plugin/installer/installer.go | 31 ++--- internal/plugin/installer/local_installer.go | 47 +++++--- internal/plugin/installer/oci_installer.go | 114 +++++++++---------- internal/plugin/sign.go | 28 ++--- internal/plugin/sign_test.go | 8 +- internal/plugin/verify.go | 43 +------ internal/plugin/verify_test.go | 79 +++++++------ pkg/action/package.go | 9 +- pkg/cmd/plugin_package.go | 11 +- pkg/cmd/plugin_verify.go | 39 ++++++- pkg/downloader/chart_downloader.go | 13 ++- pkg/provenance/sign.go | 81 ++++--------- pkg/provenance/sign_test.go | 75 ++++++------ 14 files changed, 332 insertions(+), 341 deletions(-) diff --git a/internal/plugin/installer/http_installer.go b/internal/plugin/installer/http_installer.go index a4687d8c9..bb96314f4 100644 --- a/internal/plugin/installer/http_installer.go +++ b/internal/plugin/installer/http_installer.go @@ -38,8 +38,9 @@ type HTTPInstaller struct { base extractor Extractor getter getter.Getter - // Provenance data to save after installation - provData []byte + // Cached data to avoid duplicate downloads + pluginData []byte + provData []byte } // NewHTTPInstaller creates a new HttpInstaller. @@ -74,15 +75,18 @@ func NewHTTPInstaller(source string) (*HTTPInstaller, error) { // // Implements Installer. func (i *HTTPInstaller) Install() error { - pluginData, err := i.getter.Get(i.Source) - if err != nil { - return err + // 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 - pluginBytes := pluginData.Bytes() - metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData)) if err != nil { return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) } @@ -91,20 +95,28 @@ func (i *HTTPInstaller) Install() error { 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, pluginBytes, 0644); err != nil { + if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil { return fmt.Errorf("failed to save tarball: %w", err) } - // Try to download .prov file if it exists - provURL := i.Source + ".prov" - if provData, err := i.getter.Get(provURL); err == nil { + // 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, provData.Bytes(), 0644); err != nil { + if err := os.WriteFile(provPath, i.provData, 0644); err != nil { slog.Debug("failed to save provenance file", "error", err) } } - if err := i.extractor.Extract(pluginData, i.CacheDir); err != nil { + if err := i.extractor.Extract(bytes.NewBuffer(i.pluginData), i.CacheDir); err != nil { return fmt.Errorf("extracting files from archive: %w", err) } @@ -148,51 +160,32 @@ func (i *HTTPInstaller) SupportsVerification() bool { return strings.HasSuffix(i.Source, ".tgz") || strings.HasSuffix(i.Source, ".tar.gz") } -// PrepareForVerification downloads the plugin and signature files for verification -func (i *HTTPInstaller) PrepareForVerification() (string, func(), error) { +// 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, fmt.Errorf("verification not supported for this source") - } - - // Create temporary directory for downloads - tempDir, err := os.MkdirTemp("", "helm-plugin-verify-*") - if err != nil { - return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + return nil, nil, "", fmt.Errorf("verification not supported for this source") } - cleanup := func() { - os.RemoveAll(tempDir) - } - - // Download plugin tarball - pluginFile := filepath.Join(tempDir, filepath.Base(i.Source)) - - g, err := getter.All(new(cli.EnvSettings)).ByScheme("http") - if err != nil { - cleanup() - return "", nil, err - } - - data, err := g.Get(i.Source, getter.WithURL(i.Source)) - if err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to download plugin: %w", err) - } - - if err := os.WriteFile(pluginFile, data.Bytes(), 0644); err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to write plugin file: %w", err) + // 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() } - // Try to download signature file - don't fail if it doesn't exist - if provData, err := g.Get(i.Source+".prov", getter.WithURL(i.Source+".prov")); err == nil { - if err := os.WriteFile(pluginFile+".prov", provData.Bytes(), 0644); err == nil { - // Store the provenance data so we can save it after installation + // 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() } } - // Note: We don't fail if .prov file can't be downloaded - the verification logic - // in InstallWithOptions will handle missing .prov files appropriately - return pluginFile, cleanup, nil + return i.pluginData, i.provData, filepath.Base(i.Source), nil } diff --git a/internal/plugin/installer/installer.go b/internal/plugin/installer/installer.go index dd169397e..b65dac2f4 100644 --- a/internal/plugin/installer/installer.go +++ b/internal/plugin/installer/installer.go @@ -55,8 +55,8 @@ type Installer interface { type Verifier interface { // SupportsVerification returns true if this installer can verify plugins SupportsVerification() bool - // PrepareForVerification downloads necessary files for verification - PrepareForVerification() (pluginPath string, cleanup func(), err error) + // GetVerificationData returns plugin and provenance data for verification + GetVerificationData() (archiveData, provData []byte, filename string, err error) } // Install installs a plugin. @@ -91,28 +91,19 @@ func InstallWithOptions(i Installer, opts Options) (*VerificationResult, error) return nil, fmt.Errorf("--verify is only supported for plugin tarballs (.tgz files)") } - // Prepare for verification (download files if needed) - pluginPath, cleanup, err := verifier.PrepareForVerification() + // 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 prepare for verification: %w", err) - } - if cleanup != nil { - defer cleanup() + return nil, fmt.Errorf("failed to get verification data: %w", err) } - // Check if provenance file exists - provFile := pluginPath + ".prov" - if _, err := os.Stat(provFile); err != nil { - if os.IsNotExist(err) { - // 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 { - // Other error accessing .prov file - return nil, fmt.Errorf("failed to access provenance file: %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 file exists - verify the plugin - verification, err := plugin.VerifyPlugin(pluginPath, opts.Keyring) + // 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) } diff --git a/internal/plugin/installer/local_installer.go b/internal/plugin/installer/local_installer.go index 0e00c93d0..e02261d59 100644 --- a/internal/plugin/installer/local_installer.go +++ b/internal/plugin/installer/local_installer.go @@ -35,9 +35,10 @@ var ErrPluginNotAFolder = errors.New("expected plugin to be a folder") // LocalInstaller installs plugins from the filesystem. type LocalInstaller struct { base - isArchive bool - extractor Extractor - provData []byte // Provenance data to save after installation + isArchive bool + extractor Extractor + pluginData []byte // Cached plugin data + provData []byte // Cached provenance data } // NewLocalInstaller creates a new LocalInstaller. @@ -110,7 +111,7 @@ func (i *LocalInstaller) installFromArchive() error { // Copy the original tarball to plugins directory for verification // Extract metadata to get the actual plugin name and version - metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(data)) + metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(data)) if err != nil { return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) } @@ -184,21 +185,35 @@ func (i *LocalInstaller) SupportsVerification() bool { return i.isArchive } -// PrepareForVerification returns the local path for verification -func (i *LocalInstaller) PrepareForVerification() (string, func(), error) { +// 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, fmt.Errorf("verification not supported for directories") + return nil, nil, "", fmt.Errorf("verification not supported for directories") } - // For local files, try to read the .prov file if it exists - provFile := i.Source + ".prov" - if provData, err := os.ReadFile(provFile); err == nil { - // Store the provenance data so we can save it after installation - i.provData = provData + // 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) + } + } } - // Note: We don't fail if .prov file doesn't exist - the verification logic - // in InstallWithOptions will handle missing .prov files appropriately - // Return the source path directly, no cleanup needed - return i.Source, nil, nil + return i.pluginData, i.provData, filepath.Base(i.Source), nil } diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go index c33ef13d5..afbb42ca5 100644 --- a/internal/plugin/installer/oci_installer.go +++ b/internal/plugin/installer/oci_installer.go @@ -44,6 +44,9 @@ type OCIInstaller struct { 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 @@ -83,18 +86,17 @@ func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, er func (i *OCIInstaller) Install() error { slog.Debug("pulling OCI plugin", "source", i.Source) - // Use getter to download the plugin - pluginData, err := i.getter.Get(i.Source) - if err != nil { - return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err) + // 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() } - // Save the original tarball to plugins directory for verification - // For OCI plugins, extract version from plugin.yaml inside the tarball - pluginBytes := pluginData.Bytes() - // Extract metadata to get the actual plugin name and version - metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData)) if err != nil { return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) } @@ -104,21 +106,29 @@ func (i *OCIInstaller) Install() error { 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, pluginBytes, 0644); err != nil { + if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil { return fmt.Errorf("failed to save tarball: %w", err) } - // Try to download and save .prov file alongside the tarball - provSource := i.Source + ".prov" - if provData, err := i.getter.Get(provSource); err == nil { + // 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, provData.Bytes(), 0644); err != nil { + 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(pluginBytes) < 2 || pluginBytes[0] != 0x1f || pluginBytes[1] != 0x8b { + if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b { return fmt.Errorf("plugin data is not a gzip compressed archive") } @@ -128,7 +138,7 @@ func (i *OCIInstaller) Install() error { } // Extract as gzipped tar - if err := extractTarGz(bytes.NewReader(pluginBytes), i.CacheDir); err != nil { + if err := extractTarGz(bytes.NewReader(i.pluginData), i.CacheDir); err != nil { return fmt.Errorf("failed to extract plugin: %w", err) } @@ -251,55 +261,41 @@ func (i *OCIInstaller) SupportsVerification() bool { return true } -// PrepareForVerification downloads the plugin tarball and provenance to a temporary directory -func (i *OCIInstaller) PrepareForVerification() (pluginPath string, cleanup func(), err error) { - slog.Debug("preparing OCI plugin for verification", "source", i.Source) +// 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) - // Create temporary directory for verification - tempDir, err := os.MkdirTemp("", "helm-oci-verify-") - if err != nil { - return "", nil, fmt.Errorf("failed to create temp directory: %w", err) - } - - cleanup = func() { - os.RemoveAll(tempDir) + // 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 the plugin tarball - pluginData, err := i.getter.Get(i.Source) - if err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err) + // 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 actual plugin name and version - pluginBytes := pluginData.Bytes() - metadata, err := plugin.ExtractPluginMetadataFromReader(bytes.NewReader(pluginBytes)) + // Extract metadata to get the filename + metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData)) if err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) - } - filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version) - - // Save plugin tarball to temp directory - pluginTarball := filepath.Join(tempDir, filename) - if err := os.WriteFile(pluginTarball, pluginBytes, 0644); err != nil { - cleanup() - return "", nil, fmt.Errorf("failed to save plugin tarball: %w", err) - } - - // Try to download the provenance file - don't fail if it doesn't exist - provSource := i.Source + ".prov" - if provData, err := i.getter.Get(provSource); err == nil { - // Save provenance to temp directory - provFile := filepath.Join(tempDir, filename+".prov") - if err := os.WriteFile(provFile, provData.Bytes(), 0644); err == nil { - slog.Debug("prepared plugin for verification", "plugin", pluginTarball, "provenance", provFile) - } + return nil, nil, "", fmt.Errorf("failed to extract plugin metadata from tarball: %w", err) } - // Note: We don't fail if .prov file can't be downloaded - the verification logic - // in InstallWithOptions will handle missing .prov files appropriately + filename = fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version) - slog.Debug("prepared plugin for verification", "plugin", pluginTarball) - return pluginTarball, cleanup, nil + slog.Debug("got verification data for OCI plugin", "filename", filename) + return i.pluginData, i.provData, filename, nil } diff --git a/internal/plugin/sign.go b/internal/plugin/sign.go index 134c640e7..6b8aafd3e 100644 --- a/internal/plugin/sign.go +++ b/internal/plugin/sign.go @@ -17,6 +17,7 @@ package plugin import ( "archive/tar" + "bytes" "compress/gzip" "errors" "fmt" @@ -29,14 +30,14 @@ import ( "helm.sh/helm/v4/pkg/provenance" ) -// SignPlugin signs a plugin using the SHA256 hash of the tarball. +// SignPlugin signs a plugin using the SHA256 hash of the tarball data. // -// This is used when packaging and signing a plugin from a tarball file. +// 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(tarballPath string, signer *provenance.Signatory) (string, error) { - // Extract plugin metadata from tarball - pluginMeta, err := extractPluginMetadata(tarballPath) +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) } @@ -48,22 +49,11 @@ func SignPlugin(tarballPath string, signer *provenance.Signatory) (string, error } // Use the generic provenance signing function - return signer.ClearSign(tarballPath, metadataBytes) + return signer.ClearSign(tarballData, filename, metadataBytes) } -// extractPluginMetadata extracts plugin metadata from a tarball -func extractPluginMetadata(tarballPath string) (*Metadata, error) { - f, err := os.Open(tarballPath) - if err != nil { - return nil, err - } - defer f.Close() - - return ExtractPluginMetadataFromReader(f) -} - -// ExtractPluginMetadataFromReader extracts plugin metadata from a tarball reader -func ExtractPluginMetadataFromReader(r io.Reader) (*Metadata, error) { +// 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 diff --git a/internal/plugin/sign_test.go b/internal/plugin/sign_test.go index a60970cdc..fce2dbeb3 100644 --- a/internal/plugin/sign_test.go +++ b/internal/plugin/sign_test.go @@ -69,8 +69,14 @@ runtimeConfig: 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(tarballPath, signer) + sig, err := SignPlugin(tarballData, filepath.Base(tarballPath), signer) if err != nil { t.Fatalf("failed to sign plugin: %v", err) } diff --git a/internal/plugin/verify.go b/internal/plugin/verify.go index e9656a3a6..760a56e67 100644 --- a/internal/plugin/verify.go +++ b/internal/plugin/verify.go @@ -16,57 +16,24 @@ limitations under the License. package plugin import ( - "errors" - "fmt" - "os" "path/filepath" "helm.sh/helm/v4/pkg/provenance" ) -// VerifyPlugin verifies a plugin tarball against a signature. -// -// This function verifies that a plugin tarball has a valid provenance file -// and that the provenance file is signed by a trusted entity. -func VerifyPlugin(pluginPath, keyring string) (*provenance.Verification, error) { - // Verify the plugin path exists - fi, err := os.Stat(pluginPath) - if err != nil { - return nil, err - } - - // Only support tarball verification - if fi.IsDir() { - return nil, errors.New("directory verification not supported - only plugin tarballs can be verified") - } - - // Verify it's a tarball - if !isTarball(pluginPath) { - return nil, errors.New("plugin file must be a gzipped tarball (.tar.gz or .tgz)") - } - - // Look for provenance file - provFile := pluginPath + ".prov" - if _, err := os.Stat(provFile); err != nil { - return nil, fmt.Errorf("could not find provenance file %s: %w", provFile, err) - } - +// 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 } - return verifyPluginTarball(pluginPath, provFile, sig) -} - -// verifyPluginTarball verifies a plugin tarball against its signature -func verifyPluginTarball(pluginPath, provPath string, sig *provenance.Signatory) (*provenance.Verification, error) { - // Reuse chart verification logic from pkg/provenance - return sig.Verify(pluginPath, provPath) + // 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 { +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 index a09b35ec9..9c907788f 100644 --- a/internal/plugin/verify_test.go +++ b/internal/plugin/verify_test.go @@ -18,7 +18,6 @@ package plugin import ( "os" "path/filepath" - "strings" "testing" "helm.sh/helm/v4/pkg/provenance" @@ -74,7 +73,13 @@ func TestVerifyPlugin(t *testing.T) { t.Fatal(err) } - sig, err := SignPlugin(tarballPath, signer) + // 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) } @@ -85,8 +90,19 @@ func TestVerifyPlugin(t *testing.T) { 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(tarballPath, testPubFile) + verification, err := VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile) if err != nil { t.Fatalf("Failed to verify plugin: %v", err) } @@ -146,8 +162,19 @@ InvalidSignatureData 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(tarballPath, testPubFile) + _, err = VerifyPlugin(archiveData, provData, filepath.Base(tarballPath), testPubFile) if err == nil { t.Error("Expected verification to fail with bad signature") } @@ -162,40 +189,26 @@ func TestVerifyPluginMissingProvenance(t *testing.T) { t.Fatal(err) } - // Try to verify without .prov file - _, err := VerifyPlugin(tarballPath, testPubFile) - if err == nil { - t.Error("Expected verification to fail without provenance file") - } -} - -func TestVerifyPluginDirectory(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 { + // Read the tarball data + archiveData, err := os.ReadFile(tarballPath) + if err != nil { t.Fatal(err) } - // Attempt to verify the directory - should fail - _, err := VerifyPlugin(pluginDir, testPubFile) + // Try to verify with empty provenance data + _, err = VerifyPlugin(archiveData, nil, filepath.Base(tarballPath), testPubFile) if err == nil { - t.Error("Expected directory verification to fail, but it succeeded") - } - - expectedError := "directory verification not supported" - if !containsString(err.Error(), expectedError) { - t.Errorf("Expected error to contain %q, got %q", expectedError, err.Error()) + t.Error("Expected verification to fail with empty provenance data") } } -func containsString(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && - (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || - strings.Contains(s, substr))) +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/pkg/action/package.go b/pkg/action/package.go index c59efcdb3..6e762b507 100644 --- a/pkg/action/package.go +++ b/pkg/action/package.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "syscall" "github.com/Masterminds/semver/v3" @@ -156,8 +157,14 @@ func (p *Package) Clearsign(filename string) error { 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(filename, metadataBytes) + sig, err := signer.ClearSign(archiveData, filepath.Base(filename), metadataBytes) if err != nil { return err } diff --git a/pkg/cmd/plugin_package.go b/pkg/cmd/plugin_package.go index 5da6c624e..05f8bb5ad 100644 --- a/pkg/cmd/plugin_package.go +++ b/pkg/cmd/plugin_package.go @@ -142,8 +142,15 @@ func (o *pluginPackageOptions) run(out io.Writer) error { // If signing was requested, sign the tarball if o.sign { - // Sign the plugin tarball (not the source directory) - sig, err := plugin.SignPlugin(tarballPath, signer) + // 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) diff --git a/pkg/cmd/plugin_verify.go b/pkg/cmd/plugin_verify.go index 4772fcc33..5f89e743e 100644 --- a/pkg/cmd/plugin_verify.go +++ b/pkg/cmd/plugin_verify.go @@ -18,6 +18,8 @@ package cmd import ( "fmt" "io" + "os" + "path/filepath" "github.com/spf13/cobra" @@ -65,8 +67,41 @@ func newPluginVerifyCmd(out io.Writer) *cobra.Command { } func (o *pluginVerifyOptions) run(out io.Writer) error { - // Verify the plugin - verification, err := plugin.VerifyPlugin(o.pluginPath, o.keyring) + // 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 } diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index 693e6b009..a24cad3fd 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -493,7 +493,18 @@ func VerifyChart(path, provfile, keyring string) (*provenance.Verification, erro 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/provenance/sign.go b/pkg/provenance/sign.go index 103c81fbb..3ffad2765 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -23,7 +23,6 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" "golang.org/x/crypto/openpgp" //nolint @@ -194,29 +193,20 @@ func (s *Signatory) DecryptKey(fn PassphraseFetcher) error { return s.Entity.PrivateKey.Decrypt(p) } -// ClearSign signs a package with the given key and pre-marshalled metadata. +// ClearSign signs package data with the given key and pre-marshalled metadata. // -// This takes the path to a package archive file, a key, and marshalled metadata bytes. -// This allows both charts and plugins to use the same signing infrastructure. -// -// 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(packagePath string, metadataBytes []byte) (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(packagePath); err != nil { - return "", err - } else if fi.IsDir() { - return "", errors.New("cannot sign a directory") - } - out := bytes.NewBuffer(nil) - b, err := messageBlock(packagePath, metadataBytes) + b, err := messageBlock(archiveData, filename, metadataBytes) if err != nil { return "", err } @@ -246,69 +236,47 @@ func (s *Signatory) ClearSign(packagePath string, metadataBytes []byte) (string, return out.String(), nil } -// Verify checks a signature and verifies that it is legit for a package. -func (s *Signatory) Verify(packagePath, 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{packagePath, 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(packagePath) + // 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(packagePath) - 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( @@ -318,18 +286,17 @@ func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, er ) } -// messageBlock creates a message block from a package path and pre-marshalled metadata -func messageBlock(packagePath string, metadataBytes []byte) (*bytes.Buffer, error) { - // Checksum the archive - chash, err := DigestFile(packagePath) +// 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 nil, err } - base := filepath.Base(packagePath) sums := &SumCollection{ Files: map[string]string{ - base: "sha256:" + chash, + filename: "sha256:" + chash, }, } diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 4594fac01..4f2fc7298 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -98,7 +98,13 @@ func loadChartMetadataForSigning(t *testing.T, chartPath string) []byte { func TestMessageBlock(t *testing.T) { metadataBytes := loadChartMetadataForSigning(t, testChartfile) - out, err := messageBlock(testChartfile, metadataBytes) + // 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) } @@ -243,7 +249,13 @@ func TestClearSign(t *testing.T) { metadataBytes := loadChartMetadataForSigning(t, testChartfile) - sig, err := signer.ClearSign(testChartfile, metadataBytes) + // 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) } @@ -276,7 +288,13 @@ func TestClearSignError(t *testing.T) { metadataBytes := loadChartMetadataForSigning(t, testChartfile) - sig, err := signer.ClearSign(testChartfile, metadataBytes) + // 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") } @@ -286,56 +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) } - metadataBytes := loadChartMetadataForSigning(t, testChartfile) - - sig, err := signer.ClearSign(testChartfile, metadataBytes) - 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) + // Read the chart file data + archiveData, err := os.ReadFile(testChartfile) if err != nil { t.Fatal(err) } - by, err := signer.verifySignature(sig2) + // Read the signature file data + sigData, err := os.ReadFile(testSigBlock) 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) - 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.") @@ -345,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) } From 591d863df544ec5d4093e514636553a279a67e09 Mon Sep 17 00:00:00 2001 From: Scott Rigby Date: Wed, 20 Aug 2025 17:17:16 -0400 Subject: [PATCH 20/55] Move Postrenderer to a plugin type Fix/add back postrenderer args unit tests Signed-off-by: Scott Rigby --- internal/plugin/config.go | 10 +- internal/plugin/loader_test.go | 30 ++- internal/plugin/metadata.go | 4 +- internal/plugin/metadata_v1.go | 2 +- internal/plugin/runtime_subprocess.go | 66 +++++- .../plugin/schema/postrenderer.go | 30 +-- .../plugdir/good/postrenderer-v1/plugin.yaml | 8 + .../plugdir/good/postrenderer-v1/sed-test.sh | 6 + pkg/action/action.go | 4 +- pkg/action/install.go | 4 +- pkg/action/upgrade.go | 4 +- pkg/cmd/flags.go | 27 +-- pkg/cmd/flags_test.go | 12 +- pkg/cmd/install.go | 2 +- pkg/cmd/template.go | 2 +- .../helm/plugins/postrenderer-v1/plugin.yaml | 8 + .../helm/plugins/postrenderer-v1/sed-test.sh | 6 + pkg/cmd/upgrade.go | 2 +- pkg/postrender/exec.go | 114 ----------- pkg/postrender/exec_test.go | 193 ------------------ pkg/postrenderer/postrenderer.go | 85 ++++++++ pkg/postrenderer/postrenderer_test.go | 89 ++++++++ .../plugins/postrenderer-v1/plugin.yaml | 8 + .../plugins/postrenderer-v1/sed-test.sh | 6 + 24 files changed, 368 insertions(+), 354 deletions(-) rename pkg/postrender/postrender.go => internal/plugin/schema/postrenderer.go (50%) create mode 100644 internal/plugin/testdata/plugdir/good/postrenderer-v1/plugin.yaml create mode 100755 internal/plugin/testdata/plugdir/good/postrenderer-v1/sed-test.sh create mode 100644 pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml create mode 100755 pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/sed-test.sh delete mode 100644 pkg/postrender/exec.go delete mode 100644 pkg/postrender/exec_test.go create mode 100644 pkg/postrenderer/postrenderer.go create mode 100644 pkg/postrenderer/postrenderer_test.go create mode 100644 pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml create mode 100755 pkg/postrenderer/testdata/plugins/postrenderer-v1/sed-test.sh diff --git a/internal/plugin/config.go b/internal/plugin/config.go index 83a2e0b25..e8bf4e356 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -46,8 +46,9 @@ type ConfigGetter struct { Protocols []string `yaml:"protocols"` } -func (c *ConfigCLI) GetType() string { return "cli/v1" } -func (c *ConfigGetter) GetType() string { return "getter/v1" } +// 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 @@ -66,6 +67,11 @@ func (c *ConfigGetter) Validate() error { 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 { diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 81ef26e02..63d930cbe 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -163,6 +163,31 @@ func TestLoadDirGetter(t *testing.T) { 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"), @@ -195,13 +220,14 @@ func TestLoadAll(t *testing.T) { plugsMap[p.Metadata().Name] = p } - assert.Len(t, plugsMap, 6) + 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) { @@ -228,7 +254,7 @@ func TestFindPlugins(t *testing.T) { { name: "normal", plugdirs: "./testdata/plugdir/good", - expected: 6, + expected: 7, }, } for _, c := range cases { diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index bb7e9409f..fbe7a16b8 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -31,7 +31,7 @@ type Metadata struct { // Name is the name of the plugin Name string - // Type of plugin (eg, cli/v1, getter/v1) + // Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1) Type string // Runtime specifies the runtime type (subprocess, wasm) @@ -191,6 +191,8 @@ func convertMetadataConfig(pluginType string, configRaw map[string]any) (Config, 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) } diff --git a/internal/plugin/metadata_v1.go b/internal/plugin/metadata_v1.go index 654aa8900..81dbc2e20 100644 --- a/internal/plugin/metadata_v1.go +++ b/internal/plugin/metadata_v1.go @@ -27,7 +27,7 @@ type MetadataV1 struct { // Name is the name of the plugin Name string `yaml:"name"` - // Type of plugin (eg, cli/v1, getter/v1) + // Type of plugin (eg, cli/v1, getter/v1, postrenderer/v1) Type string `yaml:"type"` // Runtime specifies the runtime type (subprocess, wasm) diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index 163f0621f..e7faeed36 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -16,9 +16,11 @@ limitations under the License. package plugin import ( + "bytes" "context" "fmt" "io" + "log/slog" "os" "os/exec" "syscall" @@ -36,7 +38,7 @@ type SubprocessProtocolCommand struct { Command string `yaml:"command"` } -// RuntimeConfigSubprocess represents configuration for subprocess runtime +// 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"` @@ -73,7 +75,7 @@ type RuntimeSubprocess struct{} var _ Runtime = (*RuntimeSubprocess)(nil) -// CreateRuntime implementation for RuntimeConfig +// CreatePlugin implementation for Runtime func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) (Plugin, error) { return &SubprocessPluginRuntime{ metadata: *metadata, @@ -82,7 +84,7 @@ func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) ( }, nil } -// RuntimeSubprocess implements the Runtime interface for subprocess execution +// SubprocessPluginRuntime implements the Plugin interface for subprocess execution type SubprocessPluginRuntime struct { metadata Metadata pluginDir string @@ -105,6 +107,8 @@ func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Outp 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) } @@ -216,6 +220,62 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { }, 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. 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/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/pkg/action/action.go b/pkg/action/action.go index 38c8b6729..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,7 +176,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // This code has to do with writing files to disk. -func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrender.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { +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) diff --git a/pkg/action/install.go b/pkg/action/install.go index 276009b5c..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" @@ -124,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 } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 63646c12b..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" @@ -114,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 diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index d11073e5f..98881c795 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -31,11 +31,12 @@ import ( "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" ) @@ -164,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 { @@ -181,7 +184,7 @@ type postRendererString struct { } func (p *postRendererString) String() string { - return p.options.binaryPath + return p.options.pluginName } func (p *postRendererString) Type() string { @@ -192,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 } @@ -221,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/install.go b/pkg/cmd/install.go index 361d91e5f..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 } 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/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/upgrade.go b/pkg/cmd/upgrade.go index 74061caf7..c8fbf8bd3 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -300,7 +300,7 @@ 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") 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 From c35755a197e0509a654d44893149e08a438576e5 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Fri, 22 Aug 2025 12:26:33 -0700 Subject: [PATCH 21/55] Remove legacy Command/Hooks from v1 Subprocess (#23) Signed-off-by: George Jenkins --- .../plugin/installer/local_installer_test.go | 2 +- internal/plugin/loader_test.go | 9 +-- internal/plugin/metadata.go | 27 +++++++-- internal/plugin/metadata_legacy.go | 6 +- internal/plugin/metadata_test.go | 39 +++---------- internal/plugin/plugin_test.go | 2 +- internal/plugin/runtime_subprocess.go | 58 +++++-------------- internal/plugin/runtime_subprocess_getter.go | 12 ++-- internal/plugin/subprocess_commands_test.go | 8 +-- .../bad/duplicate-entries-v1/plugin.yaml | 13 +++-- .../testdata/plugdir/good/getter/plugin.yaml | 3 +- pkg/cmd/plugin_package_test.go | 2 +- pkg/cmd/plugin_test.go | 2 +- .../helm/plugins/fullenv/plugin.yaml | 6 +- .../helmhome/helm/plugins/args/plugin.yaml | 3 +- .../helmhome/helm/plugins/echo/plugin.yaml | 3 +- .../helmhome/helm/plugins/env/plugin.yaml | 6 +- .../helm/plugins/exitwith/plugin.yaml | 6 +- .../helmhome/helm/plugins/fullenv/plugin.yaml | 6 +- .../helm/plugins/postrenderer-v1/plugin.yaml | 9 ++- pkg/cmd/testdata/testplugin/plugin.yaml | 12 ++++ pkg/getter/plugingetter_test.go | 2 +- .../plugins/postrenderer-v1/plugin.yaml | 2 +- 23 files changed, 120 insertions(+), 118 deletions(-) create mode 100644 pkg/cmd/testdata/testplugin/plugin.yaml diff --git a/internal/plugin/installer/local_installer_test.go b/internal/plugin/installer/local_installer_test.go index 339028ef3..189108fdb 100644 --- a/internal/plugin/installer/local_installer_test.go +++ b/internal/plugin/installer/local_installer_test.go @@ -86,7 +86,7 @@ func TestLocalInstallerTarball(t *testing.T) { Body string Mode int64 }{ - {"test-plugin/plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644}, + {"test-plugin/plugin.yaml", "name: test-plugin\napiVersion: v1\ntype: cli/v1\nruntime: subprocess\nversion: 1.0.0\nconfig:\n shortHelp: test\n longHelp: test\nruntimeConfig:\n platformCommand:\n - command: echo", 0644}, {"test-plugin/bin/test-plugin", "#!/bin/bash\necho test", 0755}, } diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index 63d930cbe..d214f7b6b 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -80,7 +80,7 @@ func TestLoadDir(t *testing.T) { IgnoreFlags: true, }, RuntimeConfig: &RuntimeConfigSubprocess{ - PlatformCommands: []PlatformCommand{ + 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"}}, }, @@ -90,6 +90,7 @@ func TestLoadDir(t *testing.T) { {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}}, }, }, + expandHookArgs: apiVersion == "legacy", }, } } @@ -150,8 +151,8 @@ func TestLoadDirGetter(t *testing.T) { RuntimeConfig: &RuntimeConfigSubprocess{ ProtocolCommands: []SubprocessProtocolCommand{ { - Protocols: []string{"myprotocol", "myprotocols"}, - Command: "echo getter", + Protocols: []string{"myprotocol", "myprotocols"}, + PlatformCommand: []PlatformCommand{{Command: "echo getter"}}, }, }, }, @@ -174,7 +175,7 @@ func TestPostRenderer(t *testing.T) { Runtime: "subprocess", Config: &ConfigPostrenderer{}, RuntimeConfig: &RuntimeConfigSubprocess{ - PlatformCommands: []PlatformCommand{ + PlatformCommand: []PlatformCommand{ { Command: "${HELM_PLUGIN_DIR}/sed-test.sh", }, diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index fbe7a16b8..1c4f02836 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -144,15 +144,32 @@ func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig { protocolCommands = make([]SubprocessProtocolCommand, 0, len(m.Downloaders)) for _, d := range m.Downloaders { - protocolCommands = append(protocolCommands, SubprocessProtocolCommand(d)) + protocolCommands = append(protocolCommands, SubprocessProtocolCommand{ + Protocols: d.Protocols, + PlatformCommand: []PlatformCommand{{Command: d.Command}}, + }) + } + } + + platformCommand := m.PlatformCommand + if len(platformCommand) == 0 && len(m.Command) > 0 { + platformCommand = []PlatformCommand{{Command: m.Command}} + } + + platformHooks := m.PlatformHooks + expandHookArgs := true + if len(platformHooks) == 0 && len(m.Hooks) > 0 { + platformHooks = make(PlatformHooks, len(m.Hooks)) + for hookName, hookCommand := range m.Hooks { + platformHooks[hookName] = []PlatformCommand{{Command: "sh", Args: []string{"-c", hookCommand}}} + expandHookArgs = false } } return &RuntimeConfigSubprocess{ - PlatformCommands: m.PlatformCommands, - Command: m.Command, - PlatformHooks: m.PlatformHooks, - Hooks: m.Hooks, + PlatformCommand: platformCommand, + PlatformHooks: platformHooks, ProtocolCommands: protocolCommands, + expandHookArgs: expandHookArgs, } } diff --git a/internal/plugin/metadata_legacy.go b/internal/plugin/metadata_legacy.go index ce9c2f580..a7b245dc0 100644 --- a/internal/plugin/metadata_legacy.go +++ b/internal/plugin/metadata_legacy.go @@ -45,8 +45,8 @@ type MetadataLegacy struct { // 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"` + // PlatformCommand is the plugin command, with a platform selector and support for args. + PlatformCommand []PlatformCommand `yaml:"platformCommand"` // Command is the plugin command, as a single string. // DEPRECATED: Use PlatformCommand instead. Removed in subprocess/v1 plugins. @@ -73,7 +73,7 @@ func (m *MetadataLegacy) Validate() error { } m.Usage = sanitizeString(m.Usage) - if len(m.PlatformCommands) > 0 && len(m.Command) > 0 { + if len(m.PlatformCommand) > 0 && len(m.Command) > 0 { return fmt.Errorf("both platformCommand and command are set") } diff --git a/internal/plugin/metadata_test.go b/internal/plugin/metadata_test.go index 810020a67..28bc4cf51 100644 --- a/internal/plugin/metadata_test.go +++ b/internal/plugin/metadata_test.go @@ -25,44 +25,25 @@ 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{}, + PlatformCommand: []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\""}}, + PlatformCommand: []PlatformCommand{ + { + Command: "echo \"mock plugin\"", + }, }, PlatformHooks: map[string][]PlatformCommand{ Install: { - {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}}, + PlatformCommand{ + Command: "echo installing...", + }, }, }, - Hooks: map[string]string{ - Install: "echo installing...", - }, } for i, item := range []struct { @@ -78,8 +59,6 @@ func TestValidatePluginData(t *testing.T) { {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 { diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index fbebecac4..bddabd136 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -23,7 +23,7 @@ func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginR t.Helper() rc := RuntimeConfigSubprocess{ - PlatformCommands: []PlatformCommand{ + PlatformCommand: []PlatformCommand{ {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}}, {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"mock plugin\""}}, }, diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index e7faeed36..6961d1fa5 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -33,28 +33,25 @@ import ( 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"` + // PlatformCommand is the platform based command which the plugin performs + // to download for the corresponding getter Protocols. + PlatformCommand []PlatformCommand `yaml:"platformCommand"` } // 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"` + PlatformCommand []PlatformCommand `yaml:"platformCommand"` // 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 allows the plugin to specify protocol specific commands + // + // Obsolete/deprecated: This is a compatibility hangover from the old plugin downloader mechanism, which was extended + // to support multiple protocols in a given plugin. The command supplied in PlatformCommand should implement protocol + // specific logic by inspecting the download URL ProtocolCommands []SubprocessProtocolCommand `yaml:"protocolCommands,omitempty"` + + expandHookArgs bool } var _ RuntimeConfig = (*RuntimeConfigSubprocess)(nil) @@ -62,12 +59,6 @@ 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 } @@ -138,25 +129,13 @@ func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env } 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 - } - } + cmds := r.RuntimeConfig.PlatformHooks[event] - // If no hook commands are defined, just return successfully if len(cmds) == 0 { return nil } - main, argv, err := PrepareCommands(cmds, expandArgs, []string{}) + main, argv, err := PrepareCommands(cmds, r.RuntimeConfig.expandHookArgs, []string{}) if err != nil { return err } @@ -200,10 +179,7 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { 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}} - } + cmds := r.RuntimeConfig.PlatformCommand command, args, err := PrepareCommands(cmds, true, extraArgs) if err != nil { @@ -232,11 +208,7 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) // 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}} - } - + cmds := r.RuntimeConfig.PlatformCommand command, args, err := PrepareCommands(cmds, true, extraArgs) if err != nil { return nil, fmt.Errorf("failed to prepare plugin command: %w", err) diff --git a/internal/plugin/runtime_subprocess_getter.go b/internal/plugin/runtime_subprocess_getter.go index af2d0c572..d1884bc93 100644 --- a/internal/plugin/runtime_subprocess_getter.go +++ b/internal/plugin/runtime_subprocess_getter.go @@ -22,7 +22,6 @@ import ( "os/exec" "path/filepath" "slices" - "strings" "helm.sh/helm/v4/internal/plugin/schema" ) @@ -55,9 +54,12 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol) } - commands := strings.Split(d.Command, " ") - args := append( - commands[1:], + command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}) + if err != nil { + return nil, fmt.Errorf("failed to prepare commands for protocol %q: %w", msg.Protocol, err) + } + args = append( + args, msg.Options.CertFile, msg.Options.KeyFile, msg.Options.CAFile, @@ -73,7 +75,7 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { // 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]) + pluginCommand := filepath.Join(r.pluginDir, command) prog := exec.Command( pluginCommand, args...) diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go index 3cb9325ab..16446cdec 100644 --- a/internal/plugin/subprocess_commands_test.go +++ b/internal/plugin/subprocess_commands_test.go @@ -27,14 +27,14 @@ func TestPrepareCommand(t *testing.T) { cmdMain := "sh" cmdArgs := []string{"-c", "echo \"test\""} - platformCommands := []PlatformCommand{ + 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 := PrepareCommands(platformCommands, true, []string{}) + cmd, args, err := PrepareCommands(platformCommand, true, []string{}) if err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestPrepareCommandExtraArgs(t *testing.T) { cmdMain := "sh" cmdArgs := []string{"-c", "echo \"test\""} - platformCommands := []PlatformCommand{ + 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\""}}, @@ -91,7 +91,7 @@ func TestPrepareCommandExtraArgs(t *testing.T) { if tc.ignoreFlags { testExtraArgs = []string{} } - cmd, args, err := PrepareCommands(platformCommands, true, testExtraArgs) + cmd, args, err := PrepareCommands(platformCommand, true, testExtraArgs) if err != nil { t.Fatal(err) } diff --git a/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml index 030ae6aca..344141121 100644 --- a/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml +++ b/internal/plugin/testdata/plugdir/bad/duplicate-entries-v1/plugin.yaml @@ -9,8 +9,11 @@ config: description ignoreFlags: true runtimeConfig: - command: "echo hello" - hooks: - install: "echo installing..." - hooks: - install: "echo installing something different" + platformCommand: + - command: "echo hello" + platformHooks: + install: + - command: "echo installing..." + platformHooks: + install: + - command: "echo installing something different" diff --git a/internal/plugin/testdata/plugdir/good/getter/plugin.yaml b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml index cfe80fbdc..7bdee9bde 100644 --- a/internal/plugin/testdata/plugdir/good/getter/plugin.yaml +++ b/internal/plugin/testdata/plugdir/good/getter/plugin.yaml @@ -10,7 +10,8 @@ config: - "myprotocols" runtimeConfig: protocolCommands: - - command: "echo getter" + - platformCommand: + - command: "echo getter" protocols: - "myprotocol" - "myprotocols" diff --git a/pkg/cmd/plugin_package_test.go b/pkg/cmd/plugin_package_test.go index df6cdd849..7d97562f8 100644 --- a/pkg/cmd/plugin_package_test.go +++ b/pkg/cmd/plugin_package_test.go @@ -34,7 +34,7 @@ config: shortHelp: A test plugin longHelp: A test plugin for testing purposes runtimeConfig: - platformCommands: + platformCommand: - os: linux command: echo args: ["test"]` diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index b476b80d2..738a64740 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -122,7 +122,7 @@ func TestLoadCLIPlugins(t *testing.T) { require.Len(t, plugins, len(tests), "Expected %d plugins, got %d", len(tests), len(plugins)) - for i := 0; i < len(plugins); i++ { + for i := range plugins { out.Reset() tt := tests[i] pp := plugins[i] diff --git a/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml b/pkg/cmd/testdata/helm home with space/helm/plugins/fullenv/plugin.yaml index 8b874da1d..a58544b03 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,10 +1,12 @@ +--- +apiVersion: v1 name: fullenv type: cli/v1 -apiVersion: v1 runtime: subprocess config: shortHelp: "show env vars" longHelp: "show all env vars" ignoreFlags: false runtimeConfig: - command: "$HELM_PLUGIN_DIR/fullenv.sh" + platformCommand: + - 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 57312cbfa..4156e7f17 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/args/plugin.yaml @@ -7,4 +7,5 @@ config: longHelp: "This echos args" ignoreFlags: false runtimeConfig: - command: "$HELM_PLUGIN_DIR/args.sh" + platformCommand: + - 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 544efa85e..a0a0b5255 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/echo/plugin.yaml @@ -7,4 +7,5 @@ config: longHelp: "This echos stuff" ignoreFlags: false runtimeConfig: - command: "echo hello" + platformCommand: + - 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 d7a4c229c..fa933af93 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml @@ -1,10 +1,12 @@ +--- +apiVersion: v1 name: env type: cli/v1 -apiVersion: v1 runtime: subprocess config: shortHelp: "env stuff" longHelp: "show the env" ignoreFlags: false runtimeConfig: - command: "echo $HELM_PLUGIN_NAME" + platformCommand: + - 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 06a350f83..ba9508255 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/exitwith/plugin.yaml @@ -1,10 +1,12 @@ +--- +apiVersion: v1 name: exitwith type: cli/v1 -apiVersion: v1 runtime: subprocess config: shortHelp: "exitwith code" longHelp: "This exits with the specified exit code" ignoreFlags: false runtimeConfig: - command: "$HELM_PLUGIN_DIR/exitwith.sh" + platformCommand: + - 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 8b874da1d..a58544b03 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/plugin.yaml @@ -1,10 +1,12 @@ +--- +apiVersion: v1 name: fullenv type: cli/v1 -apiVersion: v1 runtime: subprocess config: shortHelp: "show env vars" longHelp: "show all env vars" ignoreFlags: false runtimeConfig: - command: "$HELM_PLUGIN_DIR/fullenv.sh" + platformCommand: + - 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 index 30f1599b4..d4cd57a13 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml @@ -1,8 +1,13 @@ +--- +apiVersion: v1 name: "postrenderer-v1" version: "1.2.3" type: postrenderer/v1 -apiVersion: v1 runtime: subprocess +config: + shortHelp: "echo test" + longHelp: "This echos test" + ignoreFlags: false runtimeConfig: platformCommand: - - command: "${HELM_PLUGIN_DIR}/sed-test.sh" + - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/cmd/testdata/testplugin/plugin.yaml b/pkg/cmd/testdata/testplugin/plugin.yaml new file mode 100644 index 000000000..3ee5d04f6 --- /dev/null +++ b/pkg/cmd/testdata/testplugin/plugin.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: v1 +name: testplugin +type: cli/v1 +runtime: subprocess +config: + shortHelp: "echo test" + longHelp: "This echos test" + ignoreFlags: false +runtimeConfig: + platformCommand: + - command: "echo test" diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 8e0619635..23cfc80f8 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -112,7 +112,7 @@ func (t *testPlugin) Metadata() plugin.Metadata { Runtime: "subprocess", Config: &plugin.ConfigCLI{}, RuntimeConfig: &plugin.RuntimeConfigSubprocess{ - PlatformCommands: []plugin.PlatformCommand{ + PlatformCommand: []plugin.PlatformCommand{ { Command: "echo fake-plugin", }, diff --git a/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml index 30f1599b4..423a5191e 100644 --- a/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml +++ b/pkg/postrenderer/testdata/plugins/postrenderer-v1/plugin.yaml @@ -5,4 +5,4 @@ apiVersion: v1 runtime: subprocess runtimeConfig: platformCommand: - - command: "${HELM_PLUGIN_DIR}/sed-test.sh" + - command: "${HELM_PLUGIN_DIR}/sed-test.sh" From 89aca09e5e40674f63c1d01cfcd69bfac0dc219d Mon Sep 17 00:00:00 2001 From: tzchenxixi Date: Mon, 1 Sep 2025 18:30:27 +0800 Subject: [PATCH 22/55] chore: fix function name Signed-off-by: tzchenxixi --- pkg/kube/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/kube/client.go b/pkg/kube/client.go index c41165490..26ba7abfc 100644 --- a/pkg/kube/client.go +++ b/pkg/kube/client.go @@ -214,7 +214,7 @@ type clientCreateOptions struct { type ClientCreateOption func(*clientCreateOptions) error -// ClientUpdateOptionServerSideApply enables performing object apply server-side +// ClientCreateOptionServerSideApply enables performing object apply server-side // see: https://kubernetes.io/docs/reference/using-api/server-side-apply/ // // `forceConflicts` forces conflicts to be resolved (may be when serverSideApply enabled only) From 5595c0d00587892beb03505dd99ae3ec31ceefa9 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Mon, 1 Sep 2025 17:48:35 +0200 Subject: [PATCH 23/55] Prevent failing helm push on ghcr.io using standard GET auth token flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix GHCR auth by not forcing OAuth2 POST but also reset ForceAttemptOAuth2 after login. - Remove ForceAttemptOAuth2 in NewClient and only enable during Login ping and always restore to false. - Aligns with OCI Distribution auth (token via GET), avoiding GHCR 405 on POST /token. - Some tests Failures logs: ```sh ~/p/lifen/test/helm-f/quicktest ❯ ../../../helm/bin/helm push quicktest-0.1.0.tgz oci://ghcr.io/benoittgt/helm-charts --debug level=DEBUG msg=HEAD id=0 url=https://ghcr.io/v2/benoittgt/helm-charts/quicktest/manifests/sha256:af359fd8fb968ec1097afbd6e8e1dac9ee130861082e54dc2340d0c019407873 header=" \"Accept\": \"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.artifact.manifest.v1+json\"\n \"User-Agent\": \"Helm/4.0+unreleased\"" level=DEBUG msg=Resp id=0 status="401 Unauthorized" header=" \"Www-Authenticate\": \"Bearer realm=\\\"https://ghcr.io/token\\\",service=\\\"ghcr.io\\\",scope=\\\"repository:benoittgt/helm-charts/quicktest:pull\\\"\"\n \"Date\": \"Mon, 01 Sep 2025 13:56:35 GMT\"\n \"Content-Length\": \"73\"\n \"X-Github-Request-Id\": \"DC73:115F:2B40F2C:2BAB567:68B5A613\"\n \"Content-Type\": \"application/json\"" body=" Response body is empty" level=DEBUG msg=POST id=1 url=https://ghcr.io/token header=" \"Content-Type\": \"application/x-www-form-urlencoded\"\n \"User-Agent\": \"Helm/4.0+unreleased\"" level=DEBUG msg=Resp id=1 status="405 Method Not Allowed" header=" \"Docker-Distribution-Api-Version\": \"registry/2.0\"\n \"Strict-Transport-Security\": \"max-age=63072000; includeSubDomains; preload\"\n \"Date\": \"Mon, 01 Sep 2025 13:56:35 GMT\"\n \"Content-Length\": \"78\"\n \"X-Github-Request-Id\": \"DC73:115F:2B40F75:2BAB5C2:68B5A613\"\n \"Content-Type\": \"application/json\"" body="{\"errors\":[{\"code\":\"UNSUPPORTED\",\"message\":\"The operation is unsupported.\"}]}\n" Error: failed to perform "Exists" on destination: HEAD "https://ghcr.io/v2/benoittgt/helm-charts/quicktest/manifests/sha256:af359fd8fb968ec1097afbd6e8e1dac9ee130861082e54dc2340d0c019407873": POST "https://ghcr.io/token": response status code 405: unsupported: The operation is unsupported. ``` Signed-off-by: Benoit Tigeot --- pkg/registry/client.go | 4 +-- pkg/registry/client_test.go | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 7ba26ac5c..95250f8da 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -137,8 +137,6 @@ func NewClient(options ...ClientOption) (*Client, error) { if client.enableCache { authorizer.Cache = auth.NewCache() } - - authorizer.ForceAttemptOAuth2 = true client.authorizer = &authorizer } @@ -251,6 +249,8 @@ func (c *Client) Login(host string, options ...LoginOption) error { return fmt.Errorf("authenticating to %q: %w", host, err) } } + // Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR. + c.authorizer.ForceAttemptOAuth2 = false key := credentials.ServerAddressFromRegistry(host) key = credentials.ServerAddressFromHostname(key) diff --git a/pkg/registry/client_test.go b/pkg/registry/client_test.go index 2ffd691c2..6ae32e342 100644 --- a/pkg/registry/client_test.go +++ b/pkg/registry/client_test.go @@ -18,6 +18,10 @@ package registry import ( "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" "testing" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -51,3 +55,68 @@ func TestTagManifestTransformsReferences(t *testing.T) { _, err = memStore.Resolve(ctx, refWithPlus) require.Error(t, err, "Should NOT find the reference with the original +") } + +// Verifies that Login always restores ForceAttemptOAuth2 to false on success. +func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/" { + // Accept either HEAD or GET + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + host := strings.TrimPrefix(srv.URL, "http://") + + credFile := filepath.Join(t.TempDir(), "config.json") + c, err := NewClient( + ClientOptWriter(io.Discard), + ClientOptCredentialsFile(credFile), + ) + if err != nil { + t.Fatalf("NewClient error: %v", err) + } + + if c.authorizer == nil || c.authorizer.ForceAttemptOAuth2 { + t.Fatalf("expected ForceAttemptOAuth2 default to be false") + } + + // Call Login with plain HTTP against our test server + if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err != nil { + t.Fatalf("Login error: %v", err) + } + + if c.authorizer.ForceAttemptOAuth2 { + t.Errorf("ForceAttemptOAuth2 should be false after successful Login") + } +} + +// Verifies that Login restores ForceAttemptOAuth2 to false even when ping fails. +func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { + t.Parallel() + + // Start and immediately close, so connections will fail + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + host := strings.TrimPrefix(srv.URL, "http://") + srv.Close() + + credFile := filepath.Join(t.TempDir(), "config.json") + c, err := NewClient( + ClientOptWriter(io.Discard), + ClientOptCredentialsFile(credFile), + ) + if err != nil { + t.Fatalf("NewClient error: %v", err) + } + + // Invoke Login, expect an error but ForceAttemptOAuth2 must end false + _ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")) + + if c.authorizer.ForceAttemptOAuth2 { + t.Errorf("ForceAttemptOAuth2 should be false after failed Login") + } +} From d99d73254261d851d5f2fd8dad45b8881d1b9638 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Mon, 1 Sep 2025 09:39:38 -0700 Subject: [PATCH 24/55] fix: Adjust PostRenderer plugin output to value Signed-off-by: George Jenkins --- internal/plugin/runtime_subprocess.go | 2 +- pkg/postrenderer/postrenderer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index e7faeed36..55362b972 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -270,7 +270,7 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) } return &Output{ - Message: &schema.OutputMessagePostRendererV1{ + Message: schema.OutputMessagePostRendererV1{ Manifests: postRendered, }, }, nil diff --git a/pkg/postrenderer/postrenderer.go b/pkg/postrenderer/postrenderer.go index 2107cc465..ed6699c32 100644 --- a/pkg/postrenderer/postrenderer.go +++ b/pkg/postrenderer/postrenderer.go @@ -73,7 +73,7 @@ func (r *postRendererPlugin) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer return nil, fmt.Errorf("failed to invoke post-renderer plugin %q: %w", r.plugin.Metadata().Name, err) } - outputMessage := output.Message.(*schema.OutputMessagePostRendererV1) + outputMessage := output.Message.(schema.OutputMessagePostRendererV1) // If the binary returned almost nothing, it's likely that it didn't // successfully render anything From ee37c00c33e96c9c8747560c5e967e496547b33b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:57:28 +0000 Subject: [PATCH 25/55] chore(deps): bump sigs.k8s.io/controller-runtime from 0.21.0 to 0.22.0 Bumps [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) from 0.21.0 to 0.22.0. - [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases) - [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.21.0...v0.22.0) --- updated-dependencies: - dependency-name: sigs.k8s.io/controller-runtime dependency-version: 0.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3c9992dce..ab8797e6f 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubectl v0.34.0 oras.land/oras-go/v2 v2.6.0 - sigs.k8s.io/controller-runtime v0.21.0 + sigs.k8s.io/controller-runtime v0.22.0 sigs.k8s.io/kustomize/kyaml v0.20.1 sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index d9e7c3d3d..076b6e5bd 100644 --- a/go.sum +++ b/go.sum @@ -532,8 +532,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= -sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= -sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= +sigs.k8s.io/controller-runtime v0.22.0 h1:mTOfibb8Hxwpx3xEkR56i7xSjB+nH4hZG37SrlCY5e0= +sigs.k8s.io/controller-runtime v0.22.0/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= From 5926ec83dd4760d02f316652a801f0812af39d87 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Fri, 22 Aug 2025 15:06:09 -0700 Subject: [PATCH 26/55] Remove SetupPluginEnv Signed-off-by: George Jenkins --- cmd/helm/helm.go | 8 +- cmd/helm/helm_test.go | 20 +-- internal/plugin/error.go | 4 +- internal/plugin/plugin_test.go | 2 + internal/plugin/runtime.go | 9 ++ internal/plugin/runtime_extismv1.go | 2 +- internal/plugin/runtime_subprocess.go | 117 ++++++++++-------- internal/plugin/runtime_subprocess_getter.go | 34 ++--- internal/plugin/runtime_subprocess_test.go | 92 ++++++++------ internal/plugin/runtime_test.go | 37 ++++++ internal/plugin/schema/postrenderer.go | 5 +- internal/plugin/subprocess_commands.go | 6 +- internal/plugin/subprocess_commands_test.go | 31 +++-- pkg/cmd/load_plugins.go | 16 +-- pkg/cmd/plugin.go | 1 - pkg/cmd/plugin_test.go | 86 +++++++------ pkg/cmd/root.go | 5 + .../helmhome/helm/plugins/env/plugin-name.sh | 3 + .../helmhome/helm/plugins/env/plugin.yaml | 2 +- .../helmhome/helm/plugins/fullenv/fullenv.sh | 12 +- pkg/postrenderer/postrenderer.go | 1 - pkg/postrenderer/postrenderer_test.go | 8 -- 22 files changed, 296 insertions(+), 205 deletions(-) create mode 100755 pkg/cmd/testdata/helmhome/helm/plugins/env/plugin-name.sh diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 05e7e7ba2..66d342500 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -41,11 +41,9 @@ func main() { } if err := cmd.Execute(); err != nil { - switch e := err.(type) { - case helmcmd.PluginError: - os.Exit(e.Code) - default: - os.Exit(1) + if cerr, ok := err.(helmcmd.CommandError); ok { + os.Exit(cerr.ExitCode) } + os.Exit(1) } } diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 5431daad0..0458e8037 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -22,11 +22,13 @@ import ( "os/exec" "runtime" "testing" + + "github.com/stretchr/testify/assert" ) -func TestPluginExitCode(t *testing.T) { +func TestCliPluginExitCode(t *testing.T) { if os.Getenv("RUN_MAIN_FOR_TESTING") == "1" { - os.Args = []string{"helm", "exitwith", "2"} + os.Args = []string{"helm", "exitwith", "43"} // We DO call helm's main() here. So this looks like a normal `helm` process. main() @@ -43,7 +45,7 @@ func TestPluginExitCode(t *testing.T) { // So that the second run is able to run main() and this first run can verify the exit status returned by that. // // This technique originates from https://talks.golang.org/2014/testing.slide#23. - cmd := exec.Command(os.Args[0], "-test.run=TestPluginExitCode") + cmd := exec.Command(os.Args[0], "-test.run=TestCliPluginExitCode") cmd.Env = append( os.Environ(), "RUN_MAIN_FOR_TESTING=1", @@ -57,23 +59,21 @@ func TestPluginExitCode(t *testing.T) { cmd.Stdout = stdout cmd.Stderr = stderr err := cmd.Run() - exiterr, ok := err.(*exec.ExitError) + exiterr, ok := err.(*exec.ExitError) if !ok { - t.Fatalf("Unexpected error returned by os.Exit: %T", err) + t.Fatalf("Unexpected error type returned by os.Exit: %T", err) } - if stdout.String() != "" { - t.Errorf("Expected no write to stdout: Got %q", stdout.String()) - } + assert.Empty(t, stdout.String()) expectedStderr := "Error: plugin \"exitwith\" exited with error\n" if stderr.String() != expectedStderr { t.Errorf("Expected %q written to stderr: Got %q", expectedStderr, stderr.String()) } - if exiterr.ExitCode() != 2 { - t.Errorf("Expected exit code 2: Got %d", exiterr.ExitCode()) + if exiterr.ExitCode() != 43 { + t.Errorf("Expected exit code 43: Got %d", exiterr.ExitCode()) } } } diff --git a/internal/plugin/error.go b/internal/plugin/error.go index 5ace680cb..212460cea 100644 --- a/internal/plugin/error.go +++ b/internal/plugin/error.go @@ -19,8 +19,8 @@ package plugin // - 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 + ExitCode int // Exit code from plugin code execution + Err error // Underlying error } // Error implements the error interface diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index bddabd136..a4de8e52a 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -24,11 +24,13 @@ func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginR rc := RuntimeConfigSubprocess{ PlatformCommand: []PlatformCommand{ + {OperatingSystem: "darwin", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"mock plugin\""}}, {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: "darwin", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}}, {OperatingSystem: "linux", Architecture: "", Command: "sh", Args: []string{"-c", "echo \"installing...\""}}, {OperatingSystem: "windows", Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"installing...\""}}, }, diff --git a/internal/plugin/runtime.go b/internal/plugin/runtime.go index a9c01a380..b2ff0b7ca 100644 --- a/internal/plugin/runtime.go +++ b/internal/plugin/runtime.go @@ -16,6 +16,7 @@ limitations under the License. package plugin import ( + "fmt" "strings" "go.yaml.in/yaml/v3" @@ -73,3 +74,11 @@ func parseEnv(env []string) map[string]string { } return result } + +func formatEnv(env map[string]string) []string { + result := make([]string, 0, len(env)) + for key, value := range env { + result = append(result, fmt.Sprintf("%s=%s", key, value)) + } + return result +} diff --git a/internal/plugin/runtime_extismv1.go b/internal/plugin/runtime_extismv1.go index c0122d08f..b5cc79a6f 100644 --- a/internal/plugin/runtime_extismv1.go +++ b/internal/plugin/runtime_extismv1.go @@ -196,7 +196,7 @@ func (p *ExtismV1PluginRuntime) Invoke(ctx context.Context, input *Input) (*Outp if exitCode != 0 { return nil, &InvokeExecError{ - Code: int(exitCode), + ExitCode: int(exitCode), } } diff --git a/internal/plugin/runtime_subprocess.go b/internal/plugin/runtime_subprocess.go index a1a698679..5e6676a00 100644 --- a/internal/plugin/runtime_subprocess.go +++ b/internal/plugin/runtime_subprocess.go @@ -21,12 +21,12 @@ import ( "fmt" "io" "log/slog" + "maps" "os" "os/exec" - "syscall" + "slices" "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 @@ -62,7 +62,9 @@ func (r *RuntimeConfigSubprocess) Validate() error { return nil } -type RuntimeSubprocess struct{} +type RuntimeSubprocess struct { + EnvVars map[string]string +} var _ Runtime = (*RuntimeSubprocess)(nil) @@ -72,6 +74,7 @@ func (r *RuntimeSubprocess) CreatePlugin(pluginDir string, metadata *Metadata) ( metadata: *metadata, pluginDir: pluginDir, RuntimeConfig: *(metadata.RuntimeConfig.(*RuntimeConfigSubprocess)), + EnvVars: maps.Clone(r.EnvVars), }, nil } @@ -80,6 +83,7 @@ type SubprocessPluginRuntime struct { metadata Metadata pluginDir string RuntimeConfig RuntimeConfigSubprocess + EnvVars map[string]string } var _ Plugin = (*SubprocessPluginRuntime)(nil) @@ -109,22 +113,22 @@ func (r *SubprocessPluginRuntime) Invoke(_ context.Context, input *Input) (*Outp // This method allows execution with different command/args than the plugin's default func (r *SubprocessPluginRuntime) InvokeWithEnv(main string, argv []string, env []string, stdin io.Reader, stdout, stderr io.Writer) error { mainCmdExp := os.ExpandEnv(main) - prog := exec.Command(mainCmdExp, argv...) - prog.Env = env - prog.Stdin = stdin - prog.Stdout = stdout - prog.Stderr = stderr + cmd := exec.Command(mainCmdExp, argv...) + cmd.Env = slices.Clone(os.Environ()) + cmd.Env = append( + cmd.Env, + fmt.Sprintf("HELM_PLUGIN_NAME=%s", r.metadata.Name), + fmt.Sprintf("HELM_PLUGIN_DIR=%s", r.pluginDir)) + cmd.Env = append(cmd.Env, env...) + + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.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(), - } - } + if err := executeCmd(cmd, r.metadata.Name); err != nil { + return err } + return nil } @@ -135,15 +139,23 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { return nil } - main, argv, err := PrepareCommands(cmds, r.RuntimeConfig.expandHookArgs, []string{}) + env := parseEnv(os.Environ()) + maps.Insert(env, maps.All(r.EnvVars)) + env["HELM_PLUGIN_NAME"] = r.metadata.Name + env["HELM_PLUGIN_DIR"] = r.pluginDir + + main, argv, err := PrepareCommands(cmds, r.RuntimeConfig.expandHookArgs, []string{}, env) if err != nil { return err } - prog := exec.Command(main, argv...) - prog.Stdout, prog.Stderr = os.Stdout, os.Stderr + cmd := exec.Command(main, argv...) + cmd.Env = formatEnv(env) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr - if err := prog.Run(); err != nil { + slog.Debug("executing plugin hook command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String())) + if err := cmd.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) @@ -159,10 +171,15 @@ func (r *SubprocessPluginRuntime) InvokeHook(event string) error { 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) + slog.Debug( + "plugin execution failed", + slog.String("pluginName", pluginName), + slog.String("error", err.Error()), + slog.Int("exitCode", eerr.ExitCode()), + slog.String("stderr", string(bytes.TrimSpace(eerr.Stderr)))) return &InvokeExecError{ - Err: fmt.Errorf("plugin %q exited with error", pluginName), - Code: eerr.ExitCode(), + Err: fmt.Errorf("plugin %q exited with error", pluginName), + ExitCode: eerr.ExitCode(), } } @@ -181,14 +198,27 @@ func (r *SubprocessPluginRuntime) runCLI(input *Input) (*Output, error) { cmds := r.RuntimeConfig.PlatformCommand - command, args, err := PrepareCommands(cmds, true, extraArgs) + env := parseEnv(os.Environ()) + maps.Insert(env, maps.All(r.EnvVars)) + maps.Insert(env, maps.All(parseEnv(input.Env))) + env["HELM_PLUGIN_NAME"] = r.metadata.Name + env["HELM_PLUGIN_DIR"] = r.pluginDir + + command, args, err := PrepareCommands(cmds, true, extraArgs, env) 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 + cmd := exec.Command(command, args...) + cmd.Env = formatEnv(env) + + cmd.Stdin = input.Stdin + cmd.Stdout = input.Stdout + cmd.Stderr = input.Stderr + + slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String())) + if err := executeCmd(cmd, r.metadata.Name); err != nil { + return nil, err } return &Output{ @@ -201,20 +231,19 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) 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) + env := parseEnv(os.Environ()) + maps.Insert(env, maps.All(r.EnvVars)) + maps.Insert(env, maps.All(parseEnv(input.Env))) + env["HELM_PLUGIN_NAME"] = r.metadata.Name + env["HELM_PLUGIN_DIR"] = r.pluginDir + msg := input.Message.(schema.InputMessagePostRendererV1) cmds := r.RuntimeConfig.PlatformCommand - command, args, err := PrepareCommands(cmds, true, extraArgs) + command, args, err := PrepareCommands(cmds, true, msg.ExtraArgs, env) 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...) @@ -232,12 +261,12 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) postRendered := &bytes.Buffer{} stderr := &bytes.Buffer{} - //cmd.Env = pluginExec.env + cmd.Env = formatEnv(env) cmd.Stdout = postRendered cmd.Stderr = stderr + slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String())) if err := executeCmd(cmd, r.metadata.Name); err != nil { - slog.Info("plugin execution failed", slog.String("stderr", stderr.String())) return nil, err } @@ -247,15 +276,3 @@ func (r *SubprocessPluginRuntime) runPostrenderer(input *Input) (*Output, error) }, }, 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 index d1884bc93..6a41b149f 100644 --- a/internal/plugin/runtime_subprocess_getter.go +++ b/internal/plugin/runtime_subprocess_getter.go @@ -18,6 +18,8 @@ package plugin import ( "bytes" "fmt" + "log/slog" + "maps" "os" "os/exec" "path/filepath" @@ -54,10 +56,20 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { return nil, fmt.Errorf("no downloader found for protocol %q", msg.Protocol) } - command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}) + env := parseEnv(os.Environ()) + maps.Insert(env, maps.All(r.EnvVars)) + maps.Insert(env, maps.All(parseEnv(input.Env))) + env["HELM_PLUGIN_NAME"] = r.metadata.Name + env["HELM_PLUGIN_DIR"] = r.pluginDir + env["HELM_PLUGIN_USERNAME"] = msg.Options.Username + env["HELM_PLUGIN_PASSWORD"] = msg.Options.Password + env["HELM_PLUGIN_PASS_CREDENTIALS_ALL"] = fmt.Sprintf("%t", msg.Options.PassCredentialsAll) + + command, args, err := PrepareCommands(d.PlatformCommand, false, []string{}, env) if err != nil { return nil, fmt.Errorf("failed to prepare commands for protocol %q: %w", msg.Protocol, err) } + args = append( args, msg.Options.CertFile, @@ -65,24 +77,18 @@ func (r *SubprocessPluginRuntime) runGetter(input *Input) (*Output, error) { 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, command) - prog := exec.Command( + cmd := exec.Command( pluginCommand, args...) - prog.Env = env - prog.Stdout = &buf - prog.Stderr = os.Stderr - if err := executeCmd(prog, r.metadata.Name); err != nil { + cmd.Env = formatEnv(env) + cmd.Stdout = &buf + cmd.Stderr = os.Stderr + + slog.Debug("executing plugin command", slog.String("pluginName", r.metadata.Name), slog.String("command", cmd.String())) + if err := executeCmd(cmd, r.metadata.Name); err != nil { return nil, err } diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go index 9d932816d..dab372027 100644 --- a/internal/plugin/runtime_subprocess_test.go +++ b/internal/plugin/runtime_subprocess_test.go @@ -16,49 +16,69 @@ limitations under the License. package plugin import ( + "fmt" "os" "path/filepath" "testing" - "helm.sh/helm/v4/pkg/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" + + "helm.sh/helm/v4/internal/plugin/schema" ) -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 mockSubprocessCLIPluginErrorExit(t *testing.T, pluginName string, exitCode uint8) *SubprocessPluginRuntime { + t.Helper() + + rc := RuntimeConfigSubprocess{ + PlatformCommand: []PlatformCommand{ + {Command: "sh", Args: []string{"-c", fmt.Sprintf("echo \"mock plugin $@\"; exit %d", exitCode)}}, + }, + } + + pluginDir := t.TempDir() + + md := 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, } -} -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) - } + data, err := yaml.Marshal(md) + require.NoError(t, err) + os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), data, 0o644) + + return &SubprocessPluginRuntime{ + metadata: md, + pluginDir: pluginDir, + RuntimeConfig: rc, } } + +func TestSubprocessPluginRuntime(t *testing.T) { + p := mockSubprocessCLIPluginErrorExit(t, "foo", 56) + + output, err := p.Invoke(t.Context(), &Input{ + Message: schema.InputMessageCLIV1{ + ExtraArgs: []string{"arg1", "arg2"}, + //Env: []string{"FOO=bar"}, + }, + }) + + require.Error(t, err) + ieerr, ok := err.(*InvokeExecError) + require.True(t, ok, "expected InvokeExecError, got %T", err) + assert.Equal(t, 56, ieerr.ExitCode) + + assert.Nil(t, output) +} diff --git a/internal/plugin/runtime_test.go b/internal/plugin/runtime_test.go index 8b72648b2..f8fe481c1 100644 --- a/internal/plugin/runtime_test.go +++ b/internal/plugin/runtime_test.go @@ -61,3 +61,40 @@ func TestParseEnv(t *testing.T) { }) } } + +func TestFormatEnv(t *testing.T) { + type testCase struct { + env map[string]string + expected []string + } + + testCases := map[string]testCase{ + "empty": { + env: map[string]string{}, + expected: []string{}, + }, + "single": { + env: map[string]string{"KEY": "value"}, + expected: []string{"KEY=value"}, + }, + "multiple": { + env: map[string]string{"KEY1": "value1", "KEY2": "value2"}, + expected: []string{"KEY1=value1", "KEY2=value2"}, + }, + "empty_key": { + env: map[string]string{"": "value1", "KEY2": "value2"}, + expected: []string{"=value1", "KEY2=value2"}, + }, + "empty_value": { + env: map[string]string{"KEY1": "value1", "KEY2": "", "KEY3": "value3"}, + expected: []string{"KEY1=value1", "KEY2=", "KEY3=value3"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + result := formatEnv(tc.env) + assert.ElementsMatch(t, tc.expected, result) + }) + } +} diff --git a/internal/plugin/schema/postrenderer.go b/internal/plugin/schema/postrenderer.go index 0f0c09369..82fd3059f 100644 --- a/internal/plugin/schema/postrenderer.go +++ b/internal/plugin/schema/postrenderer.go @@ -18,16 +18,13 @@ 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"` + ExtraArgs []string `json:"extraArgs"` } type OutputMessagePostRendererV1 struct { diff --git a/internal/plugin/subprocess_commands.go b/internal/plugin/subprocess_commands.go index d979f98e3..e21ec2bab 100644 --- a/internal/plugin/subprocess_commands.go +++ b/internal/plugin/subprocess_commands.go @@ -77,13 +77,15 @@ func getPlatformCommand(cmds []PlatformCommand) ([]string, []string) { // 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) { +func PrepareCommands(cmds []PlatformCommand, expandArgs bool, extraArgs []string, env map[string]string) (string, []string, error) { cmdParts, args := getPlatformCommand(cmds) if len(cmdParts) == 0 || cmdParts[0] == "" { return "", nil, fmt.Errorf("no plugin command is applicable") } - main := os.ExpandEnv(cmdParts[0]) + main := os.Expand(cmdParts[0], func(key string) string { + return env[key] + }) baseArgs := []string{} if len(cmdParts) > 1 { for _, cmdPart := range cmdParts[1:] { diff --git a/internal/plugin/subprocess_commands_test.go b/internal/plugin/subprocess_commands_test.go index 16446cdec..c1eba7a55 100644 --- a/internal/plugin/subprocess_commands_test.go +++ b/internal/plugin/subprocess_commands_test.go @@ -34,7 +34,8 @@ func TestPrepareCommand(t *testing.T) { {OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Command: cmdMain, Args: cmdArgs}, } - cmd, args, err := PrepareCommands(platformCommand, true, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(platformCommand, true, []string{}, env) if err != nil { t.Fatal(err) } @@ -91,7 +92,9 @@ func TestPrepareCommandExtraArgs(t *testing.T) { if tc.ignoreFlags { testExtraArgs = []string{} } - cmd, args, err := PrepareCommands(platformCommand, true, testExtraArgs) + + env := map[string]string{} + cmd, args, err := PrepareCommands(platformCommand, true, testExtraArgs, env) if err != nil { t.Fatal(err) } @@ -112,7 +115,8 @@ func TestPrepareCommands(t *testing.T) { {OperatingSystem: runtime.GOOS, Architecture: "", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, } - cmd, args, err := PrepareCommands(cmds, true, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, true, []string{}, env) if err != nil { t.Fatal(err) } @@ -138,7 +142,8 @@ func TestPrepareCommandsExtraArgs(t *testing.T) { expectedArgs := append(cmdArgs, extraArgs...) - cmd, args, err := PrepareCommands(cmds, true, extraArgs) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, true, extraArgs, env) if err != nil { t.Fatal(err) } @@ -160,7 +165,8 @@ func TestPrepareCommandsNoArch(t *testing.T) { {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, } - cmd, args, err := PrepareCommands(cmds, true, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, true, []string{}, env) if err != nil { t.Fatal(err) } @@ -182,7 +188,8 @@ func TestPrepareCommandsNoOsNoArch(t *testing.T) { {OperatingSystem: runtime.GOOS, Architecture: "no-arch", Command: "pwsh", Args: []string{"-c", "echo \"error\""}}, } - cmd, args, err := PrepareCommands(cmds, true, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, true, []string{}, env) if err != nil { t.Fatal(err) } @@ -201,7 +208,8 @@ func TestPrepareCommandsNoMatch(t *testing.T) { {OperatingSystem: "no-os", Architecture: runtime.GOARCH, Command: "sh", Args: []string{"-c", "echo \"test\""}}, } - if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil { + env := map[string]string{} + if _, _, err := PrepareCommands(cmds, true, []string{}, env); err == nil { t.Fatalf("Expected error to be returned") } } @@ -209,7 +217,8 @@ func TestPrepareCommandsNoMatch(t *testing.T) { func TestPrepareCommandsNoCommands(t *testing.T) { cmds := []PlatformCommand{} - if _, _, err := PrepareCommands(cmds, true, []string{}); err == nil { + env := map[string]string{} + if _, _, err := PrepareCommands(cmds, true, []string{}, env); err == nil { t.Fatalf("Expected error to be returned") } } @@ -224,7 +233,8 @@ func TestPrepareCommandsExpand(t *testing.T) { expectedArgs := []string{"-c", "echo \"test\""} - cmd, args, err := PrepareCommands(cmds, true, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, true, []string{}, env) if err != nil { t.Fatal(err) } @@ -244,7 +254,8 @@ func TestPrepareCommandsNoExpand(t *testing.T) { {OperatingSystem: "", Architecture: "", Command: cmdMain, Args: cmdArgs}, } - cmd, args, err := PrepareCommands(cmds, false, []string{}) + env := map[string]string{} + cmd, args, err := PrepareCommands(cmds, false, []string{}, env) if err != nil { t.Fatal(err) } diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index 5057c1033..75cfdc3cf 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -46,11 +46,6 @@ const ( pluginDynamicCompletionExecutable = "plugin.complete" ) -type PluginError struct { - error - Code int -} - // loadCLIPlugins loads CLI plugins into the command list. // // This follows a different pattern than the other commands because it has @@ -101,8 +96,6 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { 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{} @@ -128,12 +121,10 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { 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 CommandError{ + error: execErr.Err, + ExitCode: execErr.ExitCode, } } return err @@ -369,7 +360,6 @@ func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, to argv = append(argv, u...) argv = append(argv, toComplete) } - 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) diff --git a/pkg/cmd/plugin.go b/pkg/cmd/plugin.go index 393e9672c..ba904ef5f 100644 --- a/pkg/cmd/plugin.go +++ b/pkg/cmd/plugin.go @@ -48,7 +48,6 @@ func newPluginCmd(out io.Writer) *cobra.Command { 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) } diff --git a/pkg/cmd/plugin_test.go b/pkg/cmd/plugin_test.go index 738a64740..f7a418569 100644 --- a/pkg/cmd/plugin_test.go +++ b/pkg/cmd/plugin_test.go @@ -17,6 +17,7 @@ package cmd import ( "bytes" + "fmt" "os" "runtime" "strings" @@ -93,14 +94,14 @@ func TestLoadCLIPlugins(t *testing.T) { ) loadCLIPlugins(&cmd, &out) - envs := strings.Join([]string{ - "fullenv", - "testdata/helmhome/helm/plugins/fullenv", - "testdata/helmhome/helm/plugins", - "testdata/helmhome/helm/repositories.yaml", - "testdata/helmhome/helm/repository", - os.Args[0], - }, "\n") + fullEnvOutput := strings.Join([]string{ + "HELM_PLUGIN_NAME=fullenv", + "HELM_PLUGIN_DIR=testdata/helmhome/helm/plugins/fullenv", + "HELM_PLUGINS=testdata/helmhome/helm/plugins", + "HELM_REPOSITORY_CONFIG=testdata/helmhome/helm/repositories.yaml", + "HELM_REPOSITORY_CACHE=testdata/helmhome/helm/repository", + fmt.Sprintf("HELM_BIN=%s", os.Args[0]), + }, "\n") + "\n" // Test that the YAML file was correctly converted to a command. tests := []struct { @@ -113,47 +114,50 @@ func TestLoadCLIPlugins(t *testing.T) { }{ {"args", "echo args", "This echos args", "-a -b -c\n", []string{"-a", "-b", "-c"}, 0}, {"echo", "echo stuff", "This echos stuff", "hello\n", []string{}, 0}, - {"env", "env stuff", "show the env", "env\n", []string{}, 0}, + {"env", "env stuff", "show the env", "HELM_PLUGIN_NAME=env\n", []string{}, 0}, {"exitwith", "exitwith code", "This exits with the specified exit code", "", []string{"2"}, 2}, - {"fullenv", "show env vars", "show all env vars", envs + "\n", []string{}, 0}, + {"fullenv", "show env vars", "show all env vars", fullEnvOutput, []string{}, 0}, } - plugins := cmd.Commands() + pluginCmds := cmd.Commands() - require.Len(t, plugins, len(tests), "Expected %d plugins, got %d", len(tests), len(plugins)) + require.Len(t, pluginCmds, len(tests), "Expected %d plugins, got %d", len(tests), len(pluginCmds)) - for i := range plugins { + for i := range pluginCmds { out.Reset() tt := tests[i] - pp := plugins[i] - if pp.Use != tt.use { - t.Errorf("%d: Expected Use=%q, got %q", i, tt.use, pp.Use) - } - if pp.Short != tt.short { - t.Errorf("%d: Expected Use=%q, got %q", i, tt.short, pp.Short) - } - if pp.Long != tt.long { - t.Errorf("%d: Expected Use=%q, got %q", i, tt.long, pp.Long) - } + pluginCmd := pluginCmds[i] + t.Run(fmt.Sprintf("%s-%d", pluginCmd.Name(), i), func(t *testing.T) { + out.Reset() + if pluginCmd.Use != tt.use { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.use, pluginCmd.Use) + } + if pluginCmd.Short != tt.short { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.short, pluginCmd.Short) + } + if pluginCmd.Long != tt.long { + t.Errorf("%d: Expected Use=%q, got %q", i, tt.long, pluginCmd.Long) + } - // Currently, plugins assume a Linux subsystem. Skip the execution - // tests until this is fixed - if runtime.GOOS != "windows" { - if err := pp.RunE(pp, tt.args); err != nil { - if tt.code > 0 { - perr, ok := err.(PluginError) - if !ok { - t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err) + // Currently, plugins assume a Linux subsystem. Skip the execution + // tests until this is fixed + if runtime.GOOS != "windows" { + if err := pluginCmd.RunE(pluginCmd, tt.args); err != nil { + if tt.code > 0 { + cerr, ok := err.(CommandError) + if !ok { + t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err) + } + if cerr.ExitCode != tt.code { + t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, cerr.ExitCode) + } + } else { + t.Errorf("Error running %s: %+v", tt.use, err) } - if perr.Code != tt.code { - t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.Code) - } - } else { - t.Errorf("Error running %s: %+v", tt.use, err) } + assert.Equal(t, tt.expect, out.String(), "expected output for %q", tt.use) } - assert.Equal(t, tt.expect, out.String(), "expected output for %s", tt.use) - } + }) } } @@ -214,12 +218,12 @@ func TestLoadPluginsWithSpace(t *testing.T) { if runtime.GOOS != "windows" { if err := pp.RunE(pp, tt.args); err != nil { if tt.code > 0 { - perr, ok := err.(PluginError) + cerr, ok := err.(CommandError) if !ok { t.Errorf("Expected %s to return pluginError: got %v(%T)", tt.use, err, err) } - if perr.Code != tt.code { - t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, perr.Code) + if cerr.ExitCode != tt.code { + t.Errorf("Expected %s to return %d: got %d", tt.use, tt.code, cerr.ExitCode) } } else { t.Errorf("Error running %s: %+v", tt.use, err) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 836df834d..2b2f7b750 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -460,3 +460,8 @@ func newRegistryClientWithTLS( } return registryClient, nil } + +type CommandError struct { + error + ExitCode int +} diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin-name.sh b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin-name.sh new file mode 100755 index 000000000..9e823ac13 --- /dev/null +++ b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin-name.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +echo HELM_PLUGIN_NAME=${HELM_PLUGIN_NAME} diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml index fa933af93..78a0a23fb 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/env/plugin.yaml @@ -9,4 +9,4 @@ config: ignoreFlags: false runtimeConfig: platformCommand: - - command: "echo $HELM_PLUGIN_NAME" + - command: ${HELM_PLUGIN_DIR}/plugin-name.sh diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh index 2efad9b3c..cc0c64a6a 100755 --- a/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh +++ b/pkg/cmd/testdata/helmhome/helm/plugins/fullenv/fullenv.sh @@ -1,7 +1,7 @@ #!/bin/sh -echo $HELM_PLUGIN_NAME -echo $HELM_PLUGIN_DIR -echo $HELM_PLUGINS -echo $HELM_REPOSITORY_CONFIG -echo $HELM_REPOSITORY_CACHE -echo $HELM_BIN +echo HELM_PLUGIN_NAME=${HELM_PLUGIN_NAME} +echo HELM_PLUGIN_DIR=${HELM_PLUGIN_DIR} +echo HELM_PLUGINS=${HELM_PLUGINS} +echo HELM_REPOSITORY_CONFIG=${HELM_REPOSITORY_CONFIG} +echo HELM_REPOSITORY_CACHE=${HELM_REPOSITORY_CACHE} +echo HELM_BIN=${HELM_BIN} diff --git a/pkg/postrenderer/postrenderer.go b/pkg/postrenderer/postrenderer.go index ed6699c32..55e6d3adf 100644 --- a/pkg/postrenderer/postrenderer.go +++ b/pkg/postrenderer/postrenderer.go @@ -65,7 +65,6 @@ func (r *postRendererPlugin) Run(renderedManifests *bytes.Buffer) (*bytes.Buffer Message: schema.InputMessagePostRendererV1{ ExtraArgs: r.args, Manifests: renderedManifests, - Settings: r.settings, }, } output, err := r.plugin.Invoke(context.Background(), input) diff --git a/pkg/postrenderer/postrenderer_test.go b/pkg/postrenderer/postrenderer_test.go index 9addd481d..824a1d179 100644 --- a/pkg/postrenderer/postrenderer_test.go +++ b/pkg/postrenderer/postrenderer_test.go @@ -18,14 +18,12 @@ 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" ) @@ -38,8 +36,6 @@ func TestNewPostRenderPluginRunWithNoOutput(t *testing.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) @@ -57,8 +53,6 @@ func TestNewPostRenderPluginWithOneArgsRun(t *testing.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) @@ -77,8 +71,6 @@ func TestNewPostRenderPluginWithTwoArgsRun(t *testing.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) From 6f957f4922ea065138f76b990ba5cc95bbcd774b Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Sun, 31 Aug 2025 08:48:15 -0400 Subject: [PATCH 27/55] Move the release util to the versioned directory The release util package is directly related to the v1 of releases and uses the v1 of releases. Signed-off-by: Matt Farina --- pkg/action/action.go | 2 +- pkg/action/install.go | 2 +- pkg/action/list.go | 2 +- pkg/action/resource_policy.go | 2 +- pkg/action/uninstall.go | 2 +- pkg/action/upgrade.go | 2 +- pkg/cmd/history.go | 2 +- pkg/cmd/template.go | 2 +- pkg/release/{ => v1}/util/filter.go | 2 +- pkg/release/{ => v1}/util/filter_test.go | 2 +- pkg/release/{ => v1}/util/kind_sorter.go | 0 pkg/release/{ => v1}/util/kind_sorter_test.go | 0 pkg/release/{ => v1}/util/manifest.go | 0 pkg/release/{ => v1}/util/manifest_sorter.go | 0 pkg/release/{ => v1}/util/manifest_sorter_test.go | 0 pkg/release/{ => v1}/util/manifest_test.go | 2 +- pkg/release/{ => v1}/util/sorter.go | 2 +- pkg/release/{ => v1}/util/sorter_test.go | 2 +- pkg/storage/storage.go | 2 +- 19 files changed, 14 insertions(+), 14 deletions(-) rename pkg/release/{ => v1}/util/filter.go (97%) rename pkg/release/{ => v1}/util/filter_test.go (96%) rename pkg/release/{ => v1}/util/kind_sorter.go (100%) rename pkg/release/{ => v1}/util/kind_sorter_test.go (100%) rename pkg/release/{ => v1}/util/manifest.go (100%) rename pkg/release/{ => v1}/util/manifest_sorter.go (100%) rename pkg/release/{ => v1}/util/manifest_sorter_test.go (100%) rename pkg/release/{ => v1}/util/manifest_test.go (95%) rename pkg/release/{ => v1}/util/sorter.go (96%) rename pkg/release/{ => v1}/util/sorter_test.go (97%) diff --git a/pkg/action/action.go b/pkg/action/action.go index 7b8fa3c34..522226a1a 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -45,8 +45,8 @@ import ( "helm.sh/helm/v4/pkg/kube" "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" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" "helm.sh/helm/v4/pkg/time" diff --git a/pkg/action/install.go b/pkg/action/install.go index 5ca499d64..484cdbf8c 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -50,8 +50,8 @@ import ( kubefake "helm.sh/helm/v4/pkg/kube/fake" "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" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/repo" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" diff --git a/pkg/action/list.go b/pkg/action/list.go index 82500582f..c6d6f2037 100644 --- a/pkg/action/list.go +++ b/pkg/action/list.go @@ -22,8 +22,8 @@ import ( "k8s.io/apimachinery/pkg/labels" - releaseutil "helm.sh/helm/v4/pkg/release/util" release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) // ListStates represents zero or more status codes that a list item may have set diff --git a/pkg/action/resource_policy.go b/pkg/action/resource_policy.go index b72e94124..fcea98ad6 100644 --- a/pkg/action/resource_policy.go +++ b/pkg/action/resource_policy.go @@ -20,7 +20,7 @@ import ( "strings" "helm.sh/helm/v4/pkg/kube" - releaseutil "helm.sh/helm/v4/pkg/release/util" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) func filterManifestsToKeep(manifests []releaseutil.Manifest) (keep, remaining []releaseutil.Manifest) { diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 6aa87d331..866be5d54 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -27,8 +27,8 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" - releaseutil "helm.sh/helm/v4/pkg/release/util" release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" helmtime "helm.sh/helm/v4/pkg/time" ) diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index f7fbd490f..c00a59079 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -33,8 +33,8 @@ import ( "helm.sh/helm/v4/pkg/kube" "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" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" ) diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index ec2a1bc12..9f029268c 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -29,8 +29,8 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" - releaseutil "helm.sh/helm/v4/pkg/release/util" release "helm.sh/helm/v4/pkg/release/v1" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" helmtime "helm.sh/helm/v4/pkg/time" ) diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index c93b5395b..aaf848c9e 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -38,7 +38,7 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" - releaseutil "helm.sh/helm/v4/pkg/release/util" + releaseutil "helm.sh/helm/v4/pkg/release/v1/util" ) const templateDesc = ` diff --git a/pkg/release/util/filter.go b/pkg/release/v1/util/filter.go similarity index 97% rename from pkg/release/util/filter.go rename to pkg/release/v1/util/filter.go index f0a082cfd..f818a6196 100644 --- a/pkg/release/util/filter.go +++ b/pkg/release/v1/util/filter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util // import "helm.sh/helm/v4/pkg/release/util" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import rspb "helm.sh/helm/v4/pkg/release/v1" diff --git a/pkg/release/util/filter_test.go b/pkg/release/v1/util/filter_test.go similarity index 96% rename from pkg/release/util/filter_test.go rename to pkg/release/v1/util/filter_test.go index 5d2564619..c8b23d526 100644 --- a/pkg/release/util/filter_test.go +++ b/pkg/release/v1/util/filter_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util // import "helm.sh/helm/v4/pkg/release/util" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "testing" diff --git a/pkg/release/util/kind_sorter.go b/pkg/release/v1/util/kind_sorter.go similarity index 100% rename from pkg/release/util/kind_sorter.go rename to pkg/release/v1/util/kind_sorter.go diff --git a/pkg/release/util/kind_sorter_test.go b/pkg/release/v1/util/kind_sorter_test.go similarity index 100% rename from pkg/release/util/kind_sorter_test.go rename to pkg/release/v1/util/kind_sorter_test.go diff --git a/pkg/release/util/manifest.go b/pkg/release/v1/util/manifest.go similarity index 100% rename from pkg/release/util/manifest.go rename to pkg/release/v1/util/manifest.go diff --git a/pkg/release/util/manifest_sorter.go b/pkg/release/v1/util/manifest_sorter.go similarity index 100% rename from pkg/release/util/manifest_sorter.go rename to pkg/release/v1/util/manifest_sorter.go diff --git a/pkg/release/util/manifest_sorter_test.go b/pkg/release/v1/util/manifest_sorter_test.go similarity index 100% rename from pkg/release/util/manifest_sorter_test.go rename to pkg/release/v1/util/manifest_sorter_test.go diff --git a/pkg/release/util/manifest_test.go b/pkg/release/v1/util/manifest_test.go similarity index 95% rename from pkg/release/util/manifest_test.go rename to pkg/release/v1/util/manifest_test.go index cfc19563d..754ac1367 100644 --- a/pkg/release/util/manifest_test.go +++ b/pkg/release/v1/util/manifest_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util // import "helm.sh/helm/v4/pkg/release/util" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "reflect" diff --git a/pkg/release/util/sorter.go b/pkg/release/v1/util/sorter.go similarity index 96% rename from pkg/release/util/sorter.go rename to pkg/release/v1/util/sorter.go index 1b09d0f3b..3712a58ef 100644 --- a/pkg/release/util/sorter.go +++ b/pkg/release/v1/util/sorter.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util // import "helm.sh/helm/v4/pkg/release/util" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "sort" diff --git a/pkg/release/util/sorter_test.go b/pkg/release/v1/util/sorter_test.go similarity index 97% rename from pkg/release/util/sorter_test.go rename to pkg/release/v1/util/sorter_test.go index 7ca540441..4628a5192 100644 --- a/pkg/release/util/sorter_test.go +++ b/pkg/release/v1/util/sorter_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util // import "helm.sh/helm/v4/pkg/release/util" +package util // import "helm.sh/helm/v4/pkg/release/v1/util" import ( "testing" diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index b43f7c0f2..f086309bb 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -22,8 +22,8 @@ import ( "log/slog" "strings" - relutil "helm.sh/helm/v4/pkg/release/util" rspb "helm.sh/helm/v4/pkg/release/v1" + relutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" ) From 52267ee74bf642ac3ea84f40ae6796ef9b391aaf Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Sun, 31 Aug 2025 09:04:48 -0400 Subject: [PATCH 28/55] Move repo package to versioned directory The repo package is internally versioned at v1. Repos were designed to be versioned. This change moves it to a versioned directory the same way other packages are now being handled. Signed-off-by: Matt Farina --- internal/resolver/resolver.go | 2 +- pkg/action/install.go | 2 +- pkg/action/pull.go | 2 +- pkg/cmd/dependency_build_test.go | 4 ++-- pkg/cmd/dependency_update_test.go | 4 ++-- pkg/cmd/flags.go | 2 +- pkg/cmd/install_test.go | 2 +- pkg/cmd/pull_test.go | 2 +- pkg/cmd/repo_add.go | 2 +- pkg/cmd/repo_add_test.go | 4 ++-- pkg/cmd/repo_index.go | 2 +- pkg/cmd/repo_index_test.go | 2 +- pkg/cmd/repo_list.go | 2 +- pkg/cmd/repo_remove.go | 2 +- pkg/cmd/repo_remove_test.go | 4 ++-- pkg/cmd/repo_update.go | 2 +- pkg/cmd/repo_update_test.go | 4 ++-- pkg/cmd/root.go | 2 +- pkg/cmd/search/search.go | 2 +- pkg/cmd/search/search_test.go | 2 +- pkg/cmd/search_repo.go | 2 +- pkg/cmd/show_test.go | 2 +- pkg/downloader/chart_downloader.go | 2 +- pkg/downloader/chart_downloader_test.go | 4 ++-- pkg/downloader/manager.go | 2 +- pkg/downloader/manager_test.go | 4 ++-- pkg/registry/utils_test.go | 2 +- pkg/repo/{ => v1}/chartrepo.go | 2 +- pkg/repo/{ => v1}/chartrepo_test.go | 0 pkg/repo/{ => v1}/doc.go | 0 pkg/repo/{ => v1}/error.go | 0 pkg/repo/{ => v1}/index.go | 0 pkg/repo/{ => v1}/index_test.go | 0 pkg/repo/{ => v1}/repo.go | 2 +- pkg/repo/{ => v1}/repo_test.go | 0 pkg/repo/{ => v1}/repotest/doc.go | 0 pkg/repo/{ => v1}/repotest/server.go | 2 +- pkg/repo/{ => v1}/repotest/server_test.go | 6 +++--- .../repotest/testdata/examplechart-0.1.0.tgz | Bin .../repotest/testdata/examplechart/.helmignore | 0 .../repotest/testdata/examplechart/Chart.yaml | 0 .../repotest/testdata/examplechart/values.yaml | 0 pkg/repo/{ => v1}/repotest/tlsconfig.go | 0 pkg/repo/{ => v1}/testdata/chartmuseum-index.yaml | 0 .../{ => v1}/testdata/local-index-annotations.yaml | 0 .../{ => v1}/testdata/local-index-unordered.yaml | 0 pkg/repo/{ => v1}/testdata/local-index.json | 0 pkg/repo/{ => v1}/testdata/local-index.yaml | 0 pkg/repo/{ => v1}/testdata/old-repositories.yaml | 0 pkg/repo/{ => v1}/testdata/repositories.yaml | 0 .../{ => v1}/testdata/repository/frobnitz-1.2.3.tgz | Bin .../{ => v1}/testdata/repository/sprocket-1.1.0.tgz | Bin .../{ => v1}/testdata/repository/sprocket-1.2.0.tgz | Bin .../testdata/repository/universe/zarthal-1.0.0.tgz | Bin pkg/repo/{ => v1}/testdata/server/index.yaml | 0 pkg/repo/{ => v1}/testdata/server/test.txt | 0 56 files changed, 40 insertions(+), 40 deletions(-) rename pkg/repo/{ => v1}/chartrepo.go (99%) rename pkg/repo/{ => v1}/chartrepo_test.go (100%) rename pkg/repo/{ => v1}/doc.go (100%) rename pkg/repo/{ => v1}/error.go (100%) rename pkg/repo/{ => v1}/index.go (100%) rename pkg/repo/{ => v1}/index_test.go (100%) rename pkg/repo/{ => v1}/repo.go (98%) rename pkg/repo/{ => v1}/repo_test.go (100%) rename pkg/repo/{ => v1}/repotest/doc.go (100%) rename pkg/repo/{ => v1}/repotest/server.go (99%) rename pkg/repo/{ => v1}/repotest/server_test.go (96%) rename pkg/repo/{ => v1}/repotest/testdata/examplechart-0.1.0.tgz (100%) rename pkg/repo/{ => v1}/repotest/testdata/examplechart/.helmignore (100%) rename pkg/repo/{ => v1}/repotest/testdata/examplechart/Chart.yaml (100%) rename pkg/repo/{ => v1}/repotest/testdata/examplechart/values.yaml (100%) rename pkg/repo/{ => v1}/repotest/tlsconfig.go (100%) rename pkg/repo/{ => v1}/testdata/chartmuseum-index.yaml (100%) rename pkg/repo/{ => v1}/testdata/local-index-annotations.yaml (100%) rename pkg/repo/{ => v1}/testdata/local-index-unordered.yaml (100%) rename pkg/repo/{ => v1}/testdata/local-index.json (100%) rename pkg/repo/{ => v1}/testdata/local-index.yaml (100%) rename pkg/repo/{ => v1}/testdata/old-repositories.yaml (100%) rename pkg/repo/{ => v1}/testdata/repositories.yaml (100%) rename pkg/repo/{ => v1}/testdata/repository/frobnitz-1.2.3.tgz (100%) rename pkg/repo/{ => v1}/testdata/repository/sprocket-1.1.0.tgz (100%) rename pkg/repo/{ => v1}/testdata/repository/sprocket-1.2.0.tgz (100%) rename pkg/repo/{ => v1}/testdata/repository/universe/zarthal-1.0.0.tgz (100%) rename pkg/repo/{ => v1}/testdata/server/index.yaml (100%) rename pkg/repo/{ => v1}/testdata/server/test.txt (100%) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 13dcd2ce9..3efe94f10 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -33,7 +33,7 @@ import ( "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/provenance" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // Resolver resolves dependencies from semantic version ranges to a particular version. diff --git a/pkg/action/install.go b/pkg/action/install.go index 484cdbf8c..b2330d551 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -52,7 +52,7 @@ import ( "helm.sh/helm/v4/pkg/registry" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" ) diff --git a/pkg/action/pull.go b/pkg/action/pull.go index c1f77e44c..be71d0ed0 100644 --- a/pkg/action/pull.go +++ b/pkg/action/pull.go @@ -27,7 +27,7 @@ import ( "helm.sh/helm/v4/pkg/downloader" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // Pull is the action for checking a given release's information. diff --git a/pkg/cmd/dependency_build_test.go b/pkg/cmd/dependency_build_test.go index a4a89b7a9..a3473301d 100644 --- a/pkg/cmd/dependency_build_test.go +++ b/pkg/cmd/dependency_build_test.go @@ -24,8 +24,8 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/provenance" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestDependencyBuildCmd(t *testing.T) { diff --git a/pkg/cmd/dependency_update_test.go b/pkg/cmd/dependency_update_test.go index f1b39c4b7..3eaa51df1 100644 --- a/pkg/cmd/dependency_update_test.go +++ b/pkg/cmd/dependency_update_test.go @@ -29,8 +29,8 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/provenance" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestDependencyUpdateCmd(t *testing.T) { diff --git a/pkg/cmd/flags.go b/pkg/cmd/flags.go index 98881c795..b20772ef9 100644 --- a/pkg/cmd/flags.go +++ b/pkg/cmd/flags.go @@ -37,7 +37,7 @@ import ( "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/kube" "helm.sh/helm/v4/pkg/postrenderer" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) const ( diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index 9cd244e84..f0f12e4f7 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -23,7 +23,7 @@ import ( "path/filepath" "testing" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestInstall(t *testing.T) { diff --git a/pkg/cmd/pull_test.go b/pkg/cmd/pull_test.go index c3156c394..c24bf33b7 100644 --- a/pkg/cmd/pull_test.go +++ b/pkg/cmd/pull_test.go @@ -24,7 +24,7 @@ import ( "path/filepath" "testing" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestPullCmd(t *testing.T) { diff --git a/pkg/cmd/repo_add.go b/pkg/cmd/repo_add.go index 187234486..00e698daf 100644 --- a/pkg/cmd/repo_add.go +++ b/pkg/cmd/repo_add.go @@ -34,7 +34,7 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/getter" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // Repositories that have been permanently deleted and no longer work diff --git a/pkg/cmd/repo_add_test.go b/pkg/cmd/repo_add_test.go index aa6c4eaad..6d3696f52 100644 --- a/pkg/cmd/repo_add_test.go +++ b/pkg/cmd/repo_add_test.go @@ -31,8 +31,8 @@ import ( "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/helmpath/xdg" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestRepoAddCmd(t *testing.T) { diff --git a/pkg/cmd/repo_index.go b/pkg/cmd/repo_index.go index c17fd9391..ece0ce811 100644 --- a/pkg/cmd/repo_index.go +++ b/pkg/cmd/repo_index.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v4/pkg/cmd/require" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) const repoIndexDesc = ` diff --git a/pkg/cmd/repo_index_test.go b/pkg/cmd/repo_index_test.go index c865c8a5d..c8959f21e 100644 --- a/pkg/cmd/repo_index_test.go +++ b/pkg/cmd/repo_index_test.go @@ -24,7 +24,7 @@ import ( "path/filepath" "testing" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) func TestRepoIndexCmd(t *testing.T) { diff --git a/pkg/cmd/repo_list.go b/pkg/cmd/repo_list.go index 70f57992e..10b4442a0 100644 --- a/pkg/cmd/repo_list.go +++ b/pkg/cmd/repo_list.go @@ -25,7 +25,7 @@ import ( "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) func newRepoListCmd(out io.Writer) *cobra.Command { diff --git a/pkg/cmd/repo_remove.go b/pkg/cmd/repo_remove.go index d0a3aa205..330e69d3a 100644 --- a/pkg/cmd/repo_remove.go +++ b/pkg/cmd/repo_remove.go @@ -28,7 +28,7 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/helmpath" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) type repoRemoveOptions struct { diff --git a/pkg/cmd/repo_remove_test.go b/pkg/cmd/repo_remove_test.go index bd8757812..fce15bb73 100644 --- a/pkg/cmd/repo_remove_test.go +++ b/pkg/cmd/repo_remove_test.go @@ -25,8 +25,8 @@ import ( "testing" "helm.sh/helm/v4/pkg/helmpath" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestRepoRemove(t *testing.T) { diff --git a/pkg/cmd/repo_update.go b/pkg/cmd/repo_update.go index 54318bf29..f2e7c0e0f 100644 --- a/pkg/cmd/repo_update.go +++ b/pkg/cmd/repo_update.go @@ -28,7 +28,7 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" "helm.sh/helm/v4/pkg/getter" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) const updateDesc = ` diff --git a/pkg/cmd/repo_update_test.go b/pkg/cmd/repo_update_test.go index b0deff1ae..7aa4d414f 100644 --- a/pkg/cmd/repo_update_test.go +++ b/pkg/cmd/repo_update_test.go @@ -26,8 +26,8 @@ import ( "helm.sh/helm/v4/internal/test/ensure" "helm.sh/helm/v4/pkg/getter" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestUpdateCmd(t *testing.T) { diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 836df834d..2c3a2f944 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -40,7 +40,7 @@ import ( kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/registry" release "helm.sh/helm/v4/pkg/release/v1" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" "helm.sh/helm/v4/pkg/storage/driver" ) diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go index f9e229154..1c7bb1d06 100644 --- a/pkg/cmd/search/search.go +++ b/pkg/cmd/search/search.go @@ -31,7 +31,7 @@ import ( "github.com/Masterminds/semver/v3" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // Result is a search result. diff --git a/pkg/cmd/search/search_test.go b/pkg/cmd/search/search_test.go index 7a4ba786b..a24eb1f64 100644 --- a/pkg/cmd/search/search_test.go +++ b/pkg/cmd/search/search_test.go @@ -21,7 +21,7 @@ import ( "testing" chart "helm.sh/helm/v4/pkg/chart/v2" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) func TestSortScore(t *testing.T) { diff --git a/pkg/cmd/search_repo.go b/pkg/cmd/search_repo.go index dffa0d1c4..35608e22e 100644 --- a/pkg/cmd/search_repo.go +++ b/pkg/cmd/search_repo.go @@ -34,7 +34,7 @@ import ( "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/search" "helm.sh/helm/v4/pkg/helmpath" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) const searchRepoDesc = ` diff --git a/pkg/cmd/show_test.go b/pkg/cmd/show_test.go index 5ccb4bcad..ff3671dbc 100644 --- a/pkg/cmd/show_test.go +++ b/pkg/cmd/show_test.go @@ -22,7 +22,7 @@ import ( "strings" "testing" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestShowPreReleaseChart(t *testing.T) { diff --git a/pkg/downloader/chart_downloader.go b/pkg/downloader/chart_downloader.go index a24cad3fd..00c8c56e8 100644 --- a/pkg/downloader/chart_downloader.go +++ b/pkg/downloader/chart_downloader.go @@ -36,7 +36,7 @@ import ( "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/provenance" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // VerificationStrategy describes a strategy for determining whether to verify a chart. diff --git a/pkg/downloader/chart_downloader_test.go b/pkg/downloader/chart_downloader_test.go index 649448fef..4349ecef9 100644 --- a/pkg/downloader/chart_downloader_test.go +++ b/pkg/downloader/chart_downloader_test.go @@ -28,8 +28,8 @@ import ( "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) const ( diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 8b77a77c0..d41b8fdb4 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -42,7 +42,7 @@ import ( "helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/helmpath" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // ErrRepoNotFound indicates that chart repositories can't be found in local repo cache. diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go index b7121a4ce..9e27f183f 100644 --- a/pkg/downloader/manager_test.go +++ b/pkg/downloader/manager_test.go @@ -32,8 +32,8 @@ import ( "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/getter" - "helm.sh/helm/v4/pkg/repo" - "helm.sh/helm/v4/pkg/repo/repotest" + "helm.sh/helm/v4/pkg/repo/v1" + "helm.sh/helm/v4/pkg/repo/v1/repotest" ) func TestVersionEquals(t *testing.T) { diff --git a/pkg/registry/utils_test.go b/pkg/registry/utils_test.go index b46317fc6..de2f9024f 100644 --- a/pkg/registry/utils_test.go +++ b/pkg/registry/utils_test.go @@ -231,7 +231,7 @@ func testPush(suite *TestSuite) { suite.NotNil(err, "error pushing non-chart bytes") // Load a test chart - chartData, err := os.ReadFile("../repo/repotest/testdata/examplechart-0.1.0.tgz") + chartData, err := os.ReadFile("../repo/v1/repotest/testdata/examplechart-0.1.0.tgz") suite.Nil(err, "no error loading test chart") meta, err := extractChartMeta(chartData) suite.Nil(err, "no error extracting chart meta") diff --git a/pkg/repo/chartrepo.go b/pkg/repo/v1/chartrepo.go similarity index 99% rename from pkg/repo/chartrepo.go rename to pkg/repo/v1/chartrepo.go index c54197d60..95c04ccef 100644 --- a/pkg/repo/chartrepo.go +++ b/pkg/repo/v1/chartrepo.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repo // import "helm.sh/helm/v4/pkg/repo" +package repo // import "helm.sh/helm/v4/pkg/repo/v1" import ( "crypto/rand" diff --git a/pkg/repo/chartrepo_test.go b/pkg/repo/v1/chartrepo_test.go similarity index 100% rename from pkg/repo/chartrepo_test.go rename to pkg/repo/v1/chartrepo_test.go diff --git a/pkg/repo/doc.go b/pkg/repo/v1/doc.go similarity index 100% rename from pkg/repo/doc.go rename to pkg/repo/v1/doc.go diff --git a/pkg/repo/error.go b/pkg/repo/v1/error.go similarity index 100% rename from pkg/repo/error.go rename to pkg/repo/v1/error.go diff --git a/pkg/repo/index.go b/pkg/repo/v1/index.go similarity index 100% rename from pkg/repo/index.go rename to pkg/repo/v1/index.go diff --git a/pkg/repo/index_test.go b/pkg/repo/v1/index_test.go similarity index 100% rename from pkg/repo/index_test.go rename to pkg/repo/v1/index_test.go diff --git a/pkg/repo/repo.go b/pkg/repo/v1/repo.go similarity index 98% rename from pkg/repo/repo.go rename to pkg/repo/v1/repo.go index 48c0e0193..38d2b0ca1 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/v1/repo.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package repo // import "helm.sh/helm/v4/pkg/repo" +package repo // import "helm.sh/helm/v4/pkg/repo/v1" import ( "fmt" diff --git a/pkg/repo/repo_test.go b/pkg/repo/v1/repo_test.go similarity index 100% rename from pkg/repo/repo_test.go rename to pkg/repo/v1/repo_test.go diff --git a/pkg/repo/repotest/doc.go b/pkg/repo/v1/repotest/doc.go similarity index 100% rename from pkg/repo/repotest/doc.go rename to pkg/repo/v1/repotest/doc.go diff --git a/pkg/repo/repotest/server.go b/pkg/repo/v1/repotest/server.go similarity index 99% rename from pkg/repo/repotest/server.go rename to pkg/repo/v1/repotest/server.go index 8f9f82281..12b96de5a 100644 --- a/pkg/repo/repotest/server.go +++ b/pkg/repo/v1/repotest/server.go @@ -37,7 +37,7 @@ import ( "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ociRegistry "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) func BasicAuthMiddleware(t *testing.T) http.HandlerFunc { diff --git a/pkg/repo/repotest/server_test.go b/pkg/repo/v1/repotest/server_test.go similarity index 96% rename from pkg/repo/repotest/server_test.go rename to pkg/repo/v1/repotest/server_test.go index 4d62ef8ed..f0e374fc0 100644 --- a/pkg/repo/repotest/server_test.go +++ b/pkg/repo/v1/repotest/server_test.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/yaml" "helm.sh/helm/v4/internal/test/ensure" - "helm.sh/helm/v4/pkg/repo" + "helm.sh/helm/v4/pkg/repo/v1" ) // Young'n, in these here parts, we test our tests. @@ -113,7 +113,7 @@ func TestNewTempServer(t *testing.T) { "tls": { options: []ServerOption{ WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"), - WithTLSConfig(MakeTestTLSConfig(t, "../../../testdata")), + WithTLSConfig(MakeTestTLSConfig(t, "../../../../testdata")), }, }, } @@ -212,7 +212,7 @@ func TestNewTempServer_TLS(t *testing.T) { srv := NewTempServer( t, WithChartSourceGlob("testdata/examplechart-0.1.0.tgz"), - WithTLSConfig(MakeTestTLSConfig(t, "../../../testdata")), + WithTLSConfig(MakeTestTLSConfig(t, "../../../../testdata")), ) defer srv.Stop() diff --git a/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz b/pkg/repo/v1/repotest/testdata/examplechart-0.1.0.tgz similarity index 100% rename from pkg/repo/repotest/testdata/examplechart-0.1.0.tgz rename to pkg/repo/v1/repotest/testdata/examplechart-0.1.0.tgz diff --git a/pkg/repo/repotest/testdata/examplechart/.helmignore b/pkg/repo/v1/repotest/testdata/examplechart/.helmignore similarity index 100% rename from pkg/repo/repotest/testdata/examplechart/.helmignore rename to pkg/repo/v1/repotest/testdata/examplechart/.helmignore diff --git a/pkg/repo/repotest/testdata/examplechart/Chart.yaml b/pkg/repo/v1/repotest/testdata/examplechart/Chart.yaml similarity index 100% rename from pkg/repo/repotest/testdata/examplechart/Chart.yaml rename to pkg/repo/v1/repotest/testdata/examplechart/Chart.yaml diff --git a/pkg/repo/repotest/testdata/examplechart/values.yaml b/pkg/repo/v1/repotest/testdata/examplechart/values.yaml similarity index 100% rename from pkg/repo/repotest/testdata/examplechart/values.yaml rename to pkg/repo/v1/repotest/testdata/examplechart/values.yaml diff --git a/pkg/repo/repotest/tlsconfig.go b/pkg/repo/v1/repotest/tlsconfig.go similarity index 100% rename from pkg/repo/repotest/tlsconfig.go rename to pkg/repo/v1/repotest/tlsconfig.go diff --git a/pkg/repo/testdata/chartmuseum-index.yaml b/pkg/repo/v1/testdata/chartmuseum-index.yaml similarity index 100% rename from pkg/repo/testdata/chartmuseum-index.yaml rename to pkg/repo/v1/testdata/chartmuseum-index.yaml diff --git a/pkg/repo/testdata/local-index-annotations.yaml b/pkg/repo/v1/testdata/local-index-annotations.yaml similarity index 100% rename from pkg/repo/testdata/local-index-annotations.yaml rename to pkg/repo/v1/testdata/local-index-annotations.yaml diff --git a/pkg/repo/testdata/local-index-unordered.yaml b/pkg/repo/v1/testdata/local-index-unordered.yaml similarity index 100% rename from pkg/repo/testdata/local-index-unordered.yaml rename to pkg/repo/v1/testdata/local-index-unordered.yaml diff --git a/pkg/repo/testdata/local-index.json b/pkg/repo/v1/testdata/local-index.json similarity index 100% rename from pkg/repo/testdata/local-index.json rename to pkg/repo/v1/testdata/local-index.json diff --git a/pkg/repo/testdata/local-index.yaml b/pkg/repo/v1/testdata/local-index.yaml similarity index 100% rename from pkg/repo/testdata/local-index.yaml rename to pkg/repo/v1/testdata/local-index.yaml diff --git a/pkg/repo/testdata/old-repositories.yaml b/pkg/repo/v1/testdata/old-repositories.yaml similarity index 100% rename from pkg/repo/testdata/old-repositories.yaml rename to pkg/repo/v1/testdata/old-repositories.yaml diff --git a/pkg/repo/testdata/repositories.yaml b/pkg/repo/v1/testdata/repositories.yaml similarity index 100% rename from pkg/repo/testdata/repositories.yaml rename to pkg/repo/v1/testdata/repositories.yaml diff --git a/pkg/repo/testdata/repository/frobnitz-1.2.3.tgz b/pkg/repo/v1/testdata/repository/frobnitz-1.2.3.tgz similarity index 100% rename from pkg/repo/testdata/repository/frobnitz-1.2.3.tgz rename to pkg/repo/v1/testdata/repository/frobnitz-1.2.3.tgz diff --git a/pkg/repo/testdata/repository/sprocket-1.1.0.tgz b/pkg/repo/v1/testdata/repository/sprocket-1.1.0.tgz similarity index 100% rename from pkg/repo/testdata/repository/sprocket-1.1.0.tgz rename to pkg/repo/v1/testdata/repository/sprocket-1.1.0.tgz diff --git a/pkg/repo/testdata/repository/sprocket-1.2.0.tgz b/pkg/repo/v1/testdata/repository/sprocket-1.2.0.tgz similarity index 100% rename from pkg/repo/testdata/repository/sprocket-1.2.0.tgz rename to pkg/repo/v1/testdata/repository/sprocket-1.2.0.tgz diff --git a/pkg/repo/testdata/repository/universe/zarthal-1.0.0.tgz b/pkg/repo/v1/testdata/repository/universe/zarthal-1.0.0.tgz similarity index 100% rename from pkg/repo/testdata/repository/universe/zarthal-1.0.0.tgz rename to pkg/repo/v1/testdata/repository/universe/zarthal-1.0.0.tgz diff --git a/pkg/repo/testdata/server/index.yaml b/pkg/repo/v1/testdata/server/index.yaml similarity index 100% rename from pkg/repo/testdata/server/index.yaml rename to pkg/repo/v1/testdata/server/index.yaml diff --git a/pkg/repo/testdata/server/test.txt b/pkg/repo/v1/testdata/server/test.txt similarity index 100% rename from pkg/repo/testdata/server/test.txt rename to pkg/repo/v1/testdata/server/test.txt From 9dcc49cbd5e37cc916a23a4f375f7f4214dfd515 Mon Sep 17 00:00:00 2001 From: Matt Farina Date: Mon, 1 Sep 2025 17:46:14 -0400 Subject: [PATCH 29/55] Move lint pkg to be part of each chart version Linting is specific to the chart versions. A v2 and v3 chart will lint differently. To accomplish this, packages like engine need to be able to handle different chart versions. This was accomplished by some changes: 1. The introduction of a Charter interface for charts 2. The ChartAccessor which is able to accept a chart and then provide access to its data via an interface. There is an interface, factory, and implementation for each version of chart. 3. Common packages were moved to a common and util packages. Due to some package loops, there are 2 packages which may get some consolidation in the future. The new interfaces provide the foundation to move the actions and cmd packages to be able to handle multiple apiVersions of charts. Signed-off-by: Matt Farina --- .golangci.yml | 6 +- Makefile | 13 +- internal/chart/v3/chart.go | 14 +- internal/chart/v3/chart_test.go | 14 +- internal/chart/v3/lint/lint.go | 66 ++ internal/chart/v3/lint/lint_test.go | 246 ++++++ internal/chart/v3/lint/rules/chartfile.go | 225 ++++++ .../chart/v3/lint/rules/chartfile_test.go | 276 +++++++ internal/chart/v3/lint/rules/crds.go | 113 +++ internal/chart/v3/lint/rules/crds_test.go | 36 + internal/chart/v3/lint/rules/dependencies.go | 101 +++ .../chart/v3/lint/rules/dependencies_test.go | 157 ++++ .../chart/v3}/lint/rules/deprecations.go | 8 +- .../chart/v3/lint/rules/deprecations_test.go | 41 + internal/chart/v3/lint/rules/template.go | 348 +++++++++ internal/chart/v3/lint/rules/template_test.go | 441 +++++++++++ .../lint/rules/testdata/albatross/Chart.yaml | 5 + .../testdata/albatross/templates/_helpers.tpl | 0 .../testdata/albatross/templates/fail.yaml | 0 .../testdata/albatross/templates/svc.yaml | 0 .../lint/rules/testdata/albatross/values.yaml | 0 .../testdata/anotherbadchartfile/Chart.yaml | 15 + .../rules/testdata/badchartfile/Chart.yaml | 0 .../rules/testdata/badchartfile/values.yaml | 0 .../rules/testdata/badchartname/Chart.yaml | 5 + .../rules/testdata/badchartname/values.yaml | 0 .../lint/rules/testdata/badcrdfile/Chart.yaml | 6 + .../badcrdfile/crds/bad-apiversion.yaml | 0 .../testdata/badcrdfile/crds/bad-crd.yaml | 0 .../testdata/badcrdfile/templates/.gitkeep | 0 .../rules/testdata/badcrdfile/values.yaml | 0 .../rules/testdata/badvaluesfile/Chart.yaml | 6 + .../templates/badvaluesfile.yaml | 0 .../rules/testdata/badvaluesfile/values.yaml | 0 .../v3/lint/rules/testdata/goodone/Chart.yaml | 5 + .../rules/testdata/goodone/crds/test-crd.yaml | 0 .../testdata/goodone/templates/goodone.yaml | 0 .../lint/rules/testdata/goodone/values.yaml | 0 .../testdata/invalidchartfile/Chart.yaml | 0 .../testdata/invalidchartfile/values.yaml | 0 .../rules/testdata/invalidcrdsdir/Chart.yaml | 6 + .../lint/rules/testdata/invalidcrdsdir/crds | 0 .../rules/testdata/invalidcrdsdir/values.yaml | 0 .../testdata/malformed-template/.helmignore | 0 .../testdata/malformed-template/Chart.yaml | 25 + .../malformed-template/templates/bad.yaml | 0 .../testdata/malformed-template/values.yaml | 0 .../testdata/multi-template-fail/Chart.yaml | 21 + .../templates/multi-fail.yaml | 0 .../v3/lint/rules/testdata/v3-fail/Chart.yaml | 21 + .../testdata/v3-fail/templates/_helpers.tpl | 0 .../v3-fail/templates/deployment.yaml | 0 .../testdata/v3-fail/templates/ingress.yaml | 0 .../testdata/v3-fail/templates/service.yaml | 0 .../lint/rules/testdata/v3-fail/values.yaml | 0 .../rules/testdata/withsubchart/Chart.yaml | 16 + .../withsubchart/charts/subchart/Chart.yaml | 6 + .../charts/subchart/templates/subchart.yaml | 0 .../withsubchart/charts/subchart/values.yaml | 0 .../withsubchart/templates/mainchart.yaml | 0 .../rules/testdata/withsubchart/values.yaml | 0 internal/chart/v3/lint/rules/values.go | 79 ++ .../chart/v3}/lint/rules/values_test.go | 0 .../errors_test.go => lint/support/doc.go} | 26 +- .../chart/v3}/lint/support/message.go | 0 .../chart/v3}/lint/support/message_test.go | 0 internal/chart/v3/loader/load.go | 9 +- internal/chart/v3/loader/load_test.go | 5 +- internal/chart/v3/util/capabilities.go | 122 --- internal/chart/v3/util/capabilities_test.go | 84 -- internal/chart/v3/util/coalesce.go | 308 -------- internal/chart/v3/util/coalesce_test.go | 723 ------------------ internal/chart/v3/util/create.go | 5 +- internal/chart/v3/util/dependencies.go | 38 +- internal/chart/v3/util/dependencies_test.go | 11 +- internal/chart/v3/util/errors.go | 43 -- internal/chart/v3/util/jsonschema.go | 113 --- internal/chart/v3/util/jsonschema_test.go | 247 ------ internal/chart/v3/util/save.go | 5 +- internal/chart/v3/util/save_test.go | 11 +- internal/chart/v3/util/values.go | 220 ------ internal/chart/v3/util/values_test.go | 293 ------- pkg/action/action.go | 21 +- pkg/action/action_test.go | 20 +- pkg/action/get_values.go | 6 +- pkg/action/hooks_test.go | 9 +- pkg/action/install.go | 12 +- pkg/action/install_test.go | 7 +- pkg/action/lint.go | 9 +- pkg/action/show.go | 3 +- pkg/action/show_test.go | 9 +- pkg/action/upgrade.go | 12 +- pkg/chart/common.go | 219 ++++++ pkg/chart/{v2/util => common}/capabilities.go | 2 +- .../{v2/util => common}/capabilities_test.go | 2 +- pkg/chart/{v2/util => common}/errors.go | 2 +- pkg/chart/{v2/util => common}/errors_test.go | 2 +- .../chart/v3 => pkg/chart/common}/file.go | 2 +- .../util => common}/testdata/coleridge.yaml | 0 pkg/chart/{v2 => common}/util/coalesce.go | 80 +- .../{v2 => common}/util/coalesce_test.go | 18 +- pkg/chart/{v2 => common}/util/jsonschema.go | 21 +- .../{v2 => common}/util/jsonschema_test.go | 7 +- .../testdata/test-values-invalid.schema.json | 0 .../util/testdata/test-values-negative.yaml | 0 .../util/testdata/test-values.schema.json | 0 .../util/testdata/test-values.yaml | 0 pkg/chart/common/util/values.go | 70 ++ pkg/chart/common/util/values_test.go | 111 +++ pkg/chart/{v2/util => common}/values.go | 47 +- pkg/chart/{v2/util => common}/values_test.go | 90 +-- pkg/chart/{v2/file.go => interfaces.go} | 28 +- pkg/chart/v2/chart.go | 14 +- pkg/chart/v2/chart_test.go | 14 +- pkg/{ => chart/v2}/lint/lint.go | 12 +- pkg/{ => chart/v2}/lint/lint_test.go | 2 +- pkg/{ => chart/v2}/lint/rules/chartfile.go | 4 +- .../v2}/lint/rules/chartfile_test.go | 2 +- pkg/{ => chart/v2}/lint/rules/crds.go | 2 +- pkg/{ => chart/v2}/lint/rules/crds_test.go | 2 +- pkg/{ => chart/v2}/lint/rules/dependencies.go | 4 +- .../v2}/lint/rules/dependencies_test.go | 2 +- pkg/chart/v2/lint/rules/deprecations.go | 106 +++ .../v2}/lint/rules/deprecations_test.go | 2 +- pkg/{ => chart/v2}/lint/rules/template.go | 16 +- .../v2}/lint/rules/template_test.go | 9 +- .../lint/rules/testdata/albatross/Chart.yaml | 0 .../testdata/albatross/templates/_helpers.tpl | 16 + .../testdata/albatross/templates/fail.yaml | 1 + .../testdata/albatross/templates/svc.yaml | 19 + .../lint/rules/testdata/albatross/values.yaml | 1 + .../testdata/anotherbadchartfile/Chart.yaml | 0 .../rules/testdata/badchartfile/Chart.yaml | 11 + .../rules/testdata/badchartfile/values.yaml | 1 + .../rules/testdata/badchartname/Chart.yaml | 0 .../rules/testdata/badchartname/values.yaml | 1 + .../lint/rules/testdata/badcrdfile/Chart.yaml | 0 .../badcrdfile/crds/bad-apiversion.yaml | 2 + .../testdata/badcrdfile/crds/bad-crd.yaml | 2 + .../testdata/badcrdfile/templates/.gitkeep | 0 .../rules/testdata/badcrdfile/values.yaml | 1 + .../rules/testdata/badvaluesfile/Chart.yaml | 0 .../templates/badvaluesfile.yaml | 2 + .../rules/testdata/badvaluesfile/values.yaml | 2 + .../lint/rules/testdata/goodone/Chart.yaml | 0 .../rules/testdata/goodone/crds/test-crd.yaml | 19 + .../testdata/goodone/templates/goodone.yaml | 2 + .../lint/rules/testdata/goodone/values.yaml | 1 + .../testdata/invalidchartfile/Chart.yaml | 6 + .../testdata/invalidchartfile/values.yaml | 0 .../rules/testdata/invalidcrdsdir/Chart.yaml | 0 .../lint/rules/testdata/invalidcrdsdir/crds | 0 .../rules/testdata/invalidcrdsdir/values.yaml | 1 + .../testdata/malformed-template/.helmignore | 23 + .../testdata/malformed-template/Chart.yaml | 0 .../malformed-template/templates/bad.yaml | 1 + .../testdata/malformed-template/values.yaml | 82 ++ .../testdata/multi-template-fail/Chart.yaml | 0 .../templates/multi-fail.yaml | 13 + .../lint/rules/testdata/v3-fail/Chart.yaml | 0 .../testdata/v3-fail/templates/_helpers.tpl | 63 ++ .../v3-fail/templates/deployment.yaml | 56 ++ .../testdata/v3-fail/templates/ingress.yaml | 62 ++ .../testdata/v3-fail/templates/service.yaml | 17 + .../lint/rules/testdata/v3-fail/values.yaml | 66 ++ .../rules/testdata/withsubchart/Chart.yaml | 0 .../withsubchart/charts/subchart/Chart.yaml | 0 .../charts/subchart/templates/subchart.yaml | 2 + .../withsubchart/charts/subchart/values.yaml | 2 + .../withsubchart/templates/mainchart.yaml | 2 + .../rules/testdata/withsubchart/values.yaml | 0 pkg/{ => chart/v2}/lint/rules/values.go | 13 +- pkg/chart/v2/lint/rules/values_test.go | 169 ++++ pkg/{ => chart/v2}/lint/support/doc.go | 2 +- pkg/chart/v2/lint/support/message.go | 76 ++ pkg/chart/v2/lint/support/message_test.go | 79 ++ pkg/chart/v2/loader/load.go | 13 +- pkg/chart/v2/loader/load_test.go | 5 +- pkg/chart/v2/util/create.go | 5 +- pkg/chart/v2/util/dependencies.go | 38 +- pkg/chart/v2/util/dependencies_test.go | 11 +- pkg/chart/v2/util/save.go | 5 +- pkg/chart/v2/util/save_test.go | 11 +- pkg/cli/values/options_test.go | 2 +- pkg/cmd/helpers_test.go | 4 +- pkg/cmd/lint.go | 6 +- pkg/cmd/status.go | 4 +- pkg/cmd/template.go | 6 +- pkg/cmd/upgrade_test.go | 5 +- pkg/engine/engine.go | 61 +- pkg/engine/engine_test.go | 203 ++--- pkg/engine/files.go | 4 +- pkg/engine/lookup_func.go | 2 +- pkg/release/v1/mock.go | 3 +- pkg/release/v1/util/manifest_sorter.go | 4 +- 195 files changed, 4090 insertions(+), 2702 deletions(-) create mode 100644 internal/chart/v3/lint/lint.go create mode 100644 internal/chart/v3/lint/lint_test.go create mode 100644 internal/chart/v3/lint/rules/chartfile.go create mode 100644 internal/chart/v3/lint/rules/chartfile_test.go create mode 100644 internal/chart/v3/lint/rules/crds.go create mode 100644 internal/chart/v3/lint/rules/crds_test.go create mode 100644 internal/chart/v3/lint/rules/dependencies.go create mode 100644 internal/chart/v3/lint/rules/dependencies_test.go rename {pkg => internal/chart/v3}/lint/rules/deprecations.go (95%) create mode 100644 internal/chart/v3/lint/rules/deprecations_test.go create mode 100644 internal/chart/v3/lint/rules/template.go create mode 100644 internal/chart/v3/lint/rules/template_test.go create mode 100644 internal/chart/v3/lint/rules/testdata/albatross/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/albatross/templates/_helpers.tpl (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/albatross/templates/fail.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/albatross/templates/svc.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/albatross/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/badchartfile/Chart.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/badchartfile/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/badchartname/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/badchartname/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/badcrdfile/templates/.gitkeep (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/badcrdfile/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/badvaluesfile/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/goodone/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/goodone/crds/test-crd.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/goodone/templates/goodone.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/goodone/values.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/invalidchartfile/Chart.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/invalidchartfile/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/invalidcrdsdir/crds (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/invalidcrdsdir/values.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/malformed-template/.helmignore (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/malformed-template/templates/bad.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/malformed-template/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/v3-fail/templates/_helpers.tpl (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/v3-fail/templates/deployment.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/v3-fail/templates/ingress.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/v3-fail/templates/service.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/v3-fail/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml create mode 100644 internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml rename {pkg => internal/chart/v3}/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/withsubchart/charts/subchart/values.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/withsubchart/templates/mainchart.yaml (100%) rename {pkg => internal/chart/v3}/lint/rules/testdata/withsubchart/values.yaml (100%) create mode 100644 internal/chart/v3/lint/rules/values.go rename {pkg => internal/chart/v3}/lint/rules/values_test.go (100%) rename internal/chart/v3/{util/errors_test.go => lint/support/doc.go} (67%) rename {pkg => internal/chart/v3}/lint/support/message.go (100%) rename {pkg => internal/chart/v3}/lint/support/message_test.go (100%) delete mode 100644 internal/chart/v3/util/capabilities.go delete mode 100644 internal/chart/v3/util/capabilities_test.go delete mode 100644 internal/chart/v3/util/coalesce.go delete mode 100644 internal/chart/v3/util/coalesce_test.go delete mode 100644 internal/chart/v3/util/errors.go delete mode 100644 internal/chart/v3/util/jsonschema.go delete mode 100644 internal/chart/v3/util/jsonschema_test.go delete mode 100644 internal/chart/v3/util/values.go delete mode 100644 internal/chart/v3/util/values_test.go create mode 100644 pkg/chart/common.go rename pkg/chart/{v2/util => common}/capabilities.go (99%) rename pkg/chart/{v2/util => common}/capabilities_test.go (99%) rename pkg/chart/{v2/util => common}/errors.go (98%) rename pkg/chart/{v2/util => common}/errors_test.go (98%) rename {internal/chart/v3 => pkg/chart/common}/file.go (98%) rename pkg/chart/{v2/util => common}/testdata/coleridge.yaml (100%) rename pkg/chart/{v2 => common}/util/coalesce.go (81%) rename pkg/chart/{v2 => common}/util/coalesce_test.go (97%) rename pkg/chart/{v2 => common}/util/jsonschema.go (89%) rename pkg/chart/{v2 => common}/util/jsonschema_test.go (96%) rename pkg/chart/{v2 => common}/util/testdata/test-values-invalid.schema.json (100%) rename pkg/chart/{v2 => common}/util/testdata/test-values-negative.yaml (100%) rename pkg/chart/{v2 => common}/util/testdata/test-values.schema.json (100%) rename pkg/chart/{v2 => common}/util/testdata/test-values.yaml (100%) create mode 100644 pkg/chart/common/util/values.go create mode 100644 pkg/chart/common/util/values_test.go rename pkg/chart/{v2/util => common}/values.go (74%) rename pkg/chart/{v2/util => common}/values_test.go (66%) rename pkg/chart/{v2/file.go => interfaces.go} (60%) rename pkg/{ => chart/v2}/lint/lint.go (83%) rename pkg/{ => chart/v2}/lint/lint_test.go (99%) rename pkg/{ => chart/v2}/lint/rules/chartfile.go (98%) rename pkg/{ => chart/v2}/lint/rules/chartfile_test.go (99%) rename pkg/{ => chart/v2}/lint/rules/crds.go (98%) rename pkg/{ => chart/v2}/lint/rules/crds_test.go (95%) rename pkg/{ => chart/v2}/lint/rules/dependencies.go (96%) rename pkg/{ => chart/v2}/lint/rules/dependencies_test.go (98%) create mode 100644 pkg/chart/v2/lint/rules/deprecations.go rename pkg/{ => chart/v2}/lint/rules/deprecations_test.go (94%) rename pkg/{ => chart/v2}/lint/rules/template.go (95%) rename pkg/{ => chart/v2}/lint/rules/template_test.go (98%) rename pkg/{ => chart/v2}/lint/rules/testdata/albatross/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl create mode 100644 pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/albatross/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/anotherbadchartfile/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/badchartname/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/badcrdfile/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep create mode 100644 pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/badvaluesfile/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/goodone/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/goodone/values.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/invalidchartfile/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/invalidcrdsdir/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds create mode 100644 pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore rename pkg/{ => chart/v2}/lint/rules/testdata/malformed-template/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/multi-template-fail/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/v3-fail/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl create mode 100644 pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml rename pkg/{ => chart/v2}/lint/rules/testdata/withsubchart/Chart.yaml (100%) rename pkg/{ => chart/v2}/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml (100%) create mode 100644 pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml create mode 100644 pkg/chart/v2/lint/rules/testdata/withsubchart/values.yaml rename pkg/{ => chart/v2}/lint/rules/values.go (84%) create mode 100644 pkg/chart/v2/lint/rules/values_test.go rename pkg/{ => chart/v2}/lint/support/doc.go (91%) create mode 100644 pkg/chart/v2/lint/support/message.go create mode 100644 pkg/chart/v2/lint/support/message_test.go diff --git a/.golangci.yml b/.golangci.yml index a9b13c35f..3df31b997 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,6 +33,7 @@ linters: - usetesting exclusions: + generated: lax presets: @@ -41,7 +42,10 @@ linters: - legacy - std-error-handling - rules: [] + rules: + - linters: + - revive + text: 'var-naming: avoid meaningless package names' warn-unused: true diff --git a/Makefile b/Makefile index 5e424bf05..5e1bfc6c2 100644 --- a/Makefile +++ b/Makefile @@ -63,10 +63,12 @@ K8S_MODULES_VER=$(subst ., ,$(subst v,,$(shell go list -f '{{.Version}}' -m k8s. K8S_MODULES_MAJOR_VER=$(shell echo $$(($(firstword $(K8S_MODULES_VER)) + 1))) K8S_MODULES_MINOR_VER=$(word 2,$(K8S_MODULES_VER)) -LDFLAGS += -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) -LDFLAGS += -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) -LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) -LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/chart/v2/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/internal/v3/lint/rules.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMajor=$(K8S_MODULES_MAJOR_VER) +LDFLAGS += -X helm.sh/helm/v4/pkg/chart/common/util.k8sVersionMinor=$(K8S_MODULES_MINOR_VER) .PHONY: all all: build @@ -112,7 +114,8 @@ test-unit: # based on older versions, this is run separately. When run without the ldflags in the unit test (above) or coverage # test, it still passes with a false-positive result as the resources shouldn’t be deprecated in the older Kubernetes # version if it only starts failing with the latest. - go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./pkg/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)' + go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./pkg/chart/v2/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)' + go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./internal/chart/v3/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)' .PHONY: test-coverage diff --git a/internal/chart/v3/chart.go b/internal/chart/v3/chart.go index 4d59fa5ec..2edc6c339 100644 --- a/internal/chart/v3/chart.go +++ b/internal/chart/v3/chart.go @@ -19,6 +19,8 @@ import ( "path/filepath" "regexp" "strings" + + "helm.sh/helm/v4/pkg/chart/common" ) // APIVersionV3 is the API version number for version 3. @@ -34,20 +36,20 @@ type Chart struct { // // This should not be used except in special cases like `helm show values`, // where we want to display the raw values, comments and all. - Raw []*File `json:"-"` + Raw []*common.File `json:"-"` // Metadata is the contents of the Chartfile. Metadata *Metadata `json:"metadata"` // Lock is the contents of Chart.lock. Lock *Lock `json:"lock"` // Templates for this chart. - Templates []*File `json:"templates"` + Templates []*common.File `json:"templates"` // Values are default config for this chart. Values map[string]interface{} `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. - Files []*File `json:"files"` + Files []*common.File `json:"files"` parent *Chart dependencies []*Chart @@ -59,7 +61,7 @@ type CRD struct { // Filename is the File obj Name including (sub-)chart.ChartFullPath Filename string // File is the File obj for the crd - File *File + File *common.File } // SetDependencies replaces the chart dependencies. @@ -134,8 +136,8 @@ func (ch *Chart) AppVersion() string { // CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. // Deprecated: use CRDObjects() -func (ch *Chart) CRDs() []*File { - files := []*File{} +func (ch *Chart) CRDs() []*common.File { + files := []*common.File{} // Find all resources in the crds/ directory for _, f := range ch.Files { if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { diff --git a/internal/chart/v3/chart_test.go b/internal/chart/v3/chart_test.go index f93b3356b..b1820ac0a 100644 --- a/internal/chart/v3/chart_test.go +++ b/internal/chart/v3/chart_test.go @@ -20,11 +20,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/chart/common" ) func TestCRDs(t *testing.T) { chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { Name: "crds/foo.yaml", Data: []byte("hello"), @@ -57,7 +59,7 @@ func TestCRDs(t *testing.T) { func TestSaveChartNoRawData(t *testing.T) { chrt := Chart{ - Raw: []*File{ + Raw: []*common.File{ { Name: "fhqwhgads.yaml", Data: []byte("Everybody to the Limit"), @@ -76,7 +78,7 @@ func TestSaveChartNoRawData(t *testing.T) { t.Fatal(err) } - is.Equal([]*File(nil), res.Raw) + is.Equal([]*common.File(nil), res.Raw) } func TestMetadata(t *testing.T) { @@ -162,7 +164,7 @@ func TestChartFullPath(t *testing.T) { func TestCRDObjects(t *testing.T) { chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { Name: "crds/foo.yaml", Data: []byte("hello"), @@ -190,7 +192,7 @@ func TestCRDObjects(t *testing.T) { { Name: "crds/foo.yaml", Filename: "crds/foo.yaml", - File: &File{ + File: &common.File{ Name: "crds/foo.yaml", Data: []byte("hello"), }, @@ -198,7 +200,7 @@ func TestCRDObjects(t *testing.T) { { Name: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml", - File: &File{ + File: &common.File{ Name: "crds/foo/bar/baz.yaml", Data: []byte("hello"), }, diff --git a/internal/chart/v3/lint/lint.go b/internal/chart/v3/lint/lint.go new file mode 100644 index 000000000..231bb6803 --- /dev/null +++ b/internal/chart/v3/lint/lint.go @@ -0,0 +1,66 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package lint // import "helm.sh/helm/v4/internal/chart/v3/lint" + +import ( + "path/filepath" + + "helm.sh/helm/v4/internal/chart/v3/lint/rules" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + "helm.sh/helm/v4/pkg/chart/common" +) + +type linterOptions struct { + KubeVersion *common.KubeVersion + SkipSchemaValidation bool +} + +type LinterOption func(lo *linterOptions) + +func WithKubeVersion(kubeVersion *common.KubeVersion) LinterOption { + return func(lo *linterOptions) { + lo.KubeVersion = kubeVersion + } +} + +func WithSkipSchemaValidation(skipSchemaValidation bool) LinterOption { + return func(lo *linterOptions) { + lo.SkipSchemaValidation = skipSchemaValidation + } +} + +func RunAll(baseDir string, values map[string]interface{}, namespace string, options ...LinterOption) support.Linter { + + chartDir, _ := filepath.Abs(baseDir) + + lo := linterOptions{} + for _, option := range options { + option(&lo) + } + + result := support.Linter{ + ChartDir: chartDir, + } + + rules.Chartfile(&result) + rules.ValuesWithOverrides(&result, values) + rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) + rules.Dependencies(&result) + rules.Crds(&result) + + return result +} diff --git a/internal/chart/v3/lint/lint_test.go b/internal/chart/v3/lint/lint_test.go new file mode 100644 index 000000000..af44cc58d --- /dev/null +++ b/internal/chart/v3/lint/lint_test.go @@ -0,0 +1,246 @@ +/* +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 lint + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/internal/chart/v3/lint/support" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" +) + +var values map[string]interface{} + +const namespace = "testNamespace" + +const badChartDir = "rules/testdata/badchartfile" +const badValuesFileDir = "rules/testdata/badvaluesfile" +const badYamlFileDir = "rules/testdata/albatross" +const badCrdFileDir = "rules/testdata/badcrdfile" +const goodChartDir = "rules/testdata/goodone" +const subChartValuesDir = "rules/testdata/withsubchart" +const malformedTemplate = "rules/testdata/malformed-template" +const invalidChartFileDir = "rules/testdata/invalidchartfile" + +func TestBadChartV3(t *testing.T) { + m := RunAll(badChartDir, values, namespace).Messages + if len(m) != 8 { + t.Errorf("Number of errors %v", len(m)) + t.Errorf("All didn't fail with expected errors, got %#v", m) + } + // There should be one INFO, one WARNING, and 2 ERROR messages, check for them + var i, w, e, e2, e3, e4, e5, e6 bool + for _, msg := range m { + if msg.Severity == support.InfoSev { + if strings.Contains(msg.Err.Error(), "icon is recommended") { + i = true + } + } + if msg.Severity == support.WarningSev { + if strings.Contains(msg.Err.Error(), "does not exist") { + w = true + } + } + if msg.Severity == support.ErrorSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + e = true + } + if strings.Contains(msg.Err.Error(), "name is required") { + e2 = true + } + + if strings.Contains(msg.Err.Error(), "apiVersion is required. The value must be \"v3\"") { + e3 = true + } + + if strings.Contains(msg.Err.Error(), "chart type is not valid in apiVersion") { + e4 = true + } + + if strings.Contains(msg.Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { + e5 = true + } + // This comes from the dependency check, which loads dependency info from the Chart.yaml + if strings.Contains(msg.Err.Error(), "unable to load chart") { + e6 = true + } + } + } + if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w { + t.Errorf("Didn't find all the expected errors, got %#v", m) + } +} + +func TestInvalidYaml(t *testing.T) { + m := RunAll(badYamlFileDir, values, namespace).Messages + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("All didn't have the error for deliberateSyntaxError") + } +} + +func TestInvalidChartYamlV3(t *testing.T) { + m := RunAll(invalidChartFileDir, values, namespace).Messages + t.Log(m) + if len(m) != 3 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "failed to strictly parse chart metadata file") { + t.Errorf("All didn't have the error for duplicate YAML keys") + } +} + +func TestBadValuesV3(t *testing.T) { + m := RunAll(badValuesFileDir, values, namespace).Messages + if len(m) < 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "unable to parse YAML") { + t.Errorf("All didn't have the error for invalid key format: %s", m[0].Err) + } +} + +func TestBadCrdFileV3(t *testing.T) { + m := RunAll(badCrdFileDir, values, namespace).Messages + assert.Lenf(t, m, 2, "All didn't fail with expected errors, got %#v", m) + assert.ErrorContains(t, m[0].Err, "apiVersion is not in 'apiextensions.k8s.io'") + assert.ErrorContains(t, m[1].Err, "object kind is not 'CustomResourceDefinition'") +} + +func TestGoodChart(t *testing.T) { + m := RunAll(goodChartDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// TestHelmCreateChart tests that a `helm create` always passes a `helm lint` test. +// +// See https://github.com/helm/helm/issues/7923 +func TestHelmCreateChart(t *testing.T) { + dir := t.TempDir() + + createdChart, err := chartutil.Create("testhelmcreatepasseslint", dir) + if err != nil { + t.Error(err) + // Fatal is bad because of the defer. + return + } + + // Note: we test with strict=true here, even though others have + // strict = false. + m := RunAll(createdChart, values, namespace, WithSkipSchemaValidation(true)).Messages + if ll := len(m); ll != 1 { + t.Errorf("All should have had exactly 1 error. Got %d", ll) + for i, msg := range m { + t.Logf("Message %d: %s", i, msg.Error()) + } + } else if msg := m[0].Err.Error(); !strings.Contains(msg, "icon is recommended") { + t.Errorf("Unexpected lint error: %s", msg) + } +} + +// TestHelmCreateChart_CheckDeprecatedWarnings checks if any default template created by `helm create` throws +// deprecated warnings in the linter check against the current Kubernetes version (provided using ldflags). +// +// See https://github.com/helm/helm/issues/11495 +// +// Resources like hpa and ingress, which are disabled by default in values.yaml are enabled here using the equivalent +// of the `--set` flag. +// +// Note: This test requires the following ldflags to be set per the current Kubernetes version to avoid false-positive +// results. +// 1. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMajor= +// 2. -X helm.sh/helm/v4/pkg/lint/rules.k8sVersionMinor= +// or directly use '$(LDFLAGS)' in Makefile. +// +// When run without ldflags, the test passes giving a false-positive result. This is because the variables +// `k8sVersionMajor` and `k8sVersionMinor` by default are set to an older version of Kubernetes, with which, there +// might not be the deprecation warning. +func TestHelmCreateChart_CheckDeprecatedWarnings(t *testing.T) { + createdChart, err := chartutil.Create("checkdeprecatedwarnings", t.TempDir()) + if err != nil { + t.Error(err) + return + } + + // Add values to enable hpa, and ingress which are disabled by default. + // This is the equivalent of: + // helm lint checkdeprecatedwarnings --set 'autoscaling.enabled=true,ingress.enabled=true' + updatedValues := map[string]interface{}{ + "autoscaling": map[string]interface{}{ + "enabled": true, + }, + "ingress": map[string]interface{}{ + "enabled": true, + }, + } + + linterRunDetails := RunAll(createdChart, updatedValues, namespace, WithSkipSchemaValidation(true)) + for _, msg := range linterRunDetails.Messages { + if strings.HasPrefix(msg.Error(), "[WARNING]") && + strings.Contains(msg.Error(), "deprecated") { + // When there is a deprecation warning for an object created + // by `helm create` for the current Kubernetes version, fail. + t.Errorf("Unexpected deprecation warning for %q: %s", msg.Path, msg.Error()) + } + } +} + +// lint ignores import-values +// See https://github.com/helm/helm/issues/9658 +func TestSubChartValuesChart(t *testing.T) { + m := RunAll(subChartValuesDir, values, namespace).Messages + if len(m) != 0 { + t.Error("All returned linter messages when it shouldn't have") + for i, msg := range m { + t.Logf("Message %d: %s", i, msg) + } + } +} + +// lint stuck with malformed template object +// See https://github.com/helm/helm/issues/11391 +func TestMalformedTemplate(t *testing.T) { + c := time.After(3 * time.Second) + ch := make(chan int, 1) + var m []support.Message + go func() { + m = RunAll(malformedTemplate, values, namespace).Messages + ch <- 1 + }() + select { + case <-c: + t.Fatalf("lint malformed template timeout") + case <-ch: + if len(m) != 1 { + t.Fatalf("All didn't fail with expected errors, got %#v", m) + } + if !strings.Contains(m[0].Err.Error(), "invalid character '{'") { + t.Errorf("All didn't have the error for invalid character '{'") + } + } +} diff --git a/internal/chart/v3/lint/rules/chartfile.go b/internal/chart/v3/lint/rules/chartfile.go new file mode 100644 index 000000000..e72a0d3b2 --- /dev/null +++ b/internal/chart/v3/lint/rules/chartfile.go @@ -0,0 +1,225 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules // import "helm.sh/helm/v4/internal/chart/v3/lint/rules" + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "github.com/asaskevich/govalidator" + "sigs.k8s.io/yaml" + + chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" +) + +// Chartfile runs a set of linter rules related to Chart.yaml file +func Chartfile(linter *support.Linter) { + chartFileName := "Chart.yaml" + chartPath := filepath.Join(linter.ChartDir, chartFileName) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlNotDirectory(chartPath)) + + chartFile, err := chartutil.LoadChartfile(chartPath) + validChartFile := linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartYamlFormat(err)) + + // Guard clause. Following linter rules require a parsable ChartFile + if !validChartFile { + return + } + + _, err = chartutil.StrictLoadChartfile(chartPath) + linter.RunLinterRule(support.WarningSev, chartFileName, validateChartYamlStrictFormat(err)) + + // type check for Chart.yaml . ignoring error as any parse + // errors would already be caught in the above load function + chartFileForTypeCheck, _ := loadChartFileForTypeCheck(chartPath) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartName(chartFile)) + + // Chart metadata + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAPIVersion(chartFile)) + + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartVersion(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartAppVersionType(chartFileForTypeCheck)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartMaintainer(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartSources(chartFile)) + linter.RunLinterRule(support.InfoSev, chartFileName, validateChartIconPresence(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) + linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) +} + +func validateChartVersionType(data map[string]interface{}) error { + return isStringValue(data, "version") +} + +func validateChartAppVersionType(data map[string]interface{}) error { + return isStringValue(data, "appVersion") +} + +func isStringValue(data map[string]interface{}, key string) error { + value, ok := data[key] + if !ok { + return nil + } + valueType := fmt.Sprintf("%T", value) + if valueType != "string" { + return fmt.Errorf("%s should be of type string but it's of type %s", key, valueType) + } + return nil +} + +func validateChartYamlNotDirectory(chartPath string) error { + fi, err := os.Stat(chartPath) + + if err == nil && fi.IsDir() { + return errors.New("should be a file, not a directory") + } + return nil +} + +func validateChartYamlFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("unable to parse YAML\n\t%w", chartFileError) + } + return nil +} + +func validateChartYamlStrictFormat(chartFileError error) error { + if chartFileError != nil { + return fmt.Errorf("failed to strictly parse chart metadata file\n\t%w", chartFileError) + } + return nil +} + +func validateChartName(cf *chart.Metadata) error { + if cf.Name == "" { + return errors.New("name is required") + } + name := filepath.Base(cf.Name) + if name != cf.Name { + return fmt.Errorf("chart name %q is invalid", cf.Name) + } + return nil +} + +func validateChartAPIVersion(cf *chart.Metadata) error { + if cf.APIVersion == "" { + return errors.New("apiVersion is required. The value must be \"v3\"") + } + + if cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("apiVersion '%s' is not valid. The value must be \"v3\"", cf.APIVersion) + } + + return nil +} + +func validateChartVersion(cf *chart.Metadata) error { + if cf.Version == "" { + return errors.New("version is required") + } + + version, err := semver.NewVersion(cf.Version) + if err != nil { + return fmt.Errorf("version '%s' is not a valid SemVer", cf.Version) + } + + c, err := semver.NewConstraint(">0.0.0-0") + if err != nil { + return err + } + valid, msg := c.Validate(version) + + if !valid && len(msg) > 0 { + return fmt.Errorf("version %v", msg[0]) + } + + return nil +} + +func validateChartMaintainer(cf *chart.Metadata) error { + for _, maintainer := range cf.Maintainers { + if maintainer == nil { + return errors.New("a maintainer entry is empty") + } + if maintainer.Name == "" { + return errors.New("each maintainer requires a name") + } else if maintainer.Email != "" && !govalidator.IsEmail(maintainer.Email) { + return fmt.Errorf("invalid email '%s' for maintainer '%s'", maintainer.Email, maintainer.Name) + } else if maintainer.URL != "" && !govalidator.IsURL(maintainer.URL) { + return fmt.Errorf("invalid url '%s' for maintainer '%s'", maintainer.URL, maintainer.Name) + } + } + return nil +} + +func validateChartSources(cf *chart.Metadata) error { + for _, source := range cf.Sources { + if source == "" || !govalidator.IsRequestURL(source) { + return fmt.Errorf("invalid source URL '%s'", source) + } + } + return nil +} + +func validateChartIconPresence(cf *chart.Metadata) error { + if cf.Icon == "" { + return errors.New("icon is recommended") + } + return nil +} + +func validateChartIconURL(cf *chart.Metadata) error { + if cf.Icon != "" && !govalidator.IsRequestURL(cf.Icon) { + return fmt.Errorf("invalid icon URL '%s'", cf.Icon) + } + return nil +} + +func validateChartDependencies(cf *chart.Metadata) error { + if len(cf.Dependencies) > 0 && cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("dependencies are not valid in the Chart file with apiVersion '%s'. They are valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV3) + } + return nil +} + +func validateChartType(cf *chart.Metadata) error { + if len(cf.Type) > 0 && cf.APIVersion != chart.APIVersionV3 { + return fmt.Errorf("chart type is not valid in apiVersion '%s'. It is valid in apiVersion '%s'", cf.APIVersion, chart.APIVersionV3) + } + return nil +} + +// loadChartFileForTypeCheck loads the Chart.yaml +// in a generic form of a map[string]interface{}, so that the type +// of the values can be checked +func loadChartFileForTypeCheck(filename string) (map[string]interface{}, error) { + b, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + y := make(map[string]interface{}) + err = yaml.Unmarshal(b, &y) + return y, err +} diff --git a/internal/chart/v3/lint/rules/chartfile_test.go b/internal/chart/v3/lint/rules/chartfile_test.go new file mode 100644 index 000000000..070cc244d --- /dev/null +++ b/internal/chart/v3/lint/rules/chartfile_test.go @@ -0,0 +1,276 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" +) + +const ( + badChartNameDir = "testdata/badchartname" + badChartDir = "testdata/badchartfile" + anotherBadChartDir = "testdata/anotherbadchartfile" +) + +var ( + badChartNamePath = filepath.Join(badChartNameDir, "Chart.yaml") + badChartFilePath = filepath.Join(badChartDir, "Chart.yaml") + nonExistingChartFilePath = filepath.Join(os.TempDir(), "Chart.yaml") +) + +var badChart, _ = chartutil.LoadChartfile(badChartFilePath) +var badChartName, _ = chartutil.LoadChartfile(badChartNamePath) + +// Validation functions Test +func TestValidateChartYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingChartFilePath, os.ModePerm) + defer os.Remove(nonExistingChartFilePath) + + err := validateChartYamlNotDirectory(nonExistingChartFilePath) + if err == nil { + t.Errorf("validateChartYamlNotDirectory to return a linter error, got no error") + } +} + +func TestValidateChartYamlFormat(t *testing.T) { + err := validateChartYamlFormat(errors.New("Read error")) + if err == nil { + t.Errorf("validateChartYamlFormat to return a linter error, got no error") + } + + err = validateChartYamlFormat(nil) + if err != nil { + t.Errorf("validateChartYamlFormat to return no error, got a linter error") + } +} + +func TestValidateChartName(t *testing.T) { + err := validateChartName(badChart) + if err == nil { + t.Errorf("validateChartName to return a linter error, got no error") + } + + err = validateChartName(badChartName) + if err == nil { + t.Error("expected validateChartName to return a linter error for an invalid name, got no error") + } +} + +func TestValidateChartVersion(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version is required"}, + {"1.2.3.4", "version '1.2.3.4' is not a valid SemVer"}, + {"waps", "'waps' is not a valid SemVer"}, + {"-3", "'-3' is not a valid SemVer"}, + } + + var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersion(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersion(badChart) + if err != nil { + t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + } + } +} + +func TestValidateChartMaintainer(t *testing.T) { + var failTest = []struct { + Name string + Email string + ErrorMsg string + }{ + {"", "", "each maintainer requires a name"}, + {"", "test@test.com", "each maintainer requires a name"}, + {"John Snow", "wrongFormatEmail.com", "invalid email"}, + } + + var successTest = []struct { + Name string + Email string + }{ + {"John Snow", ""}, + {"John Snow", "john@winterfell.com"}, + } + + for _, test := range failTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartMaintainer(%s, %s) to return \"%s\", got no error", test.Name, test.Email, test.ErrorMsg) + } + } + + for _, test := range successTest { + badChart.Maintainers = []*chart.Maintainer{{Name: test.Name, Email: test.Email}} + err := validateChartMaintainer(badChart) + if err != nil { + 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) { + var failTest = []string{"", "RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish"} + for _, test := range failTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid source URL") { + t.Errorf("validateChartSources(%s) to return \"invalid source URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Sources = []string{test} + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartSources(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestValidateChartIconPresence(t *testing.T) { + t.Run("Icon absent", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "", + } + + err := validateChartIconPresence(testChart) + + if err == nil { + t.Errorf("validateChartIconPresence to return a linter error, got no error") + } else if !strings.Contains(err.Error(), "icon is recommended") { + t.Errorf("expected %q, got %q", "icon is recommended", err.Error()) + } + }) + t.Run("Icon present", func(t *testing.T) { + testChart := &chart.Metadata{ + Icon: "http://example.org/icon.png", + } + + err := validateChartIconPresence(testChart) + + if err != nil { + t.Errorf("Unexpected error: %q", err.Error()) + } + }) +} + +func TestValidateChartIconURL(t *testing.T) { + var failTest = []string{"RiverRun", "john@winterfell", "riverrun.io"} + var successTest = []string{"http://riverrun.io", "https://riverrun.io", "https://riverrun.io/blackfish.png"} + for _, test := range failTest { + badChart.Icon = test + err := validateChartIconURL(badChart) + if err == nil || !strings.Contains(err.Error(), "invalid icon URL") { + t.Errorf("validateChartIconURL(%s) to return \"invalid icon URL\", got no error", test) + } + } + + for _, test := range successTest { + badChart.Icon = test + err := validateChartSources(badChart) + if err != nil { + t.Errorf("validateChartIconURL(%s) to return no error, got %s", test, err.Error()) + } + } +} + +func TestV3Chartfile(t *testing.T) { + t.Run("Chart.yaml basic validity issues", func(t *testing.T) { + linter := support.Linter{ChartDir: badChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 6 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "name is required") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "apiVersion is required. The value must be \"v3\"") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + + if !strings.Contains(msgs[3].Err.Error(), "icon is recommended") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } + }) + + t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { + linter := support.Linter{ChartDir: anotherBadChartDir} + Chartfile(&linter) + msgs := linter.Messages + expectedNumberOfErrorMessages := 3 + + if len(msgs) != expectedNumberOfErrorMessages { + t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) + return + } + + if !strings.Contains(msgs[0].Err.Error(), "version should be of type string") { + t.Errorf("Unexpected message 0: %s", msgs[0].Err) + } + + if !strings.Contains(msgs[1].Err.Error(), "version '7.2445e+06' is not a valid SemVer") { + t.Errorf("Unexpected message 1: %s", msgs[1].Err) + } + + if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { + t.Errorf("Unexpected message 2: %s", msgs[2].Err) + } + }) +} diff --git a/internal/chart/v3/lint/rules/crds.go b/internal/chart/v3/lint/rules/crds.go new file mode 100644 index 000000000..6bafb52eb --- /dev/null +++ b/internal/chart/v3/lint/rules/crds.go @@ -0,0 +1,113 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/util/yaml" + + "helm.sh/helm/v4/internal/chart/v3/lint/support" + "helm.sh/helm/v4/internal/chart/v3/loader" +) + +// Crds lints the CRDs in the Linter. +func Crds(linter *support.Linter) { + fpath := "crds/" + crdsPath := filepath.Join(linter.ChartDir, fpath) + + // crds directory is optional + if _, err := os.Stat(crdsPath); errors.Is(err, fs.ErrNotExist) { + return + } + + crdsDirValid := linter.RunLinterRule(support.ErrorSev, fpath, validateCrdsDir(crdsPath)) + if !crdsDirValid { + return + } + + // Load chart and parse CRDs + chart, err := loader.Load(linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + /* Iterate over all the CRDs to check: + 1. It is a YAML file and not a template + 2. The API version is apiextensions.k8s.io + 3. The kind is CustomResourceDefinition + */ + for _, crd := range chart.CRDObjects() { + fileName := crd.Name + fpath = fileName + + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(crd.File.Data), 4096) + for { + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if err == io.EOF { + break + } + + // If YAML parsing fails here, it will always fail in the next block as well, so we should return here. + // This also confirms the YAML is not a template, since templates can't be decoded into a K8sYamlStruct. + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdAPIVersion(yamlStruct)) + linter.RunLinterRule(support.ErrorSev, fpath, validateCrdKind(yamlStruct)) + } + } +} + +// Validation functions +func validateCrdsDir(crdsPath string) error { + fi, err := os.Stat(crdsPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateCrdAPIVersion(obj *k8sYamlStruct) error { + if !strings.HasPrefix(obj.APIVersion, "apiextensions.k8s.io") { + return fmt.Errorf("apiVersion is not in 'apiextensions.k8s.io'") + } + return nil +} + +func validateCrdKind(obj *k8sYamlStruct) error { + if obj.Kind != "CustomResourceDefinition" { + return fmt.Errorf("object kind is not 'CustomResourceDefinition'") + } + return nil +} diff --git a/internal/chart/v3/lint/rules/crds_test.go b/internal/chart/v3/lint/rules/crds_test.go new file mode 100644 index 000000000..d93e3d978 --- /dev/null +++ b/internal/chart/v3/lint/rules/crds_test.go @@ -0,0 +1,36 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/internal/chart/v3/lint/support" +) + +const invalidCrdsDir = "./testdata/invalidcrdsdir" + +func TestInvalidCrdsDir(t *testing.T) { + linter := support.Linter{ChartDir: invalidCrdsDir} + Crds(&linter) + res := linter.Messages + + assert.Len(t, res, 1) + assert.ErrorContains(t, res[0].Err, "not a directory") +} diff --git a/internal/chart/v3/lint/rules/dependencies.go b/internal/chart/v3/lint/rules/dependencies.go new file mode 100644 index 000000000..f45153728 --- /dev/null +++ b/internal/chart/v3/lint/rules/dependencies.go @@ -0,0 +1,101 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules // import "helm.sh/helm/v4/internal/chart/v3/lint/rules" + +import ( + "fmt" + "strings" + + chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + "helm.sh/helm/v4/internal/chart/v3/loader" +) + +// Dependencies runs lints against a chart's dependencies +// +// See https://github.com/helm/helm/issues/7910 +func Dependencies(linter *support.Linter) { + c, err := loader.LoadDir(linter.ChartDir) + if !linter.RunLinterRule(support.ErrorSev, "", validateChartFormat(err)) { + return + } + + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependencyInMetadata(c)) + linter.RunLinterRule(support.ErrorSev, linter.ChartDir, validateDependenciesUnique(c)) + linter.RunLinterRule(support.WarningSev, linter.ChartDir, validateDependencyInChartsDir(c)) +} + +func validateChartFormat(chartError error) error { + if chartError != nil { + return fmt.Errorf("unable to load chart\n\t%w", chartError) + } + return nil +} + +func validateDependencyInChartsDir(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Dependencies() { + dependencies[dep.Metadata.Name] = struct{}{} + } + for _, dep := range c.Metadata.Dependencies { + if _, ok := dependencies[dep.Name]; !ok { + missing = append(missing, dep.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart directory is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependencyInMetadata(c *chart.Chart) (err error) { + dependencies := map[string]struct{}{} + missing := []string{} + for _, dep := range c.Metadata.Dependencies { + dependencies[dep.Name] = struct{}{} + } + for _, dep := range c.Dependencies() { + if _, ok := dependencies[dep.Metadata.Name]; !ok { + missing = append(missing, dep.Metadata.Name) + } + } + if len(missing) > 0 { + err = fmt.Errorf("chart metadata is missing these dependencies: %s", strings.Join(missing, ",")) + } + return err +} + +func validateDependenciesUnique(c *chart.Chart) (err error) { + dependencies := map[string]*chart.Dependency{} + shadowing := []string{} + + for _, dep := range c.Metadata.Dependencies { + key := dep.Name + if dep.Alias != "" { + key = dep.Alias + } + if dependencies[key] != nil { + shadowing = append(shadowing, key) + } + dependencies[key] = dep + } + if len(shadowing) > 0 { + err = fmt.Errorf("multiple dependencies with name or alias: %s", strings.Join(shadowing, ",")) + } + return err +} diff --git a/internal/chart/v3/lint/rules/dependencies_test.go b/internal/chart/v3/lint/rules/dependencies_test.go new file mode 100644 index 000000000..b80e4b8a9 --- /dev/null +++ b/internal/chart/v3/lint/rules/dependencies_test.go @@ -0,0 +1,157 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package rules + +import ( + "path/filepath" + "testing" + + chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" +) + +func chartWithBadDependencies() chart.Chart { + badChartDeps := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "sub2", + }, + { + Name: "sub3", + }, + }, + }, + } + + badChartDeps.SetDependencies( + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub1", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + &chart.Chart{ + Metadata: &chart.Metadata{ + Name: "sub2", + Version: "0.1.0", + APIVersion: "v2", + }, + }, + ) + return badChartDeps +} + +func TestValidateDependencyInChartsDir(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInChartsDir(&c); err == nil { + t.Error("chart should have been flagged for missing deps in chart directory") + } +} + +func TestValidateDependencyInMetadata(t *testing.T) { + c := chartWithBadDependencies() + + if err := validateDependencyInMetadata(&c); err == nil { + t.Errorf("chart should have been flagged for missing deps in chart metadata") + } +} + +func TestValidateDependenciesUnique(t *testing.T) { + tests := []struct { + chart chart.Chart + }{ + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + }, + { + Name: "foo", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "bar", + }, + { + Name: "bar", + }, + }, + }, + }}, + {chart.Chart{ + Metadata: &chart.Metadata{ + Name: "badchart", + Version: "0.1.0", + APIVersion: "v2", + Dependencies: []*chart.Dependency{ + { + Name: "foo", + Alias: "baz", + }, + { + Name: "bar", + Alias: "baz", + }, + }, + }, + }}, + } + + for _, tt := range tests { + if err := validateDependenciesUnique(&tt.chart); err == nil { + t.Errorf("chart should have been flagged for dependency shadowing") + } + } +} + +func TestDependencies(t *testing.T) { + tmp := t.TempDir() + + c := chartWithBadDependencies() + err := chartutil.SaveDir(&c, tmp) + if err != nil { + t.Fatal(err) + } + linter := support.Linter{ChartDir: filepath.Join(tmp, c.Metadata.Name)} + + Dependencies(&linter) + if l := len(linter.Messages); l != 2 { + t.Errorf("expected 2 linter errors for bad chart dependencies. Got %d.", l) + for i, msg := range linter.Messages { + t.Logf("Message: %d, Error: %#v", i, msg) + } + } +} diff --git a/pkg/lint/rules/deprecations.go b/internal/chart/v3/lint/rules/deprecations.go similarity index 95% rename from pkg/lint/rules/deprecations.go rename to internal/chart/v3/lint/rules/deprecations.go index c6d635a5e..6f86bdbbd 100644 --- a/pkg/lint/rules/deprecations.go +++ b/internal/chart/v3/lint/rules/deprecations.go @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rules // import "helm.sh/helm/v4/pkg/lint/rules" +package rules // import "helm.sh/helm/v4/internal/chart/v3/lint/rules" import ( "fmt" "strconv" + "helm.sh/helm/v4/pkg/chart/common" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/endpoints/deprecation" kscheme "k8s.io/client-go/kubernetes/scheme" - - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) var ( @@ -47,7 +47,7 @@ func (e deprecatedAPIError) Error() string { return msg } -func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *chartutil.KubeVersion) error { +func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVersion) error { // if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation if resource.APIVersion == "" { return nil diff --git a/internal/chart/v3/lint/rules/deprecations_test.go b/internal/chart/v3/lint/rules/deprecations_test.go new file mode 100644 index 000000000..35e541e5c --- /dev/null +++ b/internal/chart/v3/lint/rules/deprecations_test.go @@ -0,0 +1,41 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules // import "helm.sh/helm/v4/internal/chart/v3/lint/rules" + +import "testing" + +func TestValidateNoDeprecations(t *testing.T) { + deprecated := &k8sYamlStruct{ + APIVersion: "extensions/v1beta1", + Kind: "Deployment", + } + err := validateNoDeprecations(deprecated, nil) + if err == nil { + t.Fatal("Expected deprecated extension to be flagged") + } + depErr := err.(deprecatedAPIError) + if depErr.Message == "" { + t.Fatalf("Expected error message to be non-blank: %v", err) + } + + if err := validateNoDeprecations(&k8sYamlStruct{ + APIVersion: "v1", + Kind: "Pod", + }, nil); err != nil { + t.Errorf("Expected a v1 Pod to not be deprecated") + } +} diff --git a/internal/chart/v3/lint/rules/template.go b/internal/chart/v3/lint/rules/template.go new file mode 100644 index 000000000..d4c62839f --- /dev/null +++ b/internal/chart/v3/lint/rules/template.go @@ -0,0 +1,348 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "strings" + + "k8s.io/apimachinery/pkg/api/validation" + apipath "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apimachinery/pkg/util/yaml" + + "helm.sh/helm/v4/internal/chart/v3/lint/support" + "helm.sh/helm/v4/internal/chart/v3/loader" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" + "helm.sh/helm/v4/pkg/engine" +) + +// Templates lints the templates in the Linter. +func Templates(linter *support.Linter, values map[string]interface{}, namespace string, _ bool) { + TemplatesWithKubeVersion(linter, values, namespace, nil) +} + +// TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. +func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) { + TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) +} + +// TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not. +func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { + fpath := "templates/" + templatesPath := filepath.Join(linter.ChartDir, fpath) + + // Templates directory is optional for now + templatesDirExists := linter.RunLinterRule(support.WarningSev, fpath, templatesDirExists(templatesPath)) + if !templatesDirExists { + return + } + + validTemplatesDir := linter.RunLinterRule(support.ErrorSev, fpath, validateTemplatesDir(templatesPath)) + if !validTemplatesDir { + return + } + + // Load chart and parse templates + chart, err := loader.Load(linter.ChartDir) + + chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !chartLoaded { + return + } + + options := common.ReleaseOptions{ + Name: "test-release", + Namespace: namespace, + } + + caps := common.DefaultCapabilities.Copy() + if kubeVersion != nil { + caps.KubeVersion = *kubeVersion + } + + // lint ignores import-values + // See https://github.com/helm/helm/issues/9658 + if err := chartutil.ProcessDependencies(chart, values); err != nil { + return + } + + cvals, err := util.CoalesceValues(chart, values) + if err != nil { + return + } + + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) + if err != nil { + linter.RunLinterRule(support.ErrorSev, fpath, err) + return + } + var e engine.Engine + e.LintMode = true + renderedContentMap, err := e.Render(chart, valuesToRender) + + renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err) + + if !renderOk { + return + } + + /* Iterate over all the templates to check: + - It is a .yaml file + - All the values in the template file is defined + - {{}} include | quote + - Generated content is a valid Yaml file + - Metadata.Namespace is not set + */ + for _, template := range chart.Templates { + fileName := template.Name + fpath = fileName + + linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName)) + + // We only apply the following lint rules to yaml files + if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" { + continue + } + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1463 + // Check that all the templates have a matching value + // linter.RunLinterRule(support.WarningSev, fpath, validateNoMissingValues(templatesPath, valuesToRender, preExecutedTemplate)) + + // NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1037 + // linter.RunLinterRule(support.WarningSev, fpath, validateQuotes(string(preExecutedTemplate))) + + renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)] + if strings.TrimSpace(renderedContent) != "" { + linter.RunLinterRule(support.WarningSev, fpath, validateTopIndentLevel(renderedContent)) + + decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096) + + // Lint all resources if the file contains multiple documents separated by --- + for { + // Even though k8sYamlStruct only defines a few fields, an error in any other + // key will be raised as well + var yamlStruct *k8sYamlStruct + + err := decoder.Decode(&yamlStruct) + if err == io.EOF { + break + } + + // If YAML linting fails here, it will always fail in the next block as well, so we should return here. + // fix https://github.com/helm/helm/issues/11391 + if !linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err)) { + return + } + if yamlStruct != nil { + // NOTE: set to warnings to allow users to support out-of-date kubernetes + // Refs https://github.com/helm/helm/issues/8596 + linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct)) + linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct, kubeVersion)) + + linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent)) + linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent)) + } + } + } + } +} + +// validateTopIndentLevel checks that the content does not start with an indent level > 0. +// +// This error can occur when a template accidentally inserts space. It can cause +// unpredictable errors depending on whether the text is normalized before being passed +// into the YAML parser. So we trap it here. +// +// See https://github.com/helm/helm/issues/8467 +func validateTopIndentLevel(content string) error { + // Read lines until we get to a non-empty one + scanner := bufio.NewScanner(bytes.NewBufferString(content)) + for scanner.Scan() { + line := scanner.Text() + // If line is empty, skip + if strings.TrimSpace(line) == "" { + continue + } + // If it starts with one or more spaces, this is an error + if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + return fmt.Errorf("document starts with an illegal indent: %q, which may cause parsing problems", line) + } + // Any other condition passes. + return nil + } + return scanner.Err() +} + +// Validation functions +func templatesDirExists(templatesPath string) error { + _, err := os.Stat(templatesPath) + if errors.Is(err, os.ErrNotExist) { + return errors.New("directory does not exist") + } + return nil +} + +func validateTemplatesDir(templatesPath string) error { + fi, err := os.Stat(templatesPath) + if err != nil { + return err + } + if !fi.IsDir() { + return errors.New("not a directory") + } + return nil +} + +func validateAllowedExtension(fileName string) error { + ext := filepath.Ext(fileName) + validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"} + + if slices.Contains(validExtensions, ext) { + return nil + } + + return fmt.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext) +} + +func validateYamlContent(err error) error { + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + return nil +} + +// validateMetadataName uses the correct validation function for the object +// Kind, or if not set, defaults to the standard definition of a subdomain in +// DNS (RFC 1123), used by most resources. +func validateMetadataName(obj *k8sYamlStruct) error { + fn := validateMetadataNameFunc(obj) + allErrs := field.ErrorList{} + for _, msg := range fn(obj.Metadata.Name, false) { + allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg)) + } + if len(allErrs) > 0 { + return fmt.Errorf("object name does not conform to Kubernetes naming requirements: %q: %w", obj.Metadata.Name, allErrs.ToAggregate()) + } + return nil +} + +// validateMetadataNameFunc will return a name validation function for the +// object kind, if defined below. +// +// Rules should match those set in the various api validations: +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/core/validation/validation.go#L205-L274 +// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/apps/validation/validation.go#L39 +// ... +// +// Implementing here to avoid importing k/k. +// +// If no mapping is defined, returns NameIsDNSSubdomain. This is used by object +// kinds that don't have special requirements, so is the most likely to work if +// new kinds are added. +func validateMetadataNameFunc(obj *k8sYamlStruct) validation.ValidateNameFunc { + switch strings.ToLower(obj.Kind) { + case "pod", "node", "secret", "endpoints", "resourcequota", // core + "controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps + "autoscaler", // autoscaler + "cronjob", "job", // batch + "lease", // coordination + "endpointslice", // discovery + "networkpolicy", "ingress", // networking + "podsecuritypolicy", // policy + "priorityclass", // scheduling + "podpreset", // settings + "storageclass", "volumeattachment", "csinode": // storage + return validation.NameIsDNSSubdomain + case "service": + return validation.NameIsDNS1035Label + case "namespace": + return validation.ValidateNamespaceName + case "serviceaccount": + return validation.ValidateServiceAccountName + case "certificatesigningrequest": + // No validation. + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140 + return func(_ string, _ bool) []string { return nil } + case "role", "clusterrole", "rolebinding", "clusterrolebinding": + // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34 + return func(name string, _ bool) []string { + return apipath.IsValidPathSegmentName(name) + } + default: + return validation.NameIsDNSSubdomain + } +} + +// validateMatchSelector ensures that template specs have a selector declared. +// See https://github.com/helm/helm/issues/1990 +func validateMatchSelector(yamlStruct *k8sYamlStruct, manifest string) error { + switch yamlStruct.Kind { + case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet": + // verify that matchLabels or matchExpressions is present + if !strings.Contains(manifest, "matchLabels") && !strings.Contains(manifest, "matchExpressions") { + return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name) + } + } + return nil +} + +func validateListAnnotations(yamlStruct *k8sYamlStruct, manifest string) error { + if yamlStruct.Kind == "List" { + m := struct { + Items []struct { + Metadata struct { + Annotations map[string]string + } + } + }{} + + if err := yaml.Unmarshal([]byte(manifest), &m); err != nil { + return validateYamlContent(err) + } + + for _, i := range m.Items { + if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok { + return errors.New("annotation 'helm.sh/resource-policy' within List objects are ignored") + } + } + } + return nil +} + +// k8sYamlStruct stubs a Kubernetes YAML file. +type k8sYamlStruct struct { + APIVersion string `json:"apiVersion"` + Kind string + Metadata k8sYamlMetadata +} + +type k8sYamlMetadata struct { + Namespace string + Name string +} diff --git a/internal/chart/v3/lint/rules/template_test.go b/internal/chart/v3/lint/rules/template_test.go new file mode 100644 index 000000000..40bcfa26b --- /dev/null +++ b/internal/chart/v3/lint/rules/template_test.go @@ -0,0 +1,441 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/internal/chart/v3/lint/support" + chartutil "helm.sh/helm/v4/internal/chart/v3/util" + "helm.sh/helm/v4/pkg/chart/common" +) + +const templateTestBasedir = "./testdata/albatross" + +func TestValidateAllowedExtension(t *testing.T) { + var failTest = []string{"/foo", "/test.toml"} + for _, test := range failTest { + err := validateAllowedExtension(test) + if err == nil || !strings.Contains(err.Error(), "Valid extensions are .yaml, .yml, .tpl, or .txt") { + t.Errorf("validateAllowedExtension('%s') to return \"Valid extensions are .yaml, .yml, .tpl, or .txt\", got no error", test) + } + } + var successTest = []string{"/foo.yaml", "foo.yaml", "foo.tpl", "/foo/bar/baz.yaml", "NOTES.txt"} + for _, test := range successTest { + err := validateAllowedExtension(test) + if err != nil { + t.Errorf("validateAllowedExtension('%s') to return no error but got \"%s\"", test, err.Error()) + } + } +} + +var values = map[string]interface{}{"nameOverride": "", "httpPort": 80} + +const namespace = "testNamespace" +const strict = false + +func TestTemplateParsing(t *testing.T) { + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected one error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "deliberateSyntaxError") { + t.Errorf("Unexpected error: %s", res[0]) + } +} + +var wrongTemplatePath = filepath.Join(templateTestBasedir, "templates", "fail.yaml") +var ignoredTemplatePath = filepath.Join(templateTestBasedir, "fail.yaml.ignored") + +// Test a template with all the existing features: +// namespaces, partial templates +func TestTemplateIntegrationHappyPath(t *testing.T) { + // Rename file so it gets ignored by the linter + os.Rename(wrongTemplatePath, ignoredTemplatePath) + defer os.Rename(ignoredTemplatePath, wrongTemplatePath) + + linter := support.Linter{ChartDir: templateTestBasedir} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 0 { + t.Fatalf("Expected no error, got %d, %v", len(res), res) + } +} + +func TestMultiTemplateFail(t *testing.T) { + linter := support.Linter{ChartDir: "./testdata/multi-template-fail"} + Templates(&linter, values, namespace, strict) + res := linter.Messages + + if len(res) != 1 { + t.Fatalf("Expected 1 error, got %d, %v", len(res), res) + } + + if !strings.Contains(res[0].Err.Error(), "object name does not conform to Kubernetes naming requirements") { + t.Errorf("Unexpected error: %s", res[0].Err) + } +} + +func TestValidateMetadataName(t *testing.T) { + tests := []struct { + obj *k8sYamlStruct + wantErr bool + }{ + // Most kinds use IsDNS1123Subdomain. + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "Pod", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "ServiceAccount", Metadata: k8sYamlMetadata{Name: "operator:sa"}}, true}, + + // Service uses IsDNS1035Label. + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "123baz"}}, true}, + {&k8sYamlStruct{Kind: "Service", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + + // Namespace uses IsDNS1123Label. + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, true}, + {&k8sYamlStruct{Kind: "Namespace", Metadata: k8sYamlMetadata{Name: "foo-bar"}}, false}, + + // CertificateSigningRequest has no validation. + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: ""}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "CertificateSigningRequest", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, false}, + + // RBAC uses path validation. + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "Role", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "foo.bar"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator/role"}}, true}, + {&k8sYamlStruct{Kind: "ClusterRole", Metadata: k8sYamlMetadata{Name: "operator%role"}}, true}, + {&k8sYamlStruct{Kind: "RoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + {&k8sYamlStruct{Kind: "ClusterRoleBinding", Metadata: k8sYamlMetadata{Name: "operator:role"}}, false}, + + // Unknown Kind + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: ""}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.bar1234baz.seventyone"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "FOO"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "123baz"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "foo.BAR.baz"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one-two"}}, false}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "-two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "one_two"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "a..b"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "%^&#$%*@^*@&#^"}}, true}, + {&k8sYamlStruct{Kind: "FutureKind", Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + + // No kind + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "foo"}}, false}, + {&k8sYamlStruct{Metadata: k8sYamlMetadata{Name: "operator:pod"}}, true}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%s", tt.obj.Kind, tt.obj.Metadata.Name), func(t *testing.T) { + if err := validateMetadataName(tt.obj); (err != nil) != tt.wantErr { + t.Errorf("validateMetadataName() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDeprecatedAPIFails(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "failapi", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/baddeployment.yaml", + Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), + }, + { + Name: "templates/goodsecret.yaml", + Data: []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: goodsecret"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l != 1 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 1 lint error, got %d", l) + } + + err := linter.Messages[0].Err.(deprecatedAPIError) + if err.Deprecated != "apps/v1beta1 Deployment" { + t.Errorf("Surprised to learn that %q is deprecated", err.Deprecated) + } +} + +const manifest = `apiVersion: v1 +kind: ConfigMap +metadata: + name: foo +data: + myval1: {{default "val" .Values.mymap.key1 }} + myval2: {{default "val" .Values.mymap.key2 }} +` + +// TestStrictTemplateParsingMapError is a regression test. +// +// The template engine should not produce an error when a map in values.yaml does +// not contain all possible keys. +// +// See https://github.com/helm/helm/issues/7483 +func TestStrictTemplateParsingMapError(t *testing.T) { + + ch := chart.Chart{ + Metadata: &chart.Metadata{ + Name: "regression7483", + APIVersion: "v2", + Version: "0.1.0", + }, + Values: map[string]interface{}{ + "mymap": map[string]string{ + "key1": "val1", + }, + }, + Templates: []*common.File{ + { + Name: "templates/configmap.yaml", + Data: []byte(manifest), + }, + }, + } + dir := t.TempDir() + if err := chartutil.SaveDir(&ch, dir); err != nil { + t.Fatal(err) + } + linter := &support.Linter{ + ChartDir: filepath.Join(dir, ch.Metadata.Name), + } + Templates(linter, ch.Values, namespace, strict) + if len(linter.Messages) != 0 { + t.Errorf("expected zero messages, got %d", len(linter.Messages)) + for i, msg := range linter.Messages { + t.Logf("Message %d: %q", i, msg) + } + } +} + +func TestValidateMatchSelector(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "apps/v1", + Kind: "Deployment", + Metadata: k8sYamlMetadata{ + Name: "mydeployment", + }, + } + manifest := ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchExpressions: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err != nil { + t.Error(err) + } + manifest = ` + apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.14.2 + ` + if err := validateMatchSelector(md, manifest); err == nil { + t.Error("expected Deployment with no selector to fail") + } +} + +func TestValidateTopIndentLevel(t *testing.T) { + for doc, shouldFail := range map[string]bool{ + // Should not fail + "\n\n\n\t\n \t\n": false, + "apiVersion:foo\n bar:baz": false, + "\n\n\napiVersion:foo\n\n\n": false, + // Should fail + " apiVersion:foo": true, + "\n\n apiVersion:foo\n\n": true, + } { + if err := validateTopIndentLevel(doc); (err == nil) == shouldFail { + t.Errorf("Expected %t for %q", shouldFail, doc) + } + } + +} + +// TestEmptyWithCommentsManifests checks the lint is not failing against empty manifests that contains only comments +// See https://github.com/helm/helm/issues/8621 +func TestEmptyWithCommentsManifests(t *testing.T) { + mychart := chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "emptymanifests", + Version: "0.1.0", + Icon: "satisfy-the-linting-gods.gif", + }, + Templates: []*common.File{ + { + Name: "templates/empty-with-comments.yaml", + Data: []byte("#@formatter:off\n"), + }, + }, + } + tmpdir := t.TempDir() + + if err := chartutil.SaveDir(&mychart, tmpdir); err != nil { + t.Fatal(err) + } + + linter := support.Linter{ChartDir: filepath.Join(tmpdir, mychart.Name())} + Templates(&linter, values, namespace, strict) + if l := len(linter.Messages); l > 0 { + for i, msg := range linter.Messages { + t.Logf("Message %d: %s", i, msg) + } + t.Fatalf("Expected 0 lint errors, got %d", l) + } +} +func TestValidateListAnnotations(t *testing.T) { + md := &k8sYamlStruct{ + APIVersion: "v1", + Kind: "List", + Metadata: k8sYamlMetadata{ + Name: "list", + }, + } + manifest := ` +apiVersion: v1 +kind: List +items: + - apiVersion: v1 + kind: ConfigMap + metadata: + annotations: + helm.sh/resource-policy: keep +` + + if err := validateListAnnotations(md, manifest); err == nil { + t.Fatal("expected list with nested keep annotations to fail") + } + + manifest = ` +apiVersion: v1 +kind: List +metadata: + annotations: + helm.sh/resource-policy: keep +items: + - apiVersion: v1 + kind: ConfigMap +` + + if err := validateListAnnotations(md, manifest); err != nil { + t.Fatalf("List objects keep annotations should pass. got: %s", err) + } +} diff --git a/internal/chart/v3/lint/rules/testdata/albatross/Chart.yaml b/internal/chart/v3/lint/rules/testdata/albatross/Chart.yaml new file mode 100644 index 000000000..5e1ed515c --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/albatross/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: albatross +description: testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/albatross/templates/_helpers.tpl b/internal/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl similarity index 100% rename from pkg/lint/rules/testdata/albatross/templates/_helpers.tpl rename to internal/chart/v3/lint/rules/testdata/albatross/templates/_helpers.tpl diff --git a/pkg/lint/rules/testdata/albatross/templates/fail.yaml b/internal/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml similarity index 100% rename from pkg/lint/rules/testdata/albatross/templates/fail.yaml rename to internal/chart/v3/lint/rules/testdata/albatross/templates/fail.yaml diff --git a/pkg/lint/rules/testdata/albatross/templates/svc.yaml b/internal/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml similarity index 100% rename from pkg/lint/rules/testdata/albatross/templates/svc.yaml rename to internal/chart/v3/lint/rules/testdata/albatross/templates/svc.yaml diff --git a/pkg/lint/rules/testdata/albatross/values.yaml b/internal/chart/v3/lint/rules/testdata/albatross/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/albatross/values.yaml rename to internal/chart/v3/lint/rules/testdata/albatross/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml b/internal/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml new file mode 100644 index 000000000..8a598473b --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/anotherbadchartfile/Chart.yaml @@ -0,0 +1,15 @@ +name: "some-chart" +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 72445e2 +home: "" +type: application +appVersion: 72225e2 +icon: "https://some-url.com/icon.jpeg" +dependencies: + - name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/lint/rules/testdata/badchartfile/Chart.yaml b/internal/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/badchartfile/Chart.yaml rename to internal/chart/v3/lint/rules/testdata/badchartfile/Chart.yaml diff --git a/pkg/lint/rules/testdata/badchartfile/values.yaml b/internal/chart/v3/lint/rules/testdata/badchartfile/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/badchartfile/values.yaml rename to internal/chart/v3/lint/rules/testdata/badchartfile/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/badchartname/Chart.yaml b/internal/chart/v3/lint/rules/testdata/badchartname/Chart.yaml new file mode 100644 index 000000000..41f452354 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/badchartname/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: "../badchartname" +type: application diff --git a/pkg/lint/rules/testdata/badchartname/values.yaml b/internal/chart/v3/lint/rules/testdata/badchartname/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/badchartname/values.yaml rename to internal/chart/v3/lint/rules/testdata/badchartname/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml b/internal/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml new file mode 100644 index 000000000..3bf007393 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/badcrdfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: badcrdfile +type: application +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml b/internal/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml similarity index 100% rename from pkg/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml rename to internal/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml diff --git a/pkg/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml b/internal/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml similarity index 100% rename from pkg/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml rename to internal/chart/v3/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml diff --git a/pkg/lint/rules/testdata/badcrdfile/templates/.gitkeep b/internal/chart/v3/lint/rules/testdata/badcrdfile/templates/.gitkeep similarity index 100% rename from pkg/lint/rules/testdata/badcrdfile/templates/.gitkeep rename to internal/chart/v3/lint/rules/testdata/badcrdfile/templates/.gitkeep diff --git a/pkg/lint/rules/testdata/badcrdfile/values.yaml b/internal/chart/v3/lint/rules/testdata/badcrdfile/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/badcrdfile/values.yaml rename to internal/chart/v3/lint/rules/testdata/badcrdfile/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml b/internal/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml new file mode 100644 index 000000000..aace27e21 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/badvaluesfile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +name: badvaluesfile +description: A Helm chart for Kubernetes +version: 0.0.1 +home: "" +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml b/internal/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml similarity index 100% rename from pkg/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml rename to internal/chart/v3/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml diff --git a/pkg/lint/rules/testdata/badvaluesfile/values.yaml b/internal/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/badvaluesfile/values.yaml rename to internal/chart/v3/lint/rules/testdata/badvaluesfile/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/goodone/Chart.yaml b/internal/chart/v3/lint/rules/testdata/goodone/Chart.yaml new file mode 100644 index 000000000..bf8f5e309 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/goodone/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v3 +name: goodone +description: good testing chart +version: 199.44.12345-Alpha.1+cafe009 +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/goodone/crds/test-crd.yaml b/internal/chart/v3/lint/rules/testdata/goodone/crds/test-crd.yaml similarity index 100% rename from pkg/lint/rules/testdata/goodone/crds/test-crd.yaml rename to internal/chart/v3/lint/rules/testdata/goodone/crds/test-crd.yaml diff --git a/pkg/lint/rules/testdata/goodone/templates/goodone.yaml b/internal/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml similarity index 100% rename from pkg/lint/rules/testdata/goodone/templates/goodone.yaml rename to internal/chart/v3/lint/rules/testdata/goodone/templates/goodone.yaml diff --git a/pkg/lint/rules/testdata/goodone/values.yaml b/internal/chart/v3/lint/rules/testdata/goodone/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/goodone/values.yaml rename to internal/chart/v3/lint/rules/testdata/goodone/values.yaml diff --git a/pkg/lint/rules/testdata/invalidchartfile/Chart.yaml b/internal/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/invalidchartfile/Chart.yaml rename to internal/chart/v3/lint/rules/testdata/invalidchartfile/Chart.yaml diff --git a/pkg/lint/rules/testdata/invalidchartfile/values.yaml b/internal/chart/v3/lint/rules/testdata/invalidchartfile/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/invalidchartfile/values.yaml rename to internal/chart/v3/lint/rules/testdata/invalidchartfile/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml b/internal/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml new file mode 100644 index 000000000..0f6d1ee98 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/invalidcrdsdir/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +description: A Helm chart for Kubernetes +version: 0.1.0 +name: invalidcrdsdir +type: application +icon: http://riverrun.io diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/crds b/internal/chart/v3/lint/rules/testdata/invalidcrdsdir/crds similarity index 100% rename from pkg/lint/rules/testdata/invalidcrdsdir/crds rename to internal/chart/v3/lint/rules/testdata/invalidcrdsdir/crds diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/values.yaml b/internal/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/invalidcrdsdir/values.yaml rename to internal/chart/v3/lint/rules/testdata/invalidcrdsdir/values.yaml diff --git a/pkg/lint/rules/testdata/malformed-template/.helmignore b/internal/chart/v3/lint/rules/testdata/malformed-template/.helmignore similarity index 100% rename from pkg/lint/rules/testdata/malformed-template/.helmignore rename to internal/chart/v3/lint/rules/testdata/malformed-template/.helmignore diff --git a/internal/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml b/internal/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml new file mode 100644 index 000000000..d46b98cb5 --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/malformed-template/Chart.yaml @@ -0,0 +1,25 @@ +apiVersion: v3 +name: test +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" +icon: https://riverrun.io \ No newline at end of file diff --git a/pkg/lint/rules/testdata/malformed-template/templates/bad.yaml b/internal/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml similarity index 100% rename from pkg/lint/rules/testdata/malformed-template/templates/bad.yaml rename to internal/chart/v3/lint/rules/testdata/malformed-template/templates/bad.yaml diff --git a/pkg/lint/rules/testdata/malformed-template/values.yaml b/internal/chart/v3/lint/rules/testdata/malformed-template/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/malformed-template/values.yaml rename to internal/chart/v3/lint/rules/testdata/malformed-template/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml b/internal/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml new file mode 100644 index 000000000..bfb580bea --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/multi-template-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v3 +name: multi-template-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml b/internal/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml similarity index 100% rename from pkg/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml rename to internal/chart/v3/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml diff --git a/internal/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml b/internal/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml new file mode 100644 index 000000000..2a29c33fa --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/v3-fail/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v3 +name: v3-fail +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application and it is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl b/internal/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/templates/_helpers.tpl rename to internal/chart/v3/lint/rules/testdata/v3-fail/templates/_helpers.tpl diff --git a/pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml b/internal/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/templates/deployment.yaml rename to internal/chart/v3/lint/rules/testdata/v3-fail/templates/deployment.yaml diff --git a/pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml b/internal/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/templates/ingress.yaml rename to internal/chart/v3/lint/rules/testdata/v3-fail/templates/ingress.yaml diff --git a/pkg/lint/rules/testdata/v3-fail/templates/service.yaml b/internal/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/templates/service.yaml rename to internal/chart/v3/lint/rules/testdata/v3-fail/templates/service.yaml diff --git a/pkg/lint/rules/testdata/v3-fail/values.yaml b/internal/chart/v3/lint/rules/testdata/v3-fail/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/values.yaml rename to internal/chart/v3/lint/rules/testdata/v3-fail/values.yaml diff --git a/internal/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml new file mode 100644 index 000000000..fa15eabaf --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/withsubchart/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v3 +name: withsubchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" +icon: http://riverrun.io + +dependencies: + - name: subchart + version: 0.1.16 + repository: "file://../subchart" + import-values: + - child: subchart + parent: subchart + diff --git a/internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml new file mode 100644 index 000000000..35b13e70d --- /dev/null +++ b/internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v3 +name: subchart +description: A Helm chart for Kubernetes +type: application +version: 0.1.0 +appVersion: "1.16.0" diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml rename to internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/charts/subchart/values.yaml rename to internal/chart/v3/lint/rules/testdata/withsubchart/charts/subchart/values.yaml diff --git a/pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/templates/mainchart.yaml rename to internal/chart/v3/lint/rules/testdata/withsubchart/templates/mainchart.yaml diff --git a/pkg/lint/rules/testdata/withsubchart/values.yaml b/internal/chart/v3/lint/rules/testdata/withsubchart/values.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/values.yaml rename to internal/chart/v3/lint/rules/testdata/withsubchart/values.yaml diff --git a/internal/chart/v3/lint/rules/values.go b/internal/chart/v3/lint/rules/values.go new file mode 100644 index 000000000..adf2e2c52 --- /dev/null +++ b/internal/chart/v3/lint/rules/values.go @@ -0,0 +1,79 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "fmt" + "os" + "path/filepath" + + "helm.sh/helm/v4/internal/chart/v3/lint/support" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" +) + +// ValuesWithOverrides tests the values.yaml file. +// +// If a schema is present in the chart, values are tested against that. Otherwise, +// they are only tested for well-formedness. +// +// If additional values are supplied, they are coalesced into the values in values.yaml. +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) { + file := "values.yaml" + vf := filepath.Join(linter.ChartDir, file) + fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) + + if !fileExists { + return + } + + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides)) +} + +func validateValuesFileExistence(valuesPath string) error { + _, err := os.Stat(valuesPath) + if err != nil { + return fmt.Errorf("file does not exist") + } + return nil +} + +func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { + values, err := common.ReadValuesFile(valuesPath) + if err != nil { + return fmt.Errorf("unable to parse YAML: %w", err) + } + + // Helm 3.0.0 carried over the values linting from Helm 2.x, which only tests the top + // level values against the top-level expectations. Subchart values are not linted. + // We could change that. For now, though, we retain that strategy, and thus can + // coalesce tables (like reuse-values does) instead of doing the full chart + // CoalesceValues + coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues = util.CoalesceTables(coalescedValues, values) + + ext := filepath.Ext(valuesPath) + schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" + schema, err := os.ReadFile(schemaPath) + if len(schema) == 0 { + return nil + } + if err != nil { + return err + } + return util.ValidateAgainstSingleSchema(coalescedValues, schema) +} diff --git a/pkg/lint/rules/values_test.go b/internal/chart/v3/lint/rules/values_test.go similarity index 100% rename from pkg/lint/rules/values_test.go rename to internal/chart/v3/lint/rules/values_test.go diff --git a/internal/chart/v3/util/errors_test.go b/internal/chart/v3/lint/support/doc.go similarity index 67% rename from internal/chart/v3/util/errors_test.go rename to internal/chart/v3/lint/support/doc.go index b8ae86384..2d54a9b7d 100644 --- a/internal/chart/v3/util/errors_test.go +++ b/internal/chart/v3/lint/support/doc.go @@ -14,24 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util - -import ( - "testing" -) - -func TestErrorNoTableDoesNotPanic(t *testing.T) { - x := "empty" - - y := ErrNoTable{x} - - t.Logf("error is: %s", y) -} - -func TestErrorNoValueDoesNotPanic(t *testing.T) { - x := "empty" - - y := ErrNoValue{x} +/* +Package support contains tools for linting charts. - t.Logf("error is: %s", y) -} +Linting is the process of testing charts for errors or warnings regarding +formatting, compilation, or standards compliance. +*/ +package support // import "helm.sh/helm/v4/internal/chart/v3/lint/support" diff --git a/pkg/lint/support/message.go b/internal/chart/v3/lint/support/message.go similarity index 100% rename from pkg/lint/support/message.go rename to internal/chart/v3/lint/support/message.go diff --git a/pkg/lint/support/message_test.go b/internal/chart/v3/lint/support/message_test.go similarity index 100% rename from pkg/lint/support/message_test.go rename to internal/chart/v3/lint/support/message_test.go diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go index 30bafdad4..2959fc71d 100644 --- a/internal/chart/v3/loader/load.go +++ b/internal/chart/v3/loader/load.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/yaml" chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/chart/common" ) // ChartLoader loads a chart. @@ -79,7 +80,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { // do not rely on assumed ordering of files in the chart and crash // if Chart.yaml was not coming early enough to initialize metadata for _, f := range files { - c.Raw = append(c.Raw, &chart.File{Name: f.Name, Data: f.Data}) + c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) if f.Name == "Chart.yaml" { if c.Metadata == nil { c.Metadata = new(chart.Metadata) @@ -115,10 +116,10 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { c.Schema = f.Data case strings.HasPrefix(f.Name, "templates/"): - c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data}) + c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) case strings.HasPrefix(f.Name, "charts/"): if filepath.Ext(f.Name) == ".prov" { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) continue } @@ -126,7 +127,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { cname := strings.SplitN(fname, "/", 2)[0] subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) default: - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) } } diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index e770923ff..1d8ca836a 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -31,6 +31,7 @@ import ( "time" chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/chart/common" ) func TestLoadDir(t *testing.T) { @@ -491,7 +492,7 @@ foo: } } -func TestMergeValues(t *testing.T) { +func TestMergeValuesV3(t *testing.T) { nestedMap := map[string]interface{}{ "foo": "bar", "baz": map[string]string{ @@ -701,7 +702,7 @@ func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { } } -func verifyBomStripped(t *testing.T, files []*chart.File) { +func verifyBomStripped(t *testing.T, files []*common.File) { t.Helper() for _, file := range files { if bytes.HasPrefix(file.Data, utf8bom) { diff --git a/internal/chart/v3/util/capabilities.go b/internal/chart/v3/util/capabilities.go deleted file mode 100644 index 23b6d46fa..000000000 --- a/internal/chart/v3/util/capabilities.go +++ /dev/null @@ -1,122 +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 util - -import ( - "fmt" - "slices" - "strconv" - - "github.com/Masterminds/semver/v3" - "k8s.io/client-go/kubernetes/scheme" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" - - helmversion "helm.sh/helm/v4/internal/version" -) - -var ( - // The Kubernetes version can be set by LDFLAGS. In order to do that the value - // must be a string. - k8sVersionMajor = "1" - k8sVersionMinor = "20" - - // DefaultVersionSet is the default version set, which includes only Core V1 ("v1"). - DefaultVersionSet = allKnownVersions() - - // DefaultCapabilities is the default set of capabilities. - DefaultCapabilities = &Capabilities{ - KubeVersion: KubeVersion{ - Version: fmt.Sprintf("v%s.%s.0", k8sVersionMajor, k8sVersionMinor), - Major: k8sVersionMajor, - Minor: k8sVersionMinor, - }, - APIVersions: DefaultVersionSet, - HelmVersion: helmversion.Get(), - } -) - -// Capabilities describes the capabilities of the Kubernetes cluster. -type Capabilities struct { - // KubeVersion is the Kubernetes version. - KubeVersion KubeVersion - // APIVersions are supported Kubernetes API versions. - APIVersions VersionSet - // HelmVersion is the build information for this helm version - HelmVersion helmversion.BuildInfo -} - -func (capabilities *Capabilities) Copy() *Capabilities { - return &Capabilities{ - KubeVersion: capabilities.KubeVersion, - APIVersions: capabilities.APIVersions, - HelmVersion: capabilities.HelmVersion, - } -} - -// KubeVersion is the Kubernetes version. -type KubeVersion struct { - Version string // Kubernetes version - Major string // Kubernetes major version - Minor string // Kubernetes minor version -} - -// String implements fmt.Stringer -func (kv *KubeVersion) String() string { return kv.Version } - -// GitVersion returns the Kubernetes version string. -// -// Deprecated: use KubeVersion.Version. -func (kv *KubeVersion) GitVersion() string { return kv.Version } - -// ParseKubeVersion parses kubernetes version from string -func ParseKubeVersion(version string) (*KubeVersion, error) { - sv, err := semver.NewVersion(version) - if err != nil { - return nil, err - } - return &KubeVersion{ - Version: "v" + sv.String(), - Major: strconv.FormatUint(sv.Major(), 10), - Minor: strconv.FormatUint(sv.Minor(), 10), - }, nil -} - -// VersionSet is a set of Kubernetes API versions. -type VersionSet []string - -// Has returns true if the version string is in the set. -// -// vs.Has("apps/v1") -func (v VersionSet) Has(apiVersion string) bool { - return slices.Contains(v, apiVersion) -} - -func allKnownVersions() VersionSet { - // We should register the built in extension APIs as well so CRDs are - // supported in the default version set. This has caused problems with `helm - // template` in the past, so let's be safe - apiextensionsv1beta1.AddToScheme(scheme.Scheme) - apiextensionsv1.AddToScheme(scheme.Scheme) - - groups := scheme.Scheme.PrioritizedVersionsAllGroups() - vs := make(VersionSet, 0, len(groups)) - for _, gv := range groups { - vs = append(vs, gv.String()) - } - return vs -} diff --git a/internal/chart/v3/util/capabilities_test.go b/internal/chart/v3/util/capabilities_test.go deleted file mode 100644 index aa9be9db8..000000000 --- a/internal/chart/v3/util/capabilities_test.go +++ /dev/null @@ -1,84 +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 util - -import ( - "testing" -) - -func TestVersionSet(t *testing.T) { - vs := VersionSet{"v1", "apps/v1"} - if d := len(vs); d != 2 { - t.Errorf("Expected 2 versions, got %d", d) - } - - if !vs.Has("apps/v1") { - t.Error("Expected to find apps/v1") - } - - if vs.Has("Spanish/inquisition") { - t.Error("No one expects the Spanish/inquisition") - } -} - -func TestDefaultVersionSet(t *testing.T) { - if !DefaultVersionSet.Has("v1") { - t.Error("Expected core v1 version set") - } -} - -func TestDefaultCapabilities(t *testing.T) { - kv := DefaultCapabilities.KubeVersion - if kv.String() != "v1.20.0" { - t.Errorf("Expected default KubeVersion.String() to be v1.20.0, got %q", kv.String()) - } - if kv.Version != "v1.20.0" { - t.Errorf("Expected default KubeVersion.Version to be v1.20.0, got %q", kv.Version) - } - if kv.GitVersion() != "v1.20.0" { - t.Errorf("Expected default KubeVersion.GitVersion() to be v1.20.0, got %q", kv.Version) - } - if kv.Major != "1" { - t.Errorf("Expected default KubeVersion.Major to be 1, got %q", kv.Major) - } - if kv.Minor != "20" { - t.Errorf("Expected default KubeVersion.Minor to be 20, got %q", kv.Minor) - } -} - -func TestDefaultCapabilitiesHelmVersion(t *testing.T) { - hv := DefaultCapabilities.HelmVersion - - if hv.Version != "v4.0" { - t.Errorf("Expected default HelmVersion to be v4.0, got %q", hv.Version) - } -} - -func TestParseKubeVersion(t *testing.T) { - kv, err := ParseKubeVersion("v1.16.0") - if err != nil { - t.Errorf("Expected v1.16.0 to parse successfully") - } - if kv.Version != "v1.16.0" { - t.Errorf("Expected parsed KubeVersion.Version to be v1.16.0, got %q", kv.String()) - } - if kv.Major != "1" { - t.Errorf("Expected parsed KubeVersion.Major to be 1, got %q", kv.Major) - } - if kv.Minor != "16" { - t.Errorf("Expected parsed KubeVersion.Minor to be 16, got %q", kv.Minor) - } -} diff --git a/internal/chart/v3/util/coalesce.go b/internal/chart/v3/util/coalesce.go deleted file mode 100644 index caea2e119..000000000 --- a/internal/chart/v3/util/coalesce.go +++ /dev/null @@ -1,308 +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 util - -import ( - "fmt" - "log" - "maps" - - "github.com/mitchellh/copystructure" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -func concatPrefix(a, b string) string { - if a == "" { - return b - } - return fmt.Sprintf("%s.%s", a, b) -} - -// CoalesceValues coalesces all of the values in a chart (and its subcharts). -// -// Values are coalesced together using the following rules: -// -// - Values in a higher level chart always override values in a lower-level -// dependency chart -// - Scalar values and arrays are replaced, maps are merged -// - A chart has access to all of the variables for it, as well as all of -// the values destined for its dependencies. -func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { - valsCopy, err := copyValues(vals) - if err != nil { - return vals, err - } - return coalesce(log.Printf, chrt, valsCopy, "", false) -} - -// MergeValues is used to merge the values in a chart and its subcharts. This -// is different from Coalescing as nil/null values are preserved. -// -// Values are coalesced together using the following rules: -// -// - Values in a higher level chart always override values in a lower-level -// dependency chart -// - Scalar values and arrays are replaced, maps are merged -// - A chart has access to all of the variables for it, as well as all of -// the values destined for its dependencies. -// -// Retaining Nils is useful when processes early in a Helm action or business -// logic need to retain them for when Coalescing will happen again later in the -// business logic. -func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { - valsCopy, err := copyValues(vals) - if err != nil { - return vals, err - } - return coalesce(log.Printf, chrt, valsCopy, "", true) -} - -func copyValues(vals map[string]interface{}) (Values, error) { - v, err := copystructure.Copy(vals) - if err != nil { - return vals, err - } - - valsCopy := v.(map[string]interface{}) - // if we have an empty map, make sure it is initialized - if valsCopy == nil { - valsCopy = make(map[string]interface{}) - } - - return valsCopy, nil -} - -type printFn func(format string, v ...interface{}) - -// coalesce coalesces the dest values and the chart values, giving priority to the dest values. -// -// This is a helper function for CoalesceValues and MergeValues. -// -// Note, the merge argument specifies whether this is being used by MergeValues -// or CoalesceValues. Coalescing removes null values and their keys in some -// situations while merging keeps the null values. -func coalesce(printf printFn, ch *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { - coalesceValues(printf, ch, dest, prefix, merge) - return coalesceDeps(printf, ch, dest, prefix, merge) -} - -// coalesceDeps coalesces the dependencies of the given chart. -func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { - for _, subchart := range chrt.Dependencies() { - if c, ok := dest[subchart.Name()]; !ok { - // If dest doesn't already have the key, create it. - dest[subchart.Name()] = make(map[string]interface{}) - } else if !istable(c) { - return dest, fmt.Errorf("type mismatch on %s: %t", subchart.Name(), c) - } - if dv, ok := dest[subchart.Name()]; ok { - dvmap := dv.(map[string]interface{}) - subPrefix := concatPrefix(prefix, chrt.Metadata.Name) - // Get globals out of dest and merge them into dvmap. - coalesceGlobals(printf, dvmap, dest, subPrefix, merge) - // Now coalesce the rest of the values. - var err error - dest[subchart.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) - if err != nil { - return dest, err - } - } - } - return dest, nil -} - -// coalesceGlobals copies the globals out of src and merges them into dest. -// -// For convenience, returns dest. -func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) { - var dg, sg map[string]interface{} - - if destglob, ok := dest[GlobalKey]; !ok { - dg = make(map[string]interface{}) - } else if dg, ok = destglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because destination %s is not a table.", GlobalKey) - return - } - - if srcglob, ok := src[GlobalKey]; !ok { - sg = make(map[string]interface{}) - } else if sg, ok = srcglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because source %s is not a table.", GlobalKey) - return - } - - // EXPERIMENTAL: In the past, we have disallowed globals to test tables. This - // reverses that decision. It may somehow be possible to introduce a loop - // here, but I haven't found a way. So for the time being, let's allow - // tables in globals. - for key, val := range sg { - if istable(val) { - vv := copyMap(val.(map[string]interface{})) - if destv, ok := dg[key]; !ok { - // Here there is no merge. We're just adding. - dg[key] = vv - } else { - if destvmap, ok := destv.(map[string]interface{}); !ok { - printf("Conflict: cannot merge map onto non-map for %q. Skipping.", key) - } else { - // Basically, we reverse order of coalesce here to merge - // top-down. - subPrefix := concatPrefix(prefix, key) - // In this location coalesceTablesFullKey should always have - // merge set to true. The output of coalesceGlobals is run - // through coalesce where any nils will be removed. - coalesceTablesFullKey(printf, vv, destvmap, subPrefix, true) - dg[key] = vv - } - } - } else if dv, ok := dg[key]; ok && istable(dv) { - // It's not clear if this condition can actually ever trigger. - printf("key %s is table. Skipping", key) - } else { - // TODO: Do we need to do any additional checking on the value? - dg[key] = val - } - } - dest[GlobalKey] = dg -} - -func copyMap(src map[string]interface{}) map[string]interface{} { - m := make(map[string]interface{}, len(src)) - maps.Copy(m, src) - return m -} - -// coalesceValues builds up a values map for a particular chart. -// -// Values in v will override the values in the chart. -func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, prefix string, merge bool) { - subPrefix := concatPrefix(prefix, c.Metadata.Name) - - // Using c.Values directly when coalescing a table can cause problems where - // the original c.Values is altered. Creating a deep copy stops the problem. - // This section is fault-tolerant as there is no ability to return an error. - valuesCopy, err := copystructure.Copy(c.Values) - var vc map[string]interface{} - var ok bool - if err != nil { - // If there is an error something is wrong with copying c.Values it - // means there is a problem in the deep copying package or something - // wrong with c.Values. In this case we will use c.Values and report - // an error. - printf("warning: unable to copy values, err: %s", err) - vc = c.Values - } else { - vc, ok = valuesCopy.(map[string]interface{}) - if !ok { - // c.Values has a map[string]interface{} structure. If the copy of - // it cannot be treated as map[string]interface{} there is something - // strangely wrong. Log it and use c.Values - printf("warning: unable to convert values copy to values type") - vc = c.Values - } - } - - for key, val := range vc { - if value, ok := v[key]; ok { - if value == nil && !merge { - // When the YAML value is null and we are coalescing instead of - // merging, we remove the value's key. - // This allows Helm's various sources of values (value files or --set) to - // remove incompatible keys from any previous chart, file, or set values. - delete(v, key) - } else if dest, ok := value.(map[string]interface{}); ok { - // if v[key] is a table, merge nv's val table into v[key]. - src, ok := val.(map[string]interface{}) - if !ok { - // If the original value is nil, there is nothing to coalesce, so we don't print - // the warning - if val != nil { - printf("warning: skipped value for %s.%s: Not a table.", subPrefix, key) - } - } else { - // If the key is a child chart, coalesce tables with Merge set to true - merge := childChartMergeTrue(c, key, merge) - - // Because v has higher precedence than nv, dest values override src - // values. - coalesceTablesFullKey(printf, dest, src, concatPrefix(subPrefix, key), merge) - } - } - } else { - // If the key is not in v, copy it from nv. - v[key] = val - } - } -} - -func childChartMergeTrue(chrt *chart.Chart, key string, merge bool) bool { - for _, subchart := range chrt.Dependencies() { - if subchart.Name() == key { - return true - } - } - return merge -} - -// CoalesceTables merges a source map into a destination map. -// -// dest is considered authoritative. -func CoalesceTables(dst, src map[string]interface{}) map[string]interface{} { - return coalesceTablesFullKey(log.Printf, dst, src, "", false) -} - -func MergeTables(dst, src map[string]interface{}) map[string]interface{} { - return coalesceTablesFullKey(log.Printf, dst, src, "", true) -} - -// coalesceTablesFullKey merges a source map into a destination map. -// -// dest is considered authoritative. -func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, prefix string, merge bool) map[string]interface{} { - // When --reuse-values is set but there are no modifications yet, return new values - if src == nil { - return dst - } - if dst == nil { - return src - } - for key, val := range dst { - if val == nil { - src[key] = nil - } - } - // Because dest has higher precedence than src, dest values override src - // values. - for key, val := range src { - fullkey := concatPrefix(prefix, key) - if dv, ok := dst[key]; ok && !merge && dv == nil { - delete(dst, key) - } else if !ok { - dst[key] = val - } else if istable(val) { - if istable(dv) { - coalesceTablesFullKey(printf, dv.(map[string]interface{}), val.(map[string]interface{}), fullkey, merge) - } else { - printf("warning: cannot overwrite table with non table for %s (%v)", fullkey, val) - } - } else if istable(dv) && val != nil { - printf("warning: destination for %s is a table. Ignoring non-table value (%v)", fullkey, val) - } - } - return dst -} diff --git a/internal/chart/v3/util/coalesce_test.go b/internal/chart/v3/util/coalesce_test.go deleted file mode 100644 index 4770b601d..000000000 --- a/internal/chart/v3/util/coalesce_test.go +++ /dev/null @@ -1,723 +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 util - -import ( - "encoding/json" - "fmt" - "maps" - "testing" - - "github.com/stretchr/testify/assert" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -// ref: http://www.yaml.org/spec/1.2/spec.html#id2803362 -var testCoalesceValuesYaml = []byte(` -top: yup -bottom: null -right: Null -left: NULL -front: ~ -back: "" -nested: - boat: null - -global: - name: Ishmael - subject: Queequeg - nested: - boat: true - -pequod: - boat: null - global: - name: Stinky - harpooner: Tashtego - nested: - boat: false - sail: true - foo2: null - ahab: - scope: whale - boat: null - nested: - foo: true - boat: null - object: null -`) - -func withDeps(c *chart.Chart, deps ...*chart.Chart) *chart.Chart { - c.AddDependency(deps...) - return c -} - -func TestCoalesceValues(t *testing.T) { - is := assert.New(t) - - c := withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "moby"}, - Values: map[string]interface{}{ - "back": "exists", - "bottom": "exists", - "front": "exists", - "left": "exists", - "name": "moby", - "nested": map[string]interface{}{"boat": true}, - "override": "bad", - "right": "exists", - "scope": "moby", - "top": "nope", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l0": "moby"}, - }, - "pequod": map[string]interface{}{ - "boat": "maybe", - "ahab": map[string]interface{}{ - "boat": "maybe", - "nested": map[string]interface{}{"boat": "maybe"}, - }, - }, - }, - }, - withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "pequod"}, - Values: map[string]interface{}{ - "name": "pequod", - "scope": "pequod", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "pequod"}, - }, - "boat": false, - "ahab": map[string]interface{}{ - "boat": false, - "nested": map[string]interface{}{"boat": false}, - }, - }, - }, - &chart.Chart{ - Metadata: &chart.Metadata{Name: "ahab"}, - Values: map[string]interface{}{ - "global": map[string]interface{}{ - "nested": map[string]interface{}{"foo": "bar", "foo2": "bar2"}, - "nested2": map[string]interface{}{"l2": "ahab"}, - }, - "scope": "ahab", - "name": "ahab", - "boat": true, - "nested": map[string]interface{}{"foo": false, "boat": true}, - "object": map[string]interface{}{"foo": "bar"}, - }, - }, - ), - &chart.Chart{ - Metadata: &chart.Metadata{Name: "spouter"}, - Values: map[string]interface{}{ - "scope": "spouter", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "spouter"}, - }, - }, - }, - ) - - vals, err := ReadValues(testCoalesceValuesYaml) - if err != nil { - t.Fatal(err) - } - - // taking a copy of the values before passing it - // to CoalesceValues as argument, so that we can - // use it for asserting later - valsCopy := make(Values, len(vals)) - maps.Copy(valsCopy, vals) - - v, err := CoalesceValues(c, vals) - if err != nil { - t.Fatal(err) - } - j, _ := json.MarshalIndent(v, "", " ") - t.Logf("Coalesced Values: %s", string(j)) - - tests := []struct { - tpl string - expect string - }{ - {"{{.top}}", "yup"}, - {"{{.back}}", ""}, - {"{{.name}}", "moby"}, - {"{{.global.name}}", "Ishmael"}, - {"{{.global.subject}}", "Queequeg"}, - {"{{.global.harpooner}}", ""}, - {"{{.pequod.name}}", "pequod"}, - {"{{.pequod.ahab.name}}", "ahab"}, - {"{{.pequod.ahab.scope}}", "whale"}, - {"{{.pequod.ahab.nested.foo}}", "true"}, - {"{{.pequod.ahab.global.name}}", "Ishmael"}, - {"{{.pequod.ahab.global.nested.foo}}", "bar"}, - {"{{.pequod.ahab.global.nested.foo2}}", ""}, - {"{{.pequod.ahab.global.subject}}", "Queequeg"}, - {"{{.pequod.ahab.global.harpooner}}", "Tashtego"}, - {"{{.pequod.global.name}}", "Ishmael"}, - {"{{.pequod.global.nested.foo}}", ""}, - {"{{.pequod.global.subject}}", "Queequeg"}, - {"{{.spouter.global.name}}", "Ishmael"}, - {"{{.spouter.global.harpooner}}", ""}, - - {"{{.global.nested.boat}}", "true"}, - {"{{.pequod.global.nested.boat}}", "true"}, - {"{{.spouter.global.nested.boat}}", "true"}, - {"{{.pequod.global.nested.sail}}", "true"}, - {"{{.spouter.global.nested.sail}}", ""}, - - {"{{.global.nested2.l0}}", "moby"}, - {"{{.global.nested2.l1}}", ""}, - {"{{.global.nested2.l2}}", ""}, - {"{{.pequod.global.nested2.l0}}", "moby"}, - {"{{.pequod.global.nested2.l1}}", "pequod"}, - {"{{.pequod.global.nested2.l2}}", ""}, - {"{{.pequod.ahab.global.nested2.l0}}", "moby"}, - {"{{.pequod.ahab.global.nested2.l1}}", "pequod"}, - {"{{.pequod.ahab.global.nested2.l2}}", "ahab"}, - {"{{.spouter.global.nested2.l0}}", "moby"}, - {"{{.spouter.global.nested2.l1}}", "spouter"}, - {"{{.spouter.global.nested2.l2}}", ""}, - } - - for _, tt := range tests { - if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect { - t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o) - } - } - - nullKeys := []string{"bottom", "right", "left", "front"} - for _, nullKey := range nullKeys { - if _, ok := v[nullKey]; ok { - t.Errorf("Expected key %q to be removed, still present", nullKey) - } - } - - if _, ok := v["nested"].(map[string]interface{})["boat"]; ok { - t.Error("Expected nested boat key to be removed, still present") - } - - subchart := v["pequod"].(map[string]interface{}) - if _, ok := subchart["boat"]; ok { - t.Error("Expected subchart boat key to be removed, still present") - } - - subsubchart := subchart["ahab"].(map[string]interface{}) - if _, ok := subsubchart["boat"]; ok { - t.Error("Expected sub-subchart ahab boat key to be removed, still present") - } - - if _, ok := subsubchart["nested"].(map[string]interface{})["boat"]; ok { - t.Error("Expected sub-subchart nested boat key to be removed, still present") - } - - if _, ok := subsubchart["object"]; ok { - t.Error("Expected sub-subchart object map to be removed, still present") - } - - // CoalesceValues should not mutate the passed arguments - is.Equal(valsCopy, vals) -} - -func TestMergeValues(t *testing.T) { - is := assert.New(t) - - c := withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "moby"}, - Values: map[string]interface{}{ - "back": "exists", - "bottom": "exists", - "front": "exists", - "left": "exists", - "name": "moby", - "nested": map[string]interface{}{"boat": true}, - "override": "bad", - "right": "exists", - "scope": "moby", - "top": "nope", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l0": "moby"}, - }, - }, - }, - withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "pequod"}, - Values: map[string]interface{}{ - "name": "pequod", - "scope": "pequod", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "pequod"}, - }, - }, - }, - &chart.Chart{ - Metadata: &chart.Metadata{Name: "ahab"}, - Values: map[string]interface{}{ - "global": map[string]interface{}{ - "nested": map[string]interface{}{"foo": "bar"}, - "nested2": map[string]interface{}{"l2": "ahab"}, - }, - "scope": "ahab", - "name": "ahab", - "boat": true, - "nested": map[string]interface{}{"foo": false, "bar": true}, - }, - }, - ), - &chart.Chart{ - Metadata: &chart.Metadata{Name: "spouter"}, - Values: map[string]interface{}{ - "scope": "spouter", - "global": map[string]interface{}{ - "nested2": map[string]interface{}{"l1": "spouter"}, - }, - }, - }, - ) - - vals, err := ReadValues(testCoalesceValuesYaml) - if err != nil { - t.Fatal(err) - } - - // taking a copy of the values before passing it - // to MergeValues as argument, so that we can - // use it for asserting later - valsCopy := make(Values, len(vals)) - maps.Copy(valsCopy, vals) - - v, err := MergeValues(c, vals) - if err != nil { - t.Fatal(err) - } - j, _ := json.MarshalIndent(v, "", " ") - t.Logf("Coalesced Values: %s", string(j)) - - tests := []struct { - tpl string - expect string - }{ - {"{{.top}}", "yup"}, - {"{{.back}}", ""}, - {"{{.name}}", "moby"}, - {"{{.global.name}}", "Ishmael"}, - {"{{.global.subject}}", "Queequeg"}, - {"{{.global.harpooner}}", ""}, - {"{{.pequod.name}}", "pequod"}, - {"{{.pequod.ahab.name}}", "ahab"}, - {"{{.pequod.ahab.scope}}", "whale"}, - {"{{.pequod.ahab.nested.foo}}", "true"}, - {"{{.pequod.ahab.global.name}}", "Ishmael"}, - {"{{.pequod.ahab.global.nested.foo}}", "bar"}, - {"{{.pequod.ahab.global.subject}}", "Queequeg"}, - {"{{.pequod.ahab.global.harpooner}}", "Tashtego"}, - {"{{.pequod.global.name}}", "Ishmael"}, - {"{{.pequod.global.nested.foo}}", ""}, - {"{{.pequod.global.subject}}", "Queequeg"}, - {"{{.spouter.global.name}}", "Ishmael"}, - {"{{.spouter.global.harpooner}}", ""}, - - {"{{.global.nested.boat}}", "true"}, - {"{{.pequod.global.nested.boat}}", "true"}, - {"{{.spouter.global.nested.boat}}", "true"}, - {"{{.pequod.global.nested.sail}}", "true"}, - {"{{.spouter.global.nested.sail}}", ""}, - - {"{{.global.nested2.l0}}", "moby"}, - {"{{.global.nested2.l1}}", ""}, - {"{{.global.nested2.l2}}", ""}, - {"{{.pequod.global.nested2.l0}}", "moby"}, - {"{{.pequod.global.nested2.l1}}", "pequod"}, - {"{{.pequod.global.nested2.l2}}", ""}, - {"{{.pequod.ahab.global.nested2.l0}}", "moby"}, - {"{{.pequod.ahab.global.nested2.l1}}", "pequod"}, - {"{{.pequod.ahab.global.nested2.l2}}", "ahab"}, - {"{{.spouter.global.nested2.l0}}", "moby"}, - {"{{.spouter.global.nested2.l1}}", "spouter"}, - {"{{.spouter.global.nested2.l2}}", ""}, - } - - for _, tt := range tests { - if o, err := ttpl(tt.tpl, v); err != nil || o != tt.expect { - t.Errorf("Expected %q to expand to %q, got %q", tt.tpl, tt.expect, o) - } - } - - // nullKeys is different from coalescing. Here the null/nil values are not - // removed. - nullKeys := []string{"bottom", "right", "left", "front"} - for _, nullKey := range nullKeys { - if vv, ok := v[nullKey]; !ok { - t.Errorf("Expected key %q to be present but it was removed", nullKey) - } else if vv != nil { - t.Errorf("Expected key %q to be null but it has a value of %v", nullKey, vv) - } - } - - if _, ok := v["nested"].(map[string]interface{})["boat"]; !ok { - t.Error("Expected nested boat key to be present but it was removed") - } - - subchart := v["pequod"].(map[string]interface{})["ahab"].(map[string]interface{}) - if _, ok := subchart["boat"]; !ok { - t.Error("Expected subchart boat key to be present but it was removed") - } - - if _, ok := subchart["nested"].(map[string]interface{})["bar"]; !ok { - t.Error("Expected subchart nested bar key to be present but it was removed") - } - - // CoalesceValues should not mutate the passed arguments - is.Equal(valsCopy, vals) -} - -func TestCoalesceTables(t *testing.T) { - dst := map[string]interface{}{ - "name": "Ishmael", - "address": map[string]interface{}{ - "street": "123 Spouter Inn Ct.", - "city": "Nantucket", - "country": nil, - }, - "details": map[string]interface{}{ - "friends": []string{"Tashtego"}, - }, - "boat": "pequod", - "hole": nil, - } - src := map[string]interface{}{ - "occupation": "whaler", - "address": map[string]interface{}{ - "state": "MA", - "street": "234 Spouter Inn Ct.", - "country": "US", - }, - "details": "empty", - "boat": map[string]interface{}{ - "mast": true, - }, - "hole": "black", - } - - // What we expect is that anything in dst overrides anything in src, but that - // otherwise the values are coalesced. - CoalesceTables(dst, src) - - if dst["name"] != "Ishmael" { - t.Errorf("Unexpected name: %s", dst["name"]) - } - if dst["occupation"] != "whaler" { - t.Errorf("Unexpected occupation: %s", dst["occupation"]) - } - - addr, ok := dst["address"].(map[string]interface{}) - if !ok { - t.Fatal("Address went away.") - } - - if addr["street"].(string) != "123 Spouter Inn Ct." { - t.Errorf("Unexpected address: %v", addr["street"]) - } - - if addr["city"].(string) != "Nantucket" { - t.Errorf("Unexpected city: %v", addr["city"]) - } - - if addr["state"].(string) != "MA" { - t.Errorf("Unexpected state: %v", addr["state"]) - } - - if _, ok = addr["country"]; ok { - t.Error("The country is not left out.") - } - - if det, ok := dst["details"].(map[string]interface{}); !ok { - t.Fatalf("Details is the wrong type: %v", dst["details"]) - } else if _, ok := det["friends"]; !ok { - t.Error("Could not find your friends. Maybe you don't have any. :-(") - } - - if dst["boat"].(string) != "pequod" { - t.Errorf("Expected boat string, got %v", dst["boat"]) - } - - if _, ok = dst["hole"]; ok { - t.Error("The hole still exists.") - } - - dst2 := map[string]interface{}{ - "name": "Ishmael", - "address": map[string]interface{}{ - "street": "123 Spouter Inn Ct.", - "city": "Nantucket", - "country": "US", - }, - "details": map[string]interface{}{ - "friends": []string{"Tashtego"}, - }, - "boat": "pequod", - "hole": "black", - } - - // What we expect is that anything in dst should have all values set, - // this happens when the --reuse-values flag is set but the chart has no modifications yet - CoalesceTables(dst2, nil) - - if dst2["name"] != "Ishmael" { - t.Errorf("Unexpected name: %s", dst2["name"]) - } - - addr2, ok := dst2["address"].(map[string]interface{}) - if !ok { - t.Fatal("Address went away.") - } - - if addr2["street"].(string) != "123 Spouter Inn Ct." { - t.Errorf("Unexpected address: %v", addr2["street"]) - } - - if addr2["city"].(string) != "Nantucket" { - t.Errorf("Unexpected city: %v", addr2["city"]) - } - - if addr2["country"].(string) != "US" { - t.Errorf("Unexpected Country: %v", addr2["country"]) - } - - if det2, ok := dst2["details"].(map[string]interface{}); !ok { - t.Fatalf("Details is the wrong type: %v", dst2["details"]) - } else if _, ok := det2["friends"]; !ok { - t.Error("Could not find your friends. Maybe you don't have any. :-(") - } - - if dst2["boat"].(string) != "pequod" { - t.Errorf("Expected boat string, got %v", dst2["boat"]) - } - - if dst2["hole"].(string) != "black" { - t.Errorf("Expected hole string, got %v", dst2["boat"]) - } -} - -func TestMergeTables(t *testing.T) { - dst := map[string]interface{}{ - "name": "Ishmael", - "address": map[string]interface{}{ - "street": "123 Spouter Inn Ct.", - "city": "Nantucket", - "country": nil, - }, - "details": map[string]interface{}{ - "friends": []string{"Tashtego"}, - }, - "boat": "pequod", - "hole": nil, - } - src := map[string]interface{}{ - "occupation": "whaler", - "address": map[string]interface{}{ - "state": "MA", - "street": "234 Spouter Inn Ct.", - "country": "US", - }, - "details": "empty", - "boat": map[string]interface{}{ - "mast": true, - }, - "hole": "black", - } - - // What we expect is that anything in dst overrides anything in src, but that - // otherwise the values are coalesced. - MergeTables(dst, src) - - if dst["name"] != "Ishmael" { - t.Errorf("Unexpected name: %s", dst["name"]) - } - if dst["occupation"] != "whaler" { - t.Errorf("Unexpected occupation: %s", dst["occupation"]) - } - - addr, ok := dst["address"].(map[string]interface{}) - if !ok { - t.Fatal("Address went away.") - } - - if addr["street"].(string) != "123 Spouter Inn Ct." { - t.Errorf("Unexpected address: %v", addr["street"]) - } - - if addr["city"].(string) != "Nantucket" { - t.Errorf("Unexpected city: %v", addr["city"]) - } - - if addr["state"].(string) != "MA" { - t.Errorf("Unexpected state: %v", addr["state"]) - } - - // This is one test that is different from CoalesceTables. Because country - // is a nil value and it's not removed it's still present. - if _, ok = addr["country"]; !ok { - t.Error("The country is left out.") - } - - if det, ok := dst["details"].(map[string]interface{}); !ok { - t.Fatalf("Details is the wrong type: %v", dst["details"]) - } else if _, ok := det["friends"]; !ok { - t.Error("Could not find your friends. Maybe you don't have any. :-(") - } - - if dst["boat"].(string) != "pequod" { - t.Errorf("Expected boat string, got %v", dst["boat"]) - } - - // This is one test that is different from CoalesceTables. Because hole - // is a nil value and it's not removed it's still present. - if _, ok = dst["hole"]; !ok { - t.Error("The hole no longer exists.") - } - - dst2 := map[string]interface{}{ - "name": "Ishmael", - "address": map[string]interface{}{ - "street": "123 Spouter Inn Ct.", - "city": "Nantucket", - "country": "US", - }, - "details": map[string]interface{}{ - "friends": []string{"Tashtego"}, - }, - "boat": "pequod", - "hole": "black", - "nilval": nil, - } - - // What we expect is that anything in dst should have all values set, - // this happens when the --reuse-values flag is set but the chart has no modifications yet - MergeTables(dst2, nil) - - if dst2["name"] != "Ishmael" { - t.Errorf("Unexpected name: %s", dst2["name"]) - } - - addr2, ok := dst2["address"].(map[string]interface{}) - if !ok { - t.Fatal("Address went away.") - } - - if addr2["street"].(string) != "123 Spouter Inn Ct." { - t.Errorf("Unexpected address: %v", addr2["street"]) - } - - if addr2["city"].(string) != "Nantucket" { - t.Errorf("Unexpected city: %v", addr2["city"]) - } - - if addr2["country"].(string) != "US" { - t.Errorf("Unexpected Country: %v", addr2["country"]) - } - - if det2, ok := dst2["details"].(map[string]interface{}); !ok { - t.Fatalf("Details is the wrong type: %v", dst2["details"]) - } else if _, ok := det2["friends"]; !ok { - t.Error("Could not find your friends. Maybe you don't have any. :-(") - } - - if dst2["boat"].(string) != "pequod" { - t.Errorf("Expected boat string, got %v", dst2["boat"]) - } - - if dst2["hole"].(string) != "black" { - t.Errorf("Expected hole string, got %v", dst2["boat"]) - } - - if dst2["nilval"] != nil { - t.Error("Expected nilvalue to have nil value but it does not") - } -} - -func TestCoalesceValuesWarnings(t *testing.T) { - - c := withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "level1"}, - Values: map[string]interface{}{ - "name": "moby", - }, - }, - withDeps(&chart.Chart{ - Metadata: &chart.Metadata{Name: "level2"}, - Values: map[string]interface{}{ - "name": "pequod", - }, - }, - &chart.Chart{ - Metadata: &chart.Metadata{Name: "level3"}, - Values: map[string]interface{}{ - "name": "ahab", - "boat": true, - "spear": map[string]interface{}{ - "tip": true, - "sail": map[string]interface{}{ - "cotton": true, - }, - }, - }, - }, - ), - ) - - vals := map[string]interface{}{ - "level2": map[string]interface{}{ - "level3": map[string]interface{}{ - "boat": map[string]interface{}{"mast": true}, - "spear": map[string]interface{}{ - "tip": map[string]interface{}{ - "sharp": true, - }, - "sail": true, - }, - }, - }, - } - - warnings := make([]string, 0) - printf := func(format string, v ...interface{}) { - t.Logf(format, v...) - warnings = append(warnings, fmt.Sprintf(format, v...)) - } - - _, err := coalesce(printf, c, vals, "", false) - if err != nil { - t.Fatal(err) - } - - t.Logf("vals: %v", vals) - assert.Contains(t, warnings, "warning: skipped value for level1.level2.level3.boat: Not a table.") - assert.Contains(t, warnings, "warning: destination for level1.level2.level3.spear.tip is a table. Ignoring non-table value (true)") - assert.Contains(t, warnings, "warning: cannot overwrite table with non table for level1.level2.level3.spear.sail (map[cotton:true])") - -} - -func TestConcatPrefix(t *testing.T) { - assert.Equal(t, "b", concatPrefix("", "b")) - assert.Equal(t, "a.b", concatPrefix("a", "b")) -} diff --git a/internal/chart/v3/util/create.go b/internal/chart/v3/util/create.go index 6a28f99d4..9f742e646 100644 --- a/internal/chart/v3/util/create.go +++ b/internal/chart/v3/util/create.go @@ -28,6 +28,7 @@ import ( chart "helm.sh/helm/v4/internal/chart/v3" "helm.sh/helm/v4/internal/chart/v3/loader" + "helm.sh/helm/v4/pkg/chart/common" ) // chartName is a regular expression for testing the supplied name of a chart. @@ -655,11 +656,11 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { schart.Metadata = chartfile - var updatedTemplates []*chart.File + var updatedTemplates []*common.File for _, template := range schart.Templates { newData := transform(string(template.Data), schart.Name()) - updatedTemplates = append(updatedTemplates, &chart.File{Name: template.Name, Data: newData}) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) } schart.Templates = updatedTemplates diff --git a/internal/chart/v3/util/dependencies.go b/internal/chart/v3/util/dependencies.go index 129c46372..489772115 100644 --- a/internal/chart/v3/util/dependencies.go +++ b/internal/chart/v3/util/dependencies.go @@ -23,10 +23,12 @@ import ( "github.com/mitchellh/copystructure" chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" ) // ProcessDependencies checks through this chart's dependencies, processing accordingly. -func ProcessDependencies(c *chart.Chart, v Values) error { +func ProcessDependencies(c *chart.Chart, v common.Values) error { if err := processDependencyEnabled(c, v, ""); err != nil { return err } @@ -34,7 +36,7 @@ func ProcessDependencies(c *chart.Chart, v Values) error { } // processDependencyConditions disables charts based on condition path value in values -func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath string) { +func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, cpath string) { if reqs == nil { return } @@ -50,7 +52,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if _, ok := err.(ErrNoValue); !ok { + } else if _, ok := err.(common.ErrNoValue); !ok { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } @@ -60,7 +62,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s } // processDependencyTags disables charts based on tags in values -func processDependencyTags(reqs []*chart.Dependency, cvals Values) { +func processDependencyTags(reqs []*chart.Dependency, cvals common.Values) { if reqs == nil { return } @@ -177,7 +179,7 @@ Loop: for _, lr := range c.Metadata.Dependencies { lr.Enabled = true } - cvals, err := CoalesceValues(c, v) + cvals, err := util.CoalesceValues(c, v) if err != nil { return err } @@ -232,6 +234,8 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} return set(parsePath(path), data) } +func parsePath(key string) []string { return strings.Split(key, ".") } + func set(path []string, data map[string]interface{}) map[string]interface{} { if len(path) == 0 { return nil @@ -249,12 +253,12 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } // combine chart values and empty config to get Values - var cvals Values + var cvals common.Values var err error if merge { - cvals, err = MergeValues(c, nil) + cvals, err = util.MergeValues(c, nil) } else { - cvals, err = CoalesceValues(c, nil) + cvals, err = util.CoalesceValues(c, nil) } if err != nil { return err @@ -282,9 +286,9 @@ func processImportValues(c *chart.Chart, merge bool) error { } // create value map from child to be merged into parent if merge { - b = MergeTables(b, pathToMap(parent, vv.AsMap())) + b = util.MergeTables(b, pathToMap(parent, vv.AsMap())) } else { - b = CoalesceTables(b, pathToMap(parent, vv.AsMap())) + b = util.CoalesceTables(b, pathToMap(parent, vv.AsMap())) } case string: child := "exports." + iv @@ -298,9 +302,9 @@ func processImportValues(c *chart.Chart, merge bool) error { continue } if merge { - b = MergeTables(b, vm.AsMap()) + b = util.MergeTables(b, vm.AsMap()) } else { - b = CoalesceTables(b, vm.AsMap()) + b = util.CoalesceTables(b, vm.AsMap()) } } } @@ -315,14 +319,14 @@ func processImportValues(c *chart.Chart, merge bool) error { // deep copying the cvals as there are cases where pointers can end // up in the cvals when they are copied onto b in ways that break things. cvals = deepCopyMap(cvals) - c.Values = MergeTables(cvals, b) + c.Values = util.MergeTables(cvals, b) } else { // Trimming the nil values from cvals is needed for backwards compatibility. // Previously, the b value had been populated with cvals along with some // overrides. This caused the coalescing functionality to remove the // nil/null values. This trimming is for backwards compat. cvals = trimNilValues(cvals) - c.Values = CoalesceTables(cvals, b) + c.Values = util.CoalesceTables(cvals, b) } return nil @@ -355,6 +359,12 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { return valsCopyMap } +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} + // processDependencyImportValues imports specified chart values from child to parent. func processDependencyImportValues(c *chart.Chart, merge bool) error { for _, d := range c.Dependencies() { diff --git a/internal/chart/v3/util/dependencies_test.go b/internal/chart/v3/util/dependencies_test.go index 55839fe65..3c5bb96f7 100644 --- a/internal/chart/v3/util/dependencies_test.go +++ b/internal/chart/v3/util/dependencies_test.go @@ -23,6 +23,7 @@ import ( chart "helm.sh/helm/v4/internal/chart/v3" "helm.sh/helm/v4/internal/chart/v3/loader" + "helm.sh/helm/v4/pkg/chart/common" ) func loadChart(t *testing.T, path string) *chart.Chart { @@ -221,7 +222,7 @@ func TestProcessDependencyImportValues(t *testing.T) { if err := processDependencyImportValues(c, false); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { @@ -251,7 +252,7 @@ func TestProcessDependencyImportValues(t *testing.T) { t.Error("expect nil value not found but found it") } switch xerr := err.(type) { - case ErrNoValue: + case common.ErrNoValue: // We found what we expected default: t.Errorf("expected an ErrNoValue but got %q instead", xerr) @@ -261,7 +262,7 @@ func TestProcessDependencyImportValues(t *testing.T) { if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc = Values(c.Values) + cc = common.Values(c.Values) val, err := cc.PathValue("ensurenull") if err != nil { t.Error("expect value but ensurenull was not found") @@ -291,7 +292,7 @@ func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T e["foo.grandchild.defaults.defaultValue"] = "42" e["bar.grandchild.defaults.defaultValue"] = "42" - cValues := Values(c.Values) + cValues := common.Values(c.Values) for kk, vv := range e { pv, err := cValues.PathValue(kk) if err != nil { @@ -329,7 +330,7 @@ func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { diff --git a/internal/chart/v3/util/errors.go b/internal/chart/v3/util/errors.go deleted file mode 100644 index a175b9758..000000000 --- a/internal/chart/v3/util/errors.go +++ /dev/null @@ -1,43 +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 util - -import ( - "fmt" -) - -// ErrNoTable indicates that a chart does not have a matching table. -type ErrNoTable struct { - Key string -} - -func (e ErrNoTable) Error() string { return fmt.Sprintf("%q is not a table", e.Key) } - -// ErrNoValue indicates that Values does not contain a key with a value -type ErrNoValue struct { - Key string -} - -func (e ErrNoValue) Error() string { return fmt.Sprintf("%q is not a value", e.Key) } - -type ErrInvalidChartName struct { - Name string -} - -func (e ErrInvalidChartName) Error() string { - return fmt.Sprintf("%q is not a valid chart name", e.Name) -} diff --git a/internal/chart/v3/util/jsonschema.go b/internal/chart/v3/util/jsonschema.go deleted file mode 100644 index 9fe35904e..000000000 --- a/internal/chart/v3/util/jsonschema.go +++ /dev/null @@ -1,113 +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 util - -import ( - "bytes" - "errors" - "fmt" - "log/slog" - "strings" - - "github.com/santhosh-tekuri/jsonschema/v6" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -// ValidateAgainstSchema checks that values does not violate the structure laid out in schema -func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { - var sb strings.Builder - if chrt.Schema != nil { - slog.Debug("chart name", "chart-name", chrt.Name()) - err := ValidateAgainstSingleSchema(values, chrt.Schema) - if err != nil { - sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name())) - sb.WriteString(err.Error()) - } - } - slog.Debug("number of dependencies in the chart", "dependencies", len(chrt.Dependencies())) - // For each dependency, recursively call this function with the coalesced values - for _, subchart := range chrt.Dependencies() { - subchartValues := values[subchart.Name()].(map[string]interface{}) - if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { - sb.WriteString(err.Error()) - } - } - - if sb.Len() > 0 { - return errors.New(sb.String()) - } - - return nil -} - -// ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema -func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error) { - defer func() { - if r := recover(); r != nil { - reterr = fmt.Errorf("unable to validate schema: %s", r) - } - }() - - // This unmarshal function leverages UseNumber() for number precision. The parser - // used for values does this as well. - schema, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) - if err != nil { - return err - } - slog.Debug("unmarshalled JSON schema", "schema", schemaJSON) - - compiler := jsonschema.NewCompiler() - err = compiler.AddResource("file:///values.schema.json", schema) - if err != nil { - return err - } - - validator, err := compiler.Compile("file:///values.schema.json") - if err != nil { - return err - } - - err = validator.Validate(values.AsMap()) - if err != nil { - return JSONSchemaValidationError{err} - } - - return nil -} - -// Note, JSONSchemaValidationError is used to wrap the error from the underlying -// validation package so that Helm has a clean interface and the validation package -// could be replaced without changing the Helm SDK API. - -// JSONSchemaValidationError is the error returned when there is a schema validation -// error. -type JSONSchemaValidationError struct { - embeddedErr error -} - -// Error prints the error message -func (e JSONSchemaValidationError) Error() string { - errStr := e.embeddedErr.Error() - - // This string prefixes all of our error details. Further up the stack of helm error message - // building more detail is provided to users. This is removed. - errStr = strings.TrimPrefix(errStr, "jsonschema validation failed with 'file:///values.schema.json#'\n") - - // The extra new line is needed for when there are sub-charts. - return errStr + "\n" -} diff --git a/internal/chart/v3/util/jsonschema_test.go b/internal/chart/v3/util/jsonschema_test.go deleted file mode 100644 index 0a3820377..000000000 --- a/internal/chart/v3/util/jsonschema_test.go +++ /dev/null @@ -1,247 +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 util - -import ( - "os" - "testing" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -func TestValidateAgainstSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values.schema.json") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - - if err := ValidateAgainstSingleSchema(values, schema); err != nil { - t.Errorf("Error validating Values against Schema: %s", err) - } -} - -func TestValidateAgainstInvalidSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values-invalid.schema.json") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - - var errString string - if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `"file:///values.schema.json#" is not valid against metaschema: jsonschema validation failed with 'https://json-schema.org/draft/2020-12/schema#' -- at '': got number, want boolean or object` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} - -func TestValidateAgainstSingleSchemaNegative(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values-negative.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - schema, err := os.ReadFile("./testdata/test-values.schema.json") - if err != nil { - t.Fatalf("Error reading JSON file: %s", err) - } - - var errString string - if err := ValidateAgainstSingleSchema(values, schema); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `- at '': missing property 'employmentInfo' -- at '/age': minimum: got -5, want 0 -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} - -const subchartSchema = `{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Values", - "type": "object", - "properties": { - "age": { - "description": "Age", - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "age" - ] -} -` - -const subchartSchema2020 = `{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Values", - "type": "object", - "properties": { - "data": { - "type": "array", - "contains": { "type": "string" }, - "unevaluatedItems": { "type": "number" } - } - }, - "required": ["data"] -} -` - -func TestValidateAgainstSchema(t *testing.T) { - subchartJSON := []byte(subchartSchema) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{ - "age": 25, - }, - } - - if err := ValidateAgainstSchema(chrt, vals); err != nil { - t.Errorf("Error validating Values against Schema: %s", err) - } -} - -func TestValidateAgainstSchemaNegative(t *testing.T) { - subchartJSON := []byte(subchartSchema) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{}, - } - - var errString string - if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `subchart: -- at '': missing property 'age' -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} - -func TestValidateAgainstSchema2020(t *testing.T) { - subchartJSON := []byte(subchartSchema2020) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{ - "data": []any{"hello", 12}, - }, - } - - if err := ValidateAgainstSchema(chrt, vals); err != nil { - t.Errorf("Error validating Values against Schema: %s", err) - } -} - -func TestValidateAgainstSchema2020Negative(t *testing.T) { - subchartJSON := []byte(subchartSchema2020) - subchart := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "subchart", - }, - Schema: subchartJSON, - } - chrt := &chart.Chart{ - Metadata: &chart.Metadata{ - Name: "chrt", - }, - } - chrt.AddDependency(subchart) - - vals := map[string]interface{}{ - "name": "John", - "subchart": map[string]interface{}{ - "data": []any{12}, - }, - } - - var errString string - if err := ValidateAgainstSchema(chrt, vals); err == nil { - t.Fatalf("Expected an error, but got nil") - } else { - errString = err.Error() - } - - expectedErrString := `subchart: -- at '/data': no items match contains schema - - at '/data/0': got number, want string -` - if errString != expectedErrString { - t.Errorf("Error string :\n`%s`\ndoes not match expected\n`%s`", errString, expectedErrString) - } -} diff --git a/internal/chart/v3/util/save.go b/internal/chart/v3/util/save.go index 3125cc3c9..49d93bf40 100644 --- a/internal/chart/v3/util/save.go +++ b/internal/chart/v3/util/save.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/yaml" chart "helm.sh/helm/v4/internal/chart/v3" + "helm.sh/helm/v4/pkg/chart/common" ) var headerBytes = []byte("+aHR0cHM6Ly95b3V0dS5iZS96OVV6MWljandyTQo=") @@ -76,7 +77,7 @@ func SaveDir(c *chart.Chart, dest string) error { } // Save templates and files - for _, o := range [][]*chart.File{c.Templates, c.Files} { + for _, o := range [][]*common.File{c.Templates, c.Files} { for _, f := range o { n := filepath.Join(outdir, f.Name) if err := writeFile(n, f.Data); err != nil { @@ -246,7 +247,7 @@ func validateName(name string) error { nname := filepath.Base(name) if nname != name { - return ErrInvalidChartName{name} + return common.ErrInvalidChartName{Name: name} } return nil diff --git a/internal/chart/v3/util/save_test.go b/internal/chart/v3/util/save_test.go index 852675bb0..9b1b14a4c 100644 --- a/internal/chart/v3/util/save_test.go +++ b/internal/chart/v3/util/save_test.go @@ -31,6 +31,7 @@ import ( chart "helm.sh/helm/v4/internal/chart/v3" "helm.sh/helm/v4/internal/chart/v3/loader" + "helm.sh/helm/v4/pkg/chart/common" ) func TestSave(t *testing.T) { @@ -47,7 +48,7 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), @@ -113,7 +114,7 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, } @@ -153,7 +154,7 @@ func TestSavePreservesTimestamps(t *testing.T) { "imageName": "testimage", "imageId": 42, }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), @@ -219,10 +220,10 @@ func TestSaveDir(t *testing.T) { Name: "ahab", Version: "1.2.3", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, }, } diff --git a/internal/chart/v3/util/values.go b/internal/chart/v3/util/values.go deleted file mode 100644 index 8e1a14b45..000000000 --- a/internal/chart/v3/util/values.go +++ /dev/null @@ -1,220 +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 util - -import ( - "errors" - "fmt" - "io" - "os" - "strings" - - "sigs.k8s.io/yaml" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -// GlobalKey is the name of the Values key that is used for storing global vars. -const GlobalKey = "global" - -// Values represents a collection of chart values. -type Values map[string]interface{} - -// YAML encodes the Values into a YAML string. -func (v Values) YAML() (string, error) { - b, err := yaml.Marshal(v) - return string(b), err -} - -// Table gets a table (YAML subsection) from a Values object. -// -// The table is returned as a Values. -// -// Compound table names may be specified with dots: -// -// foo.bar -// -// The above will be evaluated as "The table bar inside the table -// foo". -// -// An ErrNoTable is returned if the table does not exist. -func (v Values) Table(name string) (Values, error) { - table := v - var err error - - for _, n := range parsePath(name) { - if table, err = tableLookup(table, n); err != nil { - break - } - } - return table, err -} - -// AsMap is a utility function for converting Values to a map[string]interface{}. -// -// It protects against nil map panics. -func (v Values) AsMap() map[string]interface{} { - if len(v) == 0 { - return map[string]interface{}{} - } - return v -} - -// Encode writes serialized Values information to the given io.Writer. -func (v Values) Encode(w io.Writer) error { - out, err := yaml.Marshal(v) - if err != nil { - return err - } - _, err = w.Write(out) - return err -} - -func tableLookup(v Values, simple string) (Values, error) { - v2, ok := v[simple] - if !ok { - return v, ErrNoTable{simple} - } - if vv, ok := v2.(map[string]interface{}); ok { - return vv, nil - } - - // This catches a case where a value is of type Values, but doesn't (for some - // reason) match the map[string]interface{}. This has been observed in the - // wild, and might be a result of a nil map of type Values. - if vv, ok := v2.(Values); ok { - return vv, nil - } - - return Values{}, ErrNoTable{simple} -} - -// ReadValues will parse YAML byte data into a Values. -func ReadValues(data []byte) (vals Values, err error) { - err = yaml.Unmarshal(data, &vals) - if len(vals) == 0 { - vals = Values{} - } - return vals, err -} - -// ReadValuesFile will parse a YAML file into a map of values. -func ReadValuesFile(filename string) (Values, error) { - data, err := os.ReadFile(filename) - if err != nil { - return map[string]interface{}{}, err - } - return ReadValues(data) -} - -// ReleaseOptions represents the additional release options needed -// for the composition of the final values struct -type ReleaseOptions struct { - Name string - Namespace string - Revision int - IsUpgrade bool - IsInstall bool -} - -// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files -// -// This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities) (Values, error) { - return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false) -} - -// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files -// -// This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValuesWithSchemaValidation(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities, skipSchemaValidation bool) (Values, error) { - if caps == nil { - caps = DefaultCapabilities - } - top := map[string]interface{}{ - "Chart": chrt.Metadata, - "Capabilities": caps, - "Release": map[string]interface{}{ - "Name": options.Name, - "Namespace": options.Namespace, - "IsUpgrade": options.IsUpgrade, - "IsInstall": options.IsInstall, - "Revision": options.Revision, - "Service": "Helm", - }, - } - - vals, err := CoalesceValues(chrt, chrtVals) - if err != nil { - return top, err - } - - if !skipSchemaValidation { - if err := ValidateAgainstSchema(chrt, vals); err != nil { - return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err) - } - } - - top["Values"] = vals - return top, nil -} - -// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. -func istable(v interface{}) bool { - _, ok := v.(map[string]interface{}) - return ok -} - -// PathValue takes a path that traverses a YAML structure and returns the value at the end of that path. -// The path starts at the root of the YAML structure and is comprised of YAML keys separated by periods. -// Given the following YAML data the value at path "chapter.one.title" is "Loomings". -// -// chapter: -// one: -// title: "Loomings" -func (v Values) PathValue(path string) (interface{}, error) { - if path == "" { - return nil, errors.New("YAML path cannot be empty") - } - return v.pathValue(parsePath(path)) -} - -func (v Values) pathValue(path []string) (interface{}, error) { - if len(path) == 1 { - // if exists must be root key not table - if _, ok := v[path[0]]; ok && !istable(v[path[0]]) { - return v[path[0]], nil - } - return nil, ErrNoValue{path[0]} - } - - key, path := path[len(path)-1], path[:len(path)-1] - // get our table for table path - t, err := v.Table(joinPath(path...)) - if err != nil { - return nil, ErrNoValue{key} - } - // check table for key and ensure value is not a table - if k, ok := t[key]; ok && !istable(k) { - return k, nil - } - return nil, ErrNoValue{key} -} - -func parsePath(key string) []string { return strings.Split(key, ".") } - -func joinPath(path ...string) string { return strings.Join(path, ".") } diff --git a/internal/chart/v3/util/values_test.go b/internal/chart/v3/util/values_test.go deleted file mode 100644 index 34c664581..000000000 --- a/internal/chart/v3/util/values_test.go +++ /dev/null @@ -1,293 +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 util - -import ( - "bytes" - "fmt" - "testing" - "text/template" - - chart "helm.sh/helm/v4/internal/chart/v3" -) - -func TestReadValues(t *testing.T) { - doc := `# Test YAML parse -poet: "Coleridge" -title: "Rime of the Ancient Mariner" -stanza: - - "at" - - "length" - - "did" - - cross - - an - - Albatross - -mariner: - with: "crossbow" - shot: "ALBATROSS" - -water: - water: - where: "everywhere" - nor: "any drop to drink" -` - - data, err := ReadValues([]byte(doc)) - if err != nil { - t.Fatalf("Error parsing bytes: %s", err) - } - matchValues(t, data) - - tests := []string{`poet: "Coleridge"`, "# Just a comment", ""} - - for _, tt := range tests { - data, err = ReadValues([]byte(tt)) - if err != nil { - t.Fatalf("Error parsing bytes (%s): %s", tt, err) - } - if data == nil { - t.Errorf(`YAML string "%s" gave a nil map`, tt) - } - } -} - -func TestToRenderValues(t *testing.T) { - - chartValues := map[string]interface{}{ - "name": "al Rashid", - "where": map[string]interface{}{ - "city": "Basrah", - "title": "caliph", - }, - } - - overrideValues := map[string]interface{}{ - "name": "Haroun", - "where": map[string]interface{}{ - "city": "Baghdad", - "date": "809 CE", - }, - } - - c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test"}, - Templates: []*chart.File{}, - Values: chartValues, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, - }, - } - c.AddDependency(&chart.Chart{ - Metadata: &chart.Metadata{Name: "where"}, - }) - - o := ReleaseOptions{ - Name: "Seven Voyages", - Namespace: "default", - Revision: 1, - IsInstall: true, - } - - res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false) - if err != nil { - t.Fatal(err) - } - - // Ensure that the top-level values are all set. - if name := res["Chart"].(*chart.Metadata).Name; name != "test" { - t.Errorf("Expected chart name 'test', got %q", name) - } - relmap := res["Release"].(map[string]interface{}) - if name := relmap["Name"]; name.(string) != "Seven Voyages" { - t.Errorf("Expected release name 'Seven Voyages', got %q", name) - } - if namespace := relmap["Namespace"]; namespace.(string) != "default" { - t.Errorf("Expected namespace 'default', got %q", namespace) - } - if revision := relmap["Revision"]; revision.(int) != 1 { - t.Errorf("Expected revision '1', got %d", revision) - } - if relmap["IsUpgrade"].(bool) { - t.Error("Expected upgrade to be false.") - } - if !relmap["IsInstall"].(bool) { - t.Errorf("Expected install to be true.") - } - if !res["Capabilities"].(*Capabilities).APIVersions.Has("v1") { - t.Error("Expected Capabilities to have v1 as an API") - } - if res["Capabilities"].(*Capabilities).KubeVersion.Major != "1" { - t.Error("Expected Capabilities to have a Kube version") - } - - vals := res["Values"].(Values) - if vals["name"] != "Haroun" { - t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) - } - where := vals["where"].(map[string]interface{}) - expects := map[string]string{ - "city": "Baghdad", - "date": "809 CE", - "title": "caliph", - } - for field, expect := range expects { - if got := where[field]; got != expect { - t.Errorf("Expected %q, got %q (%v)", expect, got, where) - } - } -} - -func TestReadValuesFile(t *testing.T) { - data, err := ReadValuesFile("./testdata/coleridge.yaml") - if err != nil { - t.Fatalf("Error reading YAML file: %s", err) - } - matchValues(t, data) -} - -func ExampleValues() { - doc := ` -title: "Moby Dick" -chapter: - one: - title: "Loomings" - two: - title: "The Carpet-Bag" - three: - title: "The Spouter Inn" -` - d, err := ReadValues([]byte(doc)) - if err != nil { - panic(err) - } - ch1, err := d.Table("chapter.one") - if err != nil { - panic("could not find chapter one") - } - fmt.Print(ch1["title"]) - // Output: - // Loomings -} - -func TestTable(t *testing.T) { - doc := ` -title: "Moby Dick" -chapter: - one: - title: "Loomings" - two: - title: "The Carpet-Bag" - three: - title: "The Spouter Inn" -` - d, err := ReadValues([]byte(doc)) - if err != nil { - t.Fatalf("Failed to parse the White Whale: %s", err) - } - - if _, err := d.Table("title"); err == nil { - t.Fatalf("Title is not a table.") - } - - if _, err := d.Table("chapter"); err != nil { - t.Fatalf("Failed to get the chapter table: %s\n%v", err, d) - } - - if v, err := d.Table("chapter.one"); err != nil { - t.Errorf("Failed to get chapter.one: %s", err) - } else if v["title"] != "Loomings" { - t.Errorf("Unexpected title: %s", v["title"]) - } - - if _, err := d.Table("chapter.three"); err != nil { - t.Errorf("Chapter three is missing: %s\n%v", err, d) - } - - if _, err := d.Table("chapter.OneHundredThirtySix"); err == nil { - t.Errorf("I think you mean 'Epilogue'") - } -} - -func matchValues(t *testing.T, data map[string]interface{}) { - t.Helper() - if data["poet"] != "Coleridge" { - t.Errorf("Unexpected poet: %s", data["poet"]) - } - - if o, err := ttpl("{{len .stanza}}", data); err != nil { - t.Errorf("len stanza: %s", err) - } else if o != "6" { - t.Errorf("Expected 6, got %s", o) - } - - if o, err := ttpl("{{.mariner.shot}}", data); err != nil { - t.Errorf(".mariner.shot: %s", err) - } else if o != "ALBATROSS" { - t.Errorf("Expected that mariner shot ALBATROSS") - } - - if o, err := ttpl("{{.water.water.where}}", data); err != nil { - t.Errorf(".water.water.where: %s", err) - } else if o != "everywhere" { - t.Errorf("Expected water water everywhere") - } -} - -func ttpl(tpl string, v map[string]interface{}) (string, error) { - var b bytes.Buffer - tt := template.Must(template.New("t").Parse(tpl)) - err := tt.Execute(&b, v) - return b.String(), err -} - -func TestPathValue(t *testing.T) { - doc := ` -title: "Moby Dick" -chapter: - one: - title: "Loomings" - two: - title: "The Carpet-Bag" - three: - title: "The Spouter Inn" -` - d, err := ReadValues([]byte(doc)) - if err != nil { - t.Fatalf("Failed to parse the White Whale: %s", err) - } - - if v, err := d.PathValue("chapter.one.title"); err != nil { - t.Errorf("Got error instead of title: %s\n%v", err, d) - } else if v != "Loomings" { - t.Errorf("No error but got wrong value for title: %s\n%v", err, d) - } - if _, err := d.PathValue("chapter.one.doesnotexist"); err == nil { - t.Errorf("Non-existent key should return error: %s\n%v", err, d) - } - if _, err := d.PathValue("chapter.doesnotexist.one"); err == nil { - t.Errorf("Non-existent key in middle of path should return error: %s\n%v", err, d) - } - if _, err := d.PathValue(""); err == nil { - t.Error("Asking for the value from an empty path should yield an error") - } - if v, err := d.PathValue("title"); err == nil { - if v != "Moby Dick" { - t.Errorf("Failed to return values for root key title") - } - } -} diff --git a/pkg/action/action.go b/pkg/action/action.go index 522226a1a..bcf6ca8ef 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -39,6 +39,7 @@ import ( "sigs.k8s.io/kustomize/kyaml/kio" kyaml "sigs.k8s.io/kustomize/kyaml/yaml" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/engine" @@ -84,7 +85,7 @@ type Configuration struct { RegistryClient *registry.Client // Capabilities describes the capabilities of the Kubernetes cluster. - Capabilities *chartutil.Capabilities + Capabilities *common.Capabilities // CustomTemplateFuncs is defined by users to provide custom template funcs CustomTemplateFuncs template.FuncMap @@ -176,7 +177,7 @@ func splitAndDeannotate(postrendered string) (map[string]string, error) { // TODO: As part of the refactor the duplicate code in cmd/helm/template.go should be removed // // This code has to do with writing files to disk. -func (cfg *Configuration) renderResources(ch *chart.Chart, values chartutil.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { +func (cfg *Configuration) renderResources(ch *chart.Chart, values common.Values, releaseName, outputDir string, subNotes, useReleaseName, includeCrds bool, pr postrenderer.PostRenderer, interactWithRemote, enableDNS, hideSecret bool) ([]*release.Hook, *bytes.Buffer, string, error) { var hs []*release.Hook b := bytes.NewBuffer(nil) @@ -337,7 +338,7 @@ type RESTClientGetter interface { } // capabilities builds a Capabilities from discovery information. -func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { +func (cfg *Configuration) getCapabilities() (*common.Capabilities, error) { if cfg.Capabilities != nil { return cfg.Capabilities, nil } @@ -366,14 +367,14 @@ func (cfg *Configuration) getCapabilities() (*chartutil.Capabilities, error) { } } - cfg.Capabilities = &chartutil.Capabilities{ + cfg.Capabilities = &common.Capabilities{ APIVersions: apiVersions, - KubeVersion: chartutil.KubeVersion{ + KubeVersion: common.KubeVersion{ Version: kubeVersion.GitVersion, Major: kubeVersion.Major, Minor: kubeVersion.Minor, }, - HelmVersion: chartutil.DefaultCapabilities.HelmVersion, + HelmVersion: common.DefaultCapabilities.HelmVersion, } return cfg.Capabilities, nil } @@ -409,10 +410,10 @@ func (cfg *Configuration) releaseContent(name string, version int) (*release.Rel } // GetVersionSet retrieves a set of available k8s API versions -func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.VersionSet, error) { +func GetVersionSet(client discovery.ServerResourcesInterface) (common.VersionSet, error) { groups, resources, err := client.ServerGroupsAndResources() if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { - return chartutil.DefaultVersionSet, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err) + return common.DefaultVersionSet, fmt.Errorf("could not get apiVersions from Kubernetes: %w", err) } // FIXME: The Kubernetes test fixture for cli appears to always return nil @@ -420,7 +421,7 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version // return the default API list. This is also a safe value to return in any // other odd-ball case. if len(groups) == 0 && len(resources) == 0 { - return chartutil.DefaultVersionSet, nil + return common.DefaultVersionSet, nil } versionMap := make(map[string]interface{}) @@ -453,7 +454,7 @@ func GetVersionSet(client discovery.ServerResourcesInterface) (chartutil.Version versions = append(versions, k) } - return chartutil.VersionSet(versions), nil + return common.VersionSet(versions), nil } // recordRelease with an update operation in case reuse has been set. diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 7a510ace6..b65e40024 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -30,8 +30,8 @@ import ( fakeclientset "k8s.io/client-go/kubernetes/fake" "helm.sh/helm/v4/internal/logging" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" "helm.sh/helm/v4/pkg/registry" @@ -64,7 +64,7 @@ func actionConfigFixtureWithDummyResources(t *testing.T, dummyResources kube.Res return &Configuration{ Releases: storage.Init(driver.NewMemory()), KubeClient: &kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: dummyResources}, - Capabilities: chartutil.DefaultCapabilities, + Capabilities: common.DefaultCapabilities, RegistryClient: registryClient, } } @@ -122,14 +122,14 @@ type chartOptions struct { type chartOption func(*chartOptions) func buildChart(opts ...chartOption) *chart.Chart { - defaultTemplates := []*chart.File{ + defaultTemplates := []*common.File{ {Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hooks", Data: []byte(manifestWithHook)}, } return buildChartWithTemplates(defaultTemplates, opts...) } -func buildChartWithTemplates(templates []*chart.File, opts ...chartOption) *chart.Chart { +func buildChartWithTemplates(templates []*common.File, opts ...chartOption) *chart.Chart { c := &chartOptions{ Chart: &chart.Chart{ // TODO: This should be more complete. @@ -179,7 +179,7 @@ func withValues(values map[string]interface{}) chartOption { func withNotes(notes string) chartOption { return func(opts *chartOptions) { - opts.Templates = append(opts.Templates, &chart.File{ + opts.Templates = append(opts.Templates, &common.File{ Name: "templates/NOTES.txt", Data: []byte(notes), }) @@ -200,7 +200,7 @@ func withMetadataDependency(dependency chart.Dependency) chartOption { func withSampleTemplates() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ + sampleTemplates := []*common.File{ // This adds basic templates and partials. {Name: "templates/goodbye", Data: []byte("goodbye: world")}, {Name: "templates/empty", Data: []byte("")}, @@ -213,14 +213,14 @@ func withSampleTemplates() chartOption { func withSampleSecret() chartOption { return func(opts *chartOptions) { - sampleSecret := &chart.File{Name: "templates/secret.yaml", Data: []byte("apiVersion: v1\nkind: Secret\n")} + sampleSecret := &common.File{Name: "templates/secret.yaml", Data: []byte("apiVersion: v1\nkind: Secret\n")} opts.Templates = append(opts.Templates, sampleSecret) } } func withSampleIncludingIncorrectTemplates() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ + sampleTemplates := []*common.File{ // This adds basic templates and partials. {Name: "templates/goodbye", Data: []byte("goodbye: world")}, {Name: "templates/empty", Data: []byte("")}, @@ -234,7 +234,7 @@ func withSampleIncludingIncorrectTemplates() chartOption { func withMultipleManifestTemplate() chartOption { return func(opts *chartOptions) { - sampleTemplates := []*chart.File{ + sampleTemplates := []*common.File{ {Name: "templates/rbac", Data: []byte(rbacManifests)}, } opts.Templates = append(opts.Templates, sampleTemplates...) @@ -851,7 +851,7 @@ func TestRenderResources_PostRenderer_MergeError(t *testing.T) { Name: "test-chart", Version: "0.1.0", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/invalid", Data: []byte("invalid: yaml: content:")}, }, } diff --git a/pkg/action/get_values.go b/pkg/action/get_values.go index 18b8b4838..a0b5d92c1 100644 --- a/pkg/action/get_values.go +++ b/pkg/action/get_values.go @@ -16,9 +16,7 @@ limitations under the License. package action -import ( - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" -) +import "helm.sh/helm/v4/pkg/chart/common/util" // GetValues is the action for checking a given release's values. // @@ -50,7 +48,7 @@ func (g *GetValues) Run(name string) (map[string]interface{}, error) { // If the user wants all values, compute the values and return. if g.AllValues { - cfg, err := chartutil.CoalesceValues(rel.Chart, rel.Config) + cfg, err := util.CoalesceValues(rel.Chart, rel.Config) if err != nil { return nil, err } diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index e3a2c0808..091155bc2 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -29,8 +29,7 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/cli-runtime/pkg/resource" - chart "helm.sh/helm/v4/pkg/chart/v2" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" @@ -178,7 +177,7 @@ func runInstallForHooksWithSuccess(t *testing.T, manifest, expectedNamespace str outBuffer := &bytes.Buffer{} instAction.cfg.KubeClient = &kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} - templates := []*chart.File{ + templates := []*common.File{ {Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hooks", Data: []byte(manifest)}, } @@ -205,7 +204,7 @@ func runInstallForHooksWithFailure(t *testing.T, manifest, expectedNamespace str outBuffer := &bytes.Buffer{} failingClient.PrintingKubeClient = kubefake.PrintingKubeClient{Out: io.Discard, LogOutput: outBuffer} - templates := []*chart.File{ + templates := []*common.File{ {Name: "templates/hello", Data: []byte("hello: world")}, {Name: "templates/hooks", Data: []byte(manifest)}, } @@ -382,7 +381,7 @@ data: configuration := &Configuration{ Releases: storage.Init(driver.NewMemory()), KubeClient: kubeClient, - Capabilities: chartutil.DefaultCapabilities, + Capabilities: common.DefaultCapabilities, } serverSideApply := true diff --git a/pkg/action/install.go b/pkg/action/install.go index b2330d551..0fe3ebc4b 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -41,6 +41,8 @@ import ( "k8s.io/cli-runtime/pkg/resource" "sigs.k8s.io/yaml" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" chart "helm.sh/helm/v4/pkg/chart/v2" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/cli" @@ -113,8 +115,8 @@ type Install struct { // KubeVersion allows specifying a custom kubernetes version to use and // APIVersions allows a manual set of supported API Versions to be passed // (for things like templating). These are ignored if ClientOnly is false - KubeVersion *chartutil.KubeVersion - APIVersions chartutil.VersionSet + KubeVersion *common.KubeVersion + APIVersions common.VersionSet // Used by helm template to render charts with .Release.IsUpgrade. Ignored if Dry-Run is false IsUpgrade bool // Enable DNS lookups when rendering templates @@ -292,7 +294,7 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma if i.ClientOnly { // Add mock objects in here so it doesn't use Kube API server // NOTE(bacongobbler): used for `helm template` - i.cfg.Capabilities = chartutil.DefaultCapabilities.Copy() + i.cfg.Capabilities = common.DefaultCapabilities.Copy() if i.KubeVersion != nil { i.cfg.Capabilities.KubeVersion = *i.KubeVersion } @@ -319,14 +321,14 @@ func (i *Install) RunWithContext(ctx context.Context, chrt *chart.Chart, vals ma // special case for helm template --is-upgrade isUpgrade := i.IsUpgrade && i.isDryRun() - options := chartutil.ReleaseOptions{ + options := common.ReleaseOptions{ Name: i.ReleaseName, Namespace: i.Namespace, Revision: 1, IsInstall: !isUpgrade, IsUpgrade: isUpgrade, } - valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chrt, vals, options, caps, i.SkipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chrt, vals, options, caps, i.SkipSchemaValidation) if err != nil { return nil, err } diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index f567b3df4..92bb64b4d 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -45,8 +45,7 @@ import ( "k8s.io/client-go/rest/fake" "helm.sh/helm/v4/internal/test" - chart "helm.sh/helm/v4/pkg/chart/v2" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/kube" kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" @@ -258,7 +257,7 @@ func TestInstallReleaseClientOnly(t *testing.T) { instAction.ClientOnly = true instAction.Run(buildChart(), nil) // disregard output - is.Equal(instAction.cfg.Capabilities, chartutil.DefaultCapabilities) + is.Equal(instAction.cfg.Capabilities, common.DefaultCapabilities) is.Equal(instAction.cfg.KubeClient, &kubefake.PrintingKubeClient{Out: io.Discard}) } @@ -429,7 +428,7 @@ func TestInstallRelease_DryRun_Lookup(t *testing.T) { vals := map[string]interface{}{} mockChart := buildChart(withSampleTemplates()) - mockChart.Templates = append(mockChart.Templates, &chart.File{ + mockChart.Templates = append(mockChart.Templates, &common.File{ Name: "templates/lookup", Data: []byte(`goodbye: {{ lookup "v1" "Namespace" "" "___" }}`), }) diff --git a/pkg/action/lint.go b/pkg/action/lint.go index 7b3c00ad2..208fd4637 100644 --- a/pkg/action/lint.go +++ b/pkg/action/lint.go @@ -22,9 +22,10 @@ import ( "path/filepath" "strings" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/v2/lint" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint" - "helm.sh/helm/v4/pkg/lint/support" ) // Lint is the action for checking that the semantics of a chart are well-formed. @@ -36,7 +37,7 @@ type Lint struct { WithSubcharts bool Quiet bool SkipSchemaValidation bool - KubeVersion *chartutil.KubeVersion + KubeVersion *common.KubeVersion } // LintResult is the result of Lint @@ -86,7 +87,7 @@ func HasWarningsOrErrors(result *LintResult) bool { return len(result.Errors) > 0 } -func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion, skipSchemaValidation bool) (support.Linter, error) { +func lintChart(path string, vals map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) (support.Linter, error) { var chartPath string linter := support.Linter{} diff --git a/pkg/action/show.go b/pkg/action/show.go index 6d6e10d24..4195d69a5 100644 --- a/pkg/action/show.go +++ b/pkg/action/show.go @@ -24,6 +24,7 @@ import ( "k8s.io/cli-runtime/pkg/printers" "sigs.k8s.io/yaml" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" @@ -140,7 +141,7 @@ func (s *Show) Run(chartpath string) (string, error) { return out.String(), nil } -func findReadme(files []*chart.File) (file *chart.File) { +func findReadme(files []*common.File) (file *common.File) { for _, file := range files { for _, n := range readmeFileNames { if file == nil { diff --git a/pkg/action/show_test.go b/pkg/action/show_test.go index 67eba2338..faf306f2a 100644 --- a/pkg/action/show_test.go +++ b/pkg/action/show_test.go @@ -19,6 +19,7 @@ package action import ( "testing" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) @@ -27,14 +28,14 @@ func TestShow(t *testing.T) { client := NewShow(ShowAll, config) client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, - Files: []*chart.File{ + Files: []*common.File{ {Name: "README.md", Data: []byte("README\n")}, {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{ + Raw: []*common.File{ {Name: "values.yaml", Data: []byte("VALUES\n")}, }, Values: map[string]interface{}{}, @@ -105,7 +106,7 @@ func TestShowCRDs(t *testing.T) { client := NewShow(ShowCRDs, config) client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, - Files: []*chart.File{ + Files: []*common.File{ {Name: "crds/ignoreme.txt", Data: []byte("error")}, {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, @@ -138,7 +139,7 @@ func TestShowNoReadme(t *testing.T) { client := NewShow(ShowAll, config) client.chart = &chart.Chart{ Metadata: &chart.Metadata{Name: "alpine"}, - Files: []*chart.File{ + Files: []*common.File{ {Name: "crds/ignoreme.txt", Data: []byte("error")}, {Name: "crds/foo.yaml", Data: []byte("---\nfoo\n")}, {Name: "crds/bar.json", Data: []byte("---\nbar\n")}, diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index c00a59079..3688adf0e 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -28,6 +28,8 @@ import ( "k8s.io/cli-runtime/pkg/resource" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" chart "helm.sh/helm/v4/pkg/chart/v2" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" @@ -260,7 +262,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin // the release object. revision := lastRelease.Version + 1 - options := chartutil.ReleaseOptions{ + options := common.ReleaseOptions{ Name: name, Namespace: currentRelease.Namespace, Revision: revision, @@ -271,7 +273,7 @@ func (u *Upgrade) prepareUpgrade(name string, chart *chart.Chart, vals map[strin if err != nil { return nil, nil, false, err } - valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, vals, options, caps, u.SkipSchemaValidation) if err != nil { return nil, nil, false, err } @@ -588,12 +590,12 @@ func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newV slog.Debug("reusing the old release's values") // We have to regenerate the old coalesced values: - oldVals, err := chartutil.CoalesceValues(current.Chart, current.Config) + oldVals, err := util.CoalesceValues(current.Chart, current.Config) if err != nil { return nil, fmt.Errorf("failed to rebuild old values: %w", err) } - newVals = chartutil.CoalesceTables(newVals, current.Config) + newVals = util.CoalesceTables(newVals, current.Config) chart.Values = oldVals @@ -604,7 +606,7 @@ func (u *Upgrade) reuseValues(chart *chart.Chart, current *release.Release, newV if u.ResetThenReuseValues { slog.Debug("merging values from old release to new values") - newVals = chartutil.CoalesceTables(newVals, current.Config) + newVals = util.CoalesceTables(newVals, current.Config) return newVals, nil } diff --git a/pkg/chart/common.go b/pkg/chart/common.go new file mode 100644 index 000000000..8b1dd58c3 --- /dev/null +++ b/pkg/chart/common.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 chart + +import ( + "errors" + "fmt" + "log/slog" + "reflect" + "strings" + + v3chart "helm.sh/helm/v4/internal/chart/v3" + common "helm.sh/helm/v4/pkg/chart/common" + v2chart "helm.sh/helm/v4/pkg/chart/v2" +) + +var NewAccessor func(chrt Charter) (Accessor, error) = NewDefaultAccessor //nolint:revive + +func NewDefaultAccessor(chrt Charter) (Accessor, error) { + switch v := chrt.(type) { + case v2chart.Chart: + return &v2Accessor{&v}, nil + case *v2chart.Chart: + return &v2Accessor{v}, nil + case v3chart.Chart: + return &v3Accessor{&v}, nil + case *v3chart.Chart: + return &v3Accessor{v}, nil + default: + return nil, errors.New("unsupported chart type") + } +} + +type v2Accessor struct { + chrt *v2chart.Chart +} + +func (r *v2Accessor) Name() string { + return r.chrt.Metadata.Name +} + +func (r *v2Accessor) IsRoot() bool { + return r.chrt.IsRoot() +} + +func (r *v2Accessor) MetadataAsMap() map[string]interface{} { + var ret map[string]interface{} + if r.chrt.Metadata == nil { + return ret + } + + ret, err := structToMap(r.chrt.Metadata) + if err != nil { + slog.Error("error converting metadata to map", "error", err) + } + return ret +} + +func (r *v2Accessor) Files() []*common.File { + return r.chrt.Files +} + +func (r *v2Accessor) Templates() []*common.File { + return r.chrt.Templates +} + +func (r *v2Accessor) ChartFullPath() string { + return r.chrt.ChartFullPath() +} + +func (r *v2Accessor) IsLibraryChart() bool { + return strings.EqualFold(r.chrt.Metadata.Type, "library") +} + +func (r *v2Accessor) Dependencies() []Charter { + var deps = make([]Charter, len(r.chrt.Dependencies())) + for i, c := range r.chrt.Dependencies() { + deps[i] = c + } + return deps +} + +func (r *v2Accessor) Values() map[string]interface{} { + return r.chrt.Values +} + +func (r *v2Accessor) Schema() []byte { + return r.chrt.Schema +} + +type v3Accessor struct { + chrt *v3chart.Chart +} + +func (r *v3Accessor) Name() string { + return r.chrt.Metadata.Name +} + +func (r *v3Accessor) IsRoot() bool { + return r.chrt.IsRoot() +} + +func (r *v3Accessor) MetadataAsMap() map[string]interface{} { + var ret map[string]interface{} + if r.chrt.Metadata == nil { + return ret + } + + ret, err := structToMap(r.chrt.Metadata) + if err != nil { + slog.Error("error converting metadata to map", "error", err) + } + return ret +} + +func (r *v3Accessor) Files() []*common.File { + return r.chrt.Files +} + +func (r *v3Accessor) Templates() []*common.File { + return r.chrt.Templates +} + +func (r *v3Accessor) ChartFullPath() string { + return r.chrt.ChartFullPath() +} + +func (r *v3Accessor) IsLibraryChart() bool { + return strings.EqualFold(r.chrt.Metadata.Type, "library") +} + +func (r *v3Accessor) Dependencies() []Charter { + var deps = make([]Charter, len(r.chrt.Dependencies())) + for i, c := range r.chrt.Dependencies() { + deps[i] = c + } + return deps +} + +func (r *v3Accessor) Values() map[string]interface{} { + return r.chrt.Values +} + +func (r *v3Accessor) Schema() []byte { + return r.chrt.Schema +} + +func structToMap(obj interface{}) (map[string]interface{}, error) { + objValue := reflect.ValueOf(obj) + + // If the value is a pointer, dereference it + if objValue.Kind() == reflect.Ptr { + objValue = objValue.Elem() + } + + // Check if the input is a struct + if objValue.Kind() != reflect.Struct { + return nil, fmt.Errorf("input must be a struct or a pointer to a struct") + } + + result := make(map[string]interface{}) + objType := objValue.Type() + + for i := 0; i < objValue.NumField(); i++ { + field := objType.Field(i) + value := objValue.Field(i) + + switch value.Kind() { + case reflect.Struct: + nestedMap, err := structToMap(value.Interface()) + if err != nil { + return nil, err + } + result[field.Name] = nestedMap + case reflect.Ptr: + // Recurse for pointers by dereferencing + if value.IsNil() { + result[field.Name] = nil + } else { + nestedMap, err := structToMap(value.Interface()) + if err != nil { + return nil, err + } + result[field.Name] = nestedMap + } + case reflect.Slice: + sliceOfMaps := make([]interface{}, value.Len()) + for j := 0; j < value.Len(); j++ { + sliceElement := value.Index(j) + if sliceElement.Kind() == reflect.Struct || sliceElement.Kind() == reflect.Ptr { + nestedMap, err := structToMap(sliceElement.Interface()) + if err != nil { + return nil, err + } + sliceOfMaps[j] = nestedMap + } else { + sliceOfMaps[j] = sliceElement.Interface() + } + } + result[field.Name] = sliceOfMaps + default: + result[field.Name] = value.Interface() + } + } + return result, nil +} diff --git a/pkg/chart/v2/util/capabilities.go b/pkg/chart/common/capabilities.go similarity index 99% rename from pkg/chart/v2/util/capabilities.go rename to pkg/chart/common/capabilities.go index 19d62c5e3..355c3978a 100644 --- a/pkg/chart/v2/util/capabilities.go +++ b/pkg/chart/common/capabilities.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "fmt" diff --git a/pkg/chart/v2/util/capabilities_test.go b/pkg/chart/common/capabilities_test.go similarity index 99% rename from pkg/chart/v2/util/capabilities_test.go rename to pkg/chart/common/capabilities_test.go index e5513b3fd..bf32b1f3f 100644 --- a/pkg/chart/v2/util/capabilities_test.go +++ b/pkg/chart/common/capabilities_test.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "testing" diff --git a/pkg/chart/v2/util/errors.go b/pkg/chart/common/errors.go similarity index 98% rename from pkg/chart/v2/util/errors.go rename to pkg/chart/common/errors.go index a175b9758..b0a2d650e 100644 --- a/pkg/chart/v2/util/errors.go +++ b/pkg/chart/common/errors.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "fmt" diff --git a/pkg/chart/v2/util/errors_test.go b/pkg/chart/common/errors_test.go similarity index 98% rename from pkg/chart/v2/util/errors_test.go rename to pkg/chart/common/errors_test.go index b8ae86384..06b3b054c 100644 --- a/pkg/chart/v2/util/errors_test.go +++ b/pkg/chart/common/errors_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "testing" diff --git a/internal/chart/v3/file.go b/pkg/chart/common/file.go similarity index 98% rename from internal/chart/v3/file.go rename to pkg/chart/common/file.go index ba04e106d..304643f1a 100644 --- a/internal/chart/v3/file.go +++ b/pkg/chart/common/file.go @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package v3 +package common // File represents a file as a name/value pair. // diff --git a/pkg/chart/v2/util/testdata/coleridge.yaml b/pkg/chart/common/testdata/coleridge.yaml similarity index 100% rename from pkg/chart/v2/util/testdata/coleridge.yaml rename to pkg/chart/common/testdata/coleridge.yaml diff --git a/pkg/chart/v2/util/coalesce.go b/pkg/chart/common/util/coalesce.go similarity index 81% rename from pkg/chart/v2/util/coalesce.go rename to pkg/chart/common/util/coalesce.go index a3e0f5ae8..5bfa1c608 100644 --- a/pkg/chart/v2/util/coalesce.go +++ b/pkg/chart/common/util/coalesce.go @@ -23,7 +23,8 @@ import ( "github.com/mitchellh/copystructure" - chart "helm.sh/helm/v4/pkg/chart/v2" + chart "helm.sh/helm/v4/pkg/chart" + "helm.sh/helm/v4/pkg/chart/common" ) func concatPrefix(a, b string) string { @@ -42,7 +43,7 @@ func concatPrefix(a, b string) string { // - Scalar values and arrays are replaced, maps are merged // - A chart has access to all of the variables for it, as well as all of // the values destined for its dependencies. -func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { +func CoalesceValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { valsCopy, err := copyValues(vals) if err != nil { return vals, err @@ -64,7 +65,7 @@ func CoalesceValues(chrt *chart.Chart, vals map[string]interface{}) (Values, err // Retaining Nils is useful when processes early in a Helm action or business // logic need to retain them for when Coalescing will happen again later in the // business logic. -func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) { +func MergeValues(chrt chart.Charter, vals map[string]interface{}) (common.Values, error) { valsCopy, err := copyValues(vals) if err != nil { return vals, err @@ -72,7 +73,7 @@ func MergeValues(chrt *chart.Chart, vals map[string]interface{}) (Values, error) return coalesce(log.Printf, chrt, valsCopy, "", true) } -func copyValues(vals map[string]interface{}) (Values, error) { +func copyValues(vals map[string]interface{}) (common.Values, error) { v, err := copystructure.Copy(vals) if err != nil { return vals, err @@ -96,28 +97,36 @@ type printFn func(format string, v ...interface{}) // Note, the merge argument specifies whether this is being used by MergeValues // or CoalesceValues. Coalescing removes null values and their keys in some // situations while merging keeps the null values. -func coalesce(printf printFn, ch *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { +func coalesce(printf printFn, ch chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { coalesceValues(printf, ch, dest, prefix, merge) return coalesceDeps(printf, ch, dest, prefix, merge) } // coalesceDeps coalesces the dependencies of the given chart. -func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { - for _, subchart := range chrt.Dependencies() { - if c, ok := dest[subchart.Name()]; !ok { +func coalesceDeps(printf printFn, chrt chart.Charter, dest map[string]interface{}, prefix string, merge bool) (map[string]interface{}, error) { + ch, err := chart.NewAccessor(chrt) + if err != nil { + return dest, err + } + for _, subchart := range ch.Dependencies() { + sub, err := chart.NewAccessor(subchart) + if err != nil { + return dest, err + } + if c, ok := dest[sub.Name()]; !ok { // If dest doesn't already have the key, create it. - dest[subchart.Name()] = make(map[string]interface{}) + dest[sub.Name()] = make(map[string]interface{}) } else if !istable(c) { - return dest, fmt.Errorf("type mismatch on %s: %t", subchart.Name(), c) + return dest, fmt.Errorf("type mismatch on %s: %t", sub.Name(), c) } - if dv, ok := dest[subchart.Name()]; ok { + if dv, ok := dest[sub.Name()]; ok { dvmap := dv.(map[string]interface{}) - subPrefix := concatPrefix(prefix, chrt.Metadata.Name) + subPrefix := concatPrefix(prefix, ch.Name()) // Get globals out of dest and merge them into dvmap. coalesceGlobals(printf, dvmap, dest, subPrefix, merge) // Now coalesce the rest of the values. var err error - dest[subchart.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) + dest[sub.Name()], err = coalesce(printf, subchart, dvmap, subPrefix, merge) if err != nil { return dest, err } @@ -132,17 +141,17 @@ func coalesceDeps(printf printFn, chrt *chart.Chart, dest map[string]interface{} func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix string, _ bool) { var dg, sg map[string]interface{} - if destglob, ok := dest[GlobalKey]; !ok { + if destglob, ok := dest[common.GlobalKey]; !ok { dg = make(map[string]interface{}) } else if dg, ok = destglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because destination %s is not a table.", GlobalKey) + printf("warning: skipping globals because destination %s is not a table.", common.GlobalKey) return } - if srcglob, ok := src[GlobalKey]; !ok { + if srcglob, ok := src[common.GlobalKey]; !ok { sg = make(map[string]interface{}) } else if sg, ok = srcglob.(map[string]interface{}); !ok { - printf("warning: skipping globals because source %s is not a table.", GlobalKey) + printf("warning: skipping globals because source %s is not a table.", common.GlobalKey) return } @@ -178,7 +187,7 @@ func coalesceGlobals(printf printFn, dest, src map[string]interface{}, prefix st dg[key] = val } } - dest[GlobalKey] = dg + dest[common.GlobalKey] = dg } func copyMap(src map[string]interface{}) map[string]interface{} { @@ -190,13 +199,18 @@ func copyMap(src map[string]interface{}) map[string]interface{} { // coalesceValues builds up a values map for a particular chart. // // Values in v will override the values in the chart. -func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, prefix string, merge bool) { - subPrefix := concatPrefix(prefix, c.Metadata.Name) +func coalesceValues(printf printFn, c chart.Charter, v map[string]interface{}, prefix string, merge bool) { + ch, err := chart.NewAccessor(c) + if err != nil { + return + } + + subPrefix := concatPrefix(prefix, ch.Name()) // Using c.Values directly when coalescing a table can cause problems where // the original c.Values is altered. Creating a deep copy stops the problem. // This section is fault-tolerant as there is no ability to return an error. - valuesCopy, err := copystructure.Copy(c.Values) + valuesCopy, err := copystructure.Copy(ch.Values()) var vc map[string]interface{} var ok bool if err != nil { @@ -205,7 +219,7 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr // wrong with c.Values. In this case we will use c.Values and report // an error. printf("warning: unable to copy values, err: %s", err) - vc = c.Values + vc = ch.Values() } else { vc, ok = valuesCopy.(map[string]interface{}) if !ok { @@ -213,7 +227,7 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr // it cannot be treated as map[string]interface{} there is something // strangely wrong. Log it and use c.Values printf("warning: unable to convert values copy to values type") - vc = c.Values + vc = ch.Values() } } @@ -250,9 +264,17 @@ func coalesceValues(printf printFn, c *chart.Chart, v map[string]interface{}, pr } } -func childChartMergeTrue(chrt *chart.Chart, key string, merge bool) bool { - for _, subchart := range chrt.Dependencies() { - if subchart.Name() == key { +func childChartMergeTrue(chrt chart.Charter, key string, merge bool) bool { + ch, err := chart.NewAccessor(chrt) + if err != nil { + return merge + } + for _, subchart := range ch.Dependencies() { + sub, err := chart.NewAccessor(subchart) + if err != nil { + return merge + } + if sub.Name() == key { return true } } @@ -306,3 +328,9 @@ func coalesceTablesFullKey(printf printFn, dst, src map[string]interface{}, pref } return dst } + +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} diff --git a/pkg/chart/v2/util/coalesce_test.go b/pkg/chart/common/util/coalesce_test.go similarity index 97% rename from pkg/chart/v2/util/coalesce_test.go rename to pkg/chart/common/util/coalesce_test.go index e2c45a435..871bfa8da 100644 --- a/pkg/chart/v2/util/coalesce_test.go +++ b/pkg/chart/common/util/coalesce_test.go @@ -17,13 +17,16 @@ limitations under the License. package util import ( + "bytes" "encoding/json" "fmt" "maps" "testing" + "text/template" "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) @@ -136,7 +139,7 @@ func TestCoalesceValues(t *testing.T) { }, ) - vals, err := ReadValues(testCoalesceValuesYaml) + vals, err := common.ReadValues(testCoalesceValuesYaml) if err != nil { t.Fatal(err) } @@ -144,7 +147,7 @@ func TestCoalesceValues(t *testing.T) { // taking a copy of the values before passing it // to CoalesceValues as argument, so that we can // use it for asserting later - valsCopy := make(Values, len(vals)) + valsCopy := make(common.Values, len(vals)) maps.Copy(valsCopy, vals) v, err := CoalesceValues(c, vals) @@ -238,6 +241,13 @@ func TestCoalesceValues(t *testing.T) { is.Equal(valsCopy, vals) } +func ttpl(tpl string, v map[string]interface{}) (string, error) { + var b bytes.Buffer + tt := template.Must(template.New("t").Parse(tpl)) + err := tt.Execute(&b, v) + return b.String(), err +} + func TestMergeValues(t *testing.T) { is := assert.New(t) @@ -294,7 +304,7 @@ func TestMergeValues(t *testing.T) { }, ) - vals, err := ReadValues(testCoalesceValuesYaml) + vals, err := common.ReadValues(testCoalesceValuesYaml) if err != nil { t.Fatal(err) } @@ -302,7 +312,7 @@ func TestMergeValues(t *testing.T) { // taking a copy of the values before passing it // to MergeValues as argument, so that we can // use it for asserting later - valsCopy := make(Values, len(vals)) + valsCopy := make(common.Values, len(vals)) maps.Copy(valsCopy, vals) v, err := MergeValues(c, vals) diff --git a/pkg/chart/v2/util/jsonschema.go b/pkg/chart/common/util/jsonschema.go similarity index 89% rename from pkg/chart/v2/util/jsonschema.go rename to pkg/chart/common/util/jsonschema.go index 72e133363..acd2ca100 100644 --- a/pkg/chart/v2/util/jsonschema.go +++ b/pkg/chart/common/util/jsonschema.go @@ -30,7 +30,8 @@ import ( "helm.sh/helm/v4/internal/version" - chart "helm.sh/helm/v4/pkg/chart/v2" + chart "helm.sh/helm/v4/pkg/chart" + "helm.sh/helm/v4/pkg/chart/common" ) // HTTPURLLoader implements a loader for HTTP/HTTPS URLs @@ -71,11 +72,15 @@ func newHTTPURLLoader() *HTTPURLLoader { } // ValidateAgainstSchema checks that values does not violate the structure laid out in schema -func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) error { +func ValidateAgainstSchema(ch chart.Charter, values map[string]interface{}) error { + chrt, err := chart.NewAccessor(ch) + if err != nil { + return err + } var sb strings.Builder - if chrt.Schema != nil { + if chrt.Schema() != nil { slog.Debug("chart name", "chart-name", chrt.Name()) - err := ValidateAgainstSingleSchema(values, chrt.Schema) + err := ValidateAgainstSingleSchema(values, chrt.Schema()) if err != nil { sb.WriteString(fmt.Sprintf("%s:\n", chrt.Name())) sb.WriteString(err.Error()) @@ -84,7 +89,11 @@ func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) err slog.Debug("number of dependencies in the chart", "dependencies", len(chrt.Dependencies())) // For each dependency, recursively call this function with the coalesced values for _, subchart := range chrt.Dependencies() { - subchartValues := values[subchart.Name()].(map[string]interface{}) + sub, err := chart.NewAccessor(subchart) + if err != nil { + return err + } + subchartValues := values[sub.Name()].(map[string]interface{}) if err := ValidateAgainstSchema(subchart, subchartValues); err != nil { sb.WriteString(err.Error()) } @@ -98,7 +107,7 @@ func ValidateAgainstSchema(chrt *chart.Chart, values map[string]interface{}) err } // ValidateAgainstSingleSchema checks that values does not violate the structure laid out in this schema -func ValidateAgainstSingleSchema(values Values, schemaJSON []byte) (reterr error) { +func ValidateAgainstSingleSchema(values common.Values, schemaJSON []byte) (reterr error) { defer func() { if r := recover(); r != nil { reterr = fmt.Errorf("unable to validate schema: %s", r) diff --git a/pkg/chart/v2/util/jsonschema_test.go b/pkg/chart/common/util/jsonschema_test.go similarity index 96% rename from pkg/chart/v2/util/jsonschema_test.go rename to pkg/chart/common/util/jsonschema_test.go index cd95b7faf..b34f9d514 100644 --- a/pkg/chart/v2/util/jsonschema_test.go +++ b/pkg/chart/common/util/jsonschema_test.go @@ -23,11 +23,12 @@ import ( "strings" "testing" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) func TestValidateAgainstSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") + values, err := common.ReadValuesFile("./testdata/test-values.yaml") if err != nil { t.Fatalf("Error reading YAML file: %s", err) } @@ -42,7 +43,7 @@ func TestValidateAgainstSingleSchema(t *testing.T) { } func TestValidateAgainstInvalidSingleSchema(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values.yaml") + values, err := common.ReadValuesFile("./testdata/test-values.yaml") if err != nil { t.Fatalf("Error reading YAML file: %s", err) } @@ -66,7 +67,7 @@ func TestValidateAgainstInvalidSingleSchema(t *testing.T) { } func TestValidateAgainstSingleSchemaNegative(t *testing.T) { - values, err := ReadValuesFile("./testdata/test-values-negative.yaml") + values, err := common.ReadValuesFile("./testdata/test-values-negative.yaml") if err != nil { t.Fatalf("Error reading YAML file: %s", err) } diff --git a/pkg/chart/v2/util/testdata/test-values-invalid.schema.json b/pkg/chart/common/util/testdata/test-values-invalid.schema.json similarity index 100% rename from pkg/chart/v2/util/testdata/test-values-invalid.schema.json rename to pkg/chart/common/util/testdata/test-values-invalid.schema.json diff --git a/pkg/chart/v2/util/testdata/test-values-negative.yaml b/pkg/chart/common/util/testdata/test-values-negative.yaml similarity index 100% rename from pkg/chart/v2/util/testdata/test-values-negative.yaml rename to pkg/chart/common/util/testdata/test-values-negative.yaml diff --git a/pkg/chart/v2/util/testdata/test-values.schema.json b/pkg/chart/common/util/testdata/test-values.schema.json similarity index 100% rename from pkg/chart/v2/util/testdata/test-values.schema.json rename to pkg/chart/common/util/testdata/test-values.schema.json diff --git a/pkg/chart/v2/util/testdata/test-values.yaml b/pkg/chart/common/util/testdata/test-values.yaml similarity index 100% rename from pkg/chart/v2/util/testdata/test-values.yaml rename to pkg/chart/common/util/testdata/test-values.yaml diff --git a/pkg/chart/common/util/values.go b/pkg/chart/common/util/values.go new file mode 100644 index 000000000..85cb29012 --- /dev/null +++ b/pkg/chart/common/util/values.go @@ -0,0 +1,70 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "fmt" + + "helm.sh/helm/v4/pkg/chart" + "helm.sh/helm/v4/pkg/chart/common" +) + +// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files +// +// This takes both ReleaseOptions and Capabilities to merge into the render values. +func ToRenderValues(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities) (common.Values, error) { + return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false) +} + +// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files +// +// This takes both ReleaseOptions and Capabilities to merge into the render values. +func ToRenderValuesWithSchemaValidation(chrt chart.Charter, chrtVals map[string]interface{}, options common.ReleaseOptions, caps *common.Capabilities, skipSchemaValidation bool) (common.Values, error) { + if caps == nil { + caps = common.DefaultCapabilities + } + accessor, err := chart.NewAccessor(chrt) + if err != nil { + return nil, err + } + top := map[string]interface{}{ + "Chart": accessor.MetadataAsMap(), + "Capabilities": caps, + "Release": map[string]interface{}{ + "Name": options.Name, + "Namespace": options.Namespace, + "IsUpgrade": options.IsUpgrade, + "IsInstall": options.IsInstall, + "Revision": options.Revision, + "Service": "Helm", + }, + } + + vals, err := CoalesceValues(chrt, chrtVals) + if err != nil { + return common.Values(top), err + } + + if !skipSchemaValidation { + if err := ValidateAgainstSchema(chrt, vals); err != nil { + return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err) + } + } + + top["Values"] = vals + return top, nil +} diff --git a/pkg/chart/common/util/values_test.go b/pkg/chart/common/util/values_test.go new file mode 100644 index 000000000..5fc030567 --- /dev/null +++ b/pkg/chart/common/util/values_test.go @@ -0,0 +1,111 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "testing" + + "helm.sh/helm/v4/pkg/chart/common" + chart "helm.sh/helm/v4/pkg/chart/v2" +) + +func TestToRenderValues(t *testing.T) { + + chartValues := map[string]interface{}{ + "name": "al Rashid", + "where": map[string]interface{}{ + "city": "Basrah", + "title": "caliph", + }, + } + + overrideValues := map[string]interface{}{ + "name": "Haroun", + "where": map[string]interface{}{ + "city": "Baghdad", + "date": "809 CE", + }, + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: "test"}, + Templates: []*common.File{}, + Values: chartValues, + Files: []*common.File{ + {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, + }, + } + c.AddDependency(&chart.Chart{ + Metadata: &chart.Metadata{Name: "where"}, + }) + + o := common.ReleaseOptions{ + Name: "Seven Voyages", + Namespace: "default", + Revision: 1, + IsInstall: true, + } + + res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false) + if err != nil { + t.Fatal(err) + } + + // Ensure that the top-level values are all set. + metamap := res["Chart"].(map[string]interface{}) + if name := metamap["Name"]; name.(string) != "test" { + t.Errorf("Expected chart name 'test', got %q", name) + } + relmap := res["Release"].(map[string]interface{}) + if name := relmap["Name"]; name.(string) != "Seven Voyages" { + t.Errorf("Expected release name 'Seven Voyages', got %q", name) + } + if namespace := relmap["Namespace"]; namespace.(string) != "default" { + t.Errorf("Expected namespace 'default', got %q", namespace) + } + if revision := relmap["Revision"]; revision.(int) != 1 { + t.Errorf("Expected revision '1', got %d", revision) + } + if relmap["IsUpgrade"].(bool) { + t.Error("Expected upgrade to be false.") + } + if !relmap["IsInstall"].(bool) { + t.Errorf("Expected install to be true.") + } + if !res["Capabilities"].(*common.Capabilities).APIVersions.Has("v1") { + t.Error("Expected Capabilities to have v1 as an API") + } + if res["Capabilities"].(*common.Capabilities).KubeVersion.Major != "1" { + t.Error("Expected Capabilities to have a Kube version") + } + + vals := res["Values"].(common.Values) + if vals["name"] != "Haroun" { + t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) + } + where := vals["where"].(map[string]interface{}) + expects := map[string]string{ + "city": "Baghdad", + "date": "809 CE", + "title": "caliph", + } + for field, expect := range expects { + if got := where[field]; got != expect { + t.Errorf("Expected %q, got %q (%v)", expect, got, where) + } + } +} diff --git a/pkg/chart/v2/util/values.go b/pkg/chart/common/values.go similarity index 74% rename from pkg/chart/v2/util/values.go rename to pkg/chart/common/values.go index 6850e8b9b..94958a779 100644 --- a/pkg/chart/v2/util/values.go +++ b/pkg/chart/common/values.go @@ -14,18 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "errors" - "fmt" "io" "os" "strings" "sigs.k8s.io/yaml" - - chart "helm.sh/helm/v4/pkg/chart/v2" ) // GlobalKey is the name of the Values key that is used for storing global vars. @@ -131,48 +128,6 @@ type ReleaseOptions struct { IsInstall bool } -// ToRenderValues composes the struct from the data coming from the Releases, Charts and Values files -// -// This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValues(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities) (Values, error) { - return ToRenderValuesWithSchemaValidation(chrt, chrtVals, options, caps, false) -} - -// ToRenderValuesWithSchemaValidation composes the struct from the data coming from the Releases, Charts and Values files -// -// This takes both ReleaseOptions and Capabilities to merge into the render values. -func ToRenderValuesWithSchemaValidation(chrt *chart.Chart, chrtVals map[string]interface{}, options ReleaseOptions, caps *Capabilities, skipSchemaValidation bool) (Values, error) { - if caps == nil { - caps = DefaultCapabilities - } - top := map[string]interface{}{ - "Chart": chrt.Metadata, - "Capabilities": caps, - "Release": map[string]interface{}{ - "Name": options.Name, - "Namespace": options.Namespace, - "IsUpgrade": options.IsUpgrade, - "IsInstall": options.IsInstall, - "Revision": options.Revision, - "Service": "Helm", - }, - } - - vals, err := CoalesceValues(chrt, chrtVals) - if err != nil { - return top, err - } - - if !skipSchemaValidation { - if err := ValidateAgainstSchema(chrt, vals); err != nil { - return top, fmt.Errorf("values don't meet the specifications of the schema(s) in the following chart(s):\n%w", err) - } - } - - top["Values"] = vals - return top, nil -} - // istable is a special-purpose function to see if the present thing matches the definition of a YAML table. func istable(v interface{}) bool { _, ok := v.(map[string]interface{}) diff --git a/pkg/chart/v2/util/values_test.go b/pkg/chart/common/values_test.go similarity index 66% rename from pkg/chart/v2/util/values_test.go rename to pkg/chart/common/values_test.go index 1a25fafb8..3cceeb2b5 100644 --- a/pkg/chart/v2/util/values_test.go +++ b/pkg/chart/common/values_test.go @@ -14,15 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -package util +package common import ( "bytes" "fmt" "testing" "text/template" - - chart "helm.sh/helm/v4/pkg/chart/v2" ) func TestReadValues(t *testing.T) { @@ -66,92 +64,6 @@ water: } } -func TestToRenderValues(t *testing.T) { - - chartValues := map[string]interface{}{ - "name": "al Rashid", - "where": map[string]interface{}{ - "city": "Basrah", - "title": "caliph", - }, - } - - overrideValues := map[string]interface{}{ - "name": "Haroun", - "where": map[string]interface{}{ - "city": "Baghdad", - "date": "809 CE", - }, - } - - c := &chart.Chart{ - Metadata: &chart.Metadata{Name: "test"}, - Templates: []*chart.File{}, - Values: chartValues, - Files: []*chart.File{ - {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, - }, - } - c.AddDependency(&chart.Chart{ - Metadata: &chart.Metadata{Name: "where"}, - }) - - o := ReleaseOptions{ - Name: "Seven Voyages", - Namespace: "default", - Revision: 1, - IsInstall: true, - } - - res, err := ToRenderValuesWithSchemaValidation(c, overrideValues, o, nil, false) - if err != nil { - t.Fatal(err) - } - - // Ensure that the top-level values are all set. - if name := res["Chart"].(*chart.Metadata).Name; name != "test" { - t.Errorf("Expected chart name 'test', got %q", name) - } - relmap := res["Release"].(map[string]interface{}) - if name := relmap["Name"]; name.(string) != "Seven Voyages" { - t.Errorf("Expected release name 'Seven Voyages', got %q", name) - } - if namespace := relmap["Namespace"]; namespace.(string) != "default" { - t.Errorf("Expected namespace 'default', got %q", namespace) - } - if revision := relmap["Revision"]; revision.(int) != 1 { - t.Errorf("Expected revision '1', got %d", revision) - } - if relmap["IsUpgrade"].(bool) { - t.Error("Expected upgrade to be false.") - } - if !relmap["IsInstall"].(bool) { - t.Errorf("Expected install to be true.") - } - if !res["Capabilities"].(*Capabilities).APIVersions.Has("v1") { - t.Error("Expected Capabilities to have v1 as an API") - } - if res["Capabilities"].(*Capabilities).KubeVersion.Major != "1" { - t.Error("Expected Capabilities to have a Kube version") - } - - vals := res["Values"].(Values) - if vals["name"] != "Haroun" { - t.Errorf("Expected 'Haroun', got %q (%v)", vals["name"], vals) - } - where := vals["where"].(map[string]interface{}) - expects := map[string]string{ - "city": "Baghdad", - "date": "809 CE", - "title": "caliph", - } - for field, expect := range expects { - if got := where[field]; got != expect { - t.Errorf("Expected %q, got %q (%v)", expect, got, where) - } - } -} - func TestReadValuesFile(t *testing.T) { data, err := ReadValuesFile("./testdata/coleridge.yaml") if err != nil { diff --git a/pkg/chart/v2/file.go b/pkg/chart/interfaces.go similarity index 60% rename from pkg/chart/v2/file.go rename to pkg/chart/interfaces.go index a2eeb0fcd..e87dd2c08 100644 --- a/pkg/chart/v2/file.go +++ b/pkg/chart/interfaces.go @@ -13,15 +13,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -package v2 +package chart -// File represents a file as a name/value pair. -// -// By convention, name is a relative path within the scope of the chart's -// base directory. -type File struct { - // Name is the path-like name of the template. - Name string `json:"name"` - // Data is the template as byte data. - Data []byte `json:"data"` +import ( + common "helm.sh/helm/v4/pkg/chart/common" +) + +type Charter interface{} + +type Accessor interface { + Name() string + IsRoot() bool + MetadataAsMap() map[string]interface{} + Files() []*common.File + Templates() []*common.File + ChartFullPath() string + IsLibraryChart() bool + Dependencies() []Charter + Values() map[string]interface{} + Schema() []byte } diff --git a/pkg/chart/v2/chart.go b/pkg/chart/v2/chart.go index 66ddf98a5..f59bcd8b3 100644 --- a/pkg/chart/v2/chart.go +++ b/pkg/chart/v2/chart.go @@ -19,6 +19,8 @@ import ( "path/filepath" "regexp" "strings" + + "helm.sh/helm/v4/pkg/chart/common" ) // APIVersionV1 is the API version number for version 1. @@ -37,20 +39,20 @@ type Chart struct { // // This should not be used except in special cases like `helm show values`, // where we want to display the raw values, comments and all. - Raw []*File `json:"-"` + Raw []*common.File `json:"-"` // Metadata is the contents of the Chartfile. Metadata *Metadata `json:"metadata"` // Lock is the contents of Chart.lock. Lock *Lock `json:"lock"` // Templates for this chart. - Templates []*File `json:"templates"` + Templates []*common.File `json:"templates"` // Values are default config for this chart. Values map[string]interface{} `json:"values"` // Schema is an optional JSON schema for imposing structure on Values Schema []byte `json:"schema"` // Files are miscellaneous files in a chart archive, // e.g. README, LICENSE, etc. - Files []*File `json:"files"` + Files []*common.File `json:"files"` parent *Chart dependencies []*Chart @@ -62,7 +64,7 @@ type CRD struct { // Filename is the File obj Name including (sub-)chart.ChartFullPath Filename string // File is the File obj for the crd - File *File + File *common.File } // SetDependencies replaces the chart dependencies. @@ -137,8 +139,8 @@ func (ch *Chart) AppVersion() string { // CRDs returns a list of File objects in the 'crds/' directory of a Helm chart. // Deprecated: use CRDObjects() -func (ch *Chart) CRDs() []*File { - files := []*File{} +func (ch *Chart) CRDs() []*common.File { + files := []*common.File{} // Find all resources in the crds/ directory for _, f := range ch.Files { if strings.HasPrefix(f.Name, "crds/") && hasManifestExtension(f.Name) { diff --git a/pkg/chart/v2/chart_test.go b/pkg/chart/v2/chart_test.go index d6311085b..a96d8c0c0 100644 --- a/pkg/chart/v2/chart_test.go +++ b/pkg/chart/v2/chart_test.go @@ -20,11 +20,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/pkg/chart/common" ) func TestCRDs(t *testing.T) { chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { Name: "crds/foo.yaml", Data: []byte("hello"), @@ -57,7 +59,7 @@ func TestCRDs(t *testing.T) { func TestSaveChartNoRawData(t *testing.T) { chrt := Chart{ - Raw: []*File{ + Raw: []*common.File{ { Name: "fhqwhgads.yaml", Data: []byte("Everybody to the Limit"), @@ -76,7 +78,7 @@ func TestSaveChartNoRawData(t *testing.T) { t.Fatal(err) } - is.Equal([]*File(nil), res.Raw) + is.Equal([]*common.File(nil), res.Raw) } func TestMetadata(t *testing.T) { @@ -162,7 +164,7 @@ func TestChartFullPath(t *testing.T) { func TestCRDObjects(t *testing.T) { chrt := Chart{ - Files: []*File{ + Files: []*common.File{ { Name: "crds/foo.yaml", Data: []byte("hello"), @@ -190,7 +192,7 @@ func TestCRDObjects(t *testing.T) { { Name: "crds/foo.yaml", Filename: "crds/foo.yaml", - File: &File{ + File: &common.File{ Name: "crds/foo.yaml", Data: []byte("hello"), }, @@ -198,7 +200,7 @@ func TestCRDObjects(t *testing.T) { { Name: "crds/foo/bar/baz.yaml", Filename: "crds/foo/bar/baz.yaml", - File: &File{ + File: &common.File{ Name: "crds/foo/bar/baz.yaml", Data: []byte("hello"), }, diff --git a/pkg/lint/lint.go b/pkg/chart/v2/lint/lint.go similarity index 83% rename from pkg/lint/lint.go rename to pkg/chart/v2/lint/lint.go index 64b2a6057..773c9bc5e 100644 --- a/pkg/lint/lint.go +++ b/pkg/chart/v2/lint/lint.go @@ -14,24 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -package lint // import "helm.sh/helm/v4/pkg/lint" +package lint // import "helm.sh/helm/v4/pkg/chart/v2/lint" import ( "path/filepath" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/rules" - "helm.sh/helm/v4/pkg/lint/support" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/v2/lint/rules" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" ) type linterOptions struct { - KubeVersion *chartutil.KubeVersion + KubeVersion *common.KubeVersion SkipSchemaValidation bool } type LinterOption func(lo *linterOptions) -func WithKubeVersion(kubeVersion *chartutil.KubeVersion) LinterOption { +func WithKubeVersion(kubeVersion *common.KubeVersion) LinterOption { return func(lo *linterOptions) { lo.KubeVersion = kubeVersion } diff --git a/pkg/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go similarity index 99% rename from pkg/lint/lint_test.go rename to pkg/chart/v2/lint/lint_test.go index 5b590c010..3c777e2bb 100644 --- a/pkg/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -23,8 +23,8 @@ import ( "github.com/stretchr/testify/assert" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" ) var values map[string]interface{} diff --git a/pkg/lint/rules/chartfile.go b/pkg/chart/v2/lint/rules/chartfile.go similarity index 98% rename from pkg/lint/rules/chartfile.go rename to pkg/chart/v2/lint/rules/chartfile.go index 103c28374..185f524a4 100644 --- a/pkg/lint/rules/chartfile.go +++ b/pkg/chart/v2/lint/rules/chartfile.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rules // import "helm.sh/helm/v4/pkg/lint/rules" +package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules" import ( "errors" @@ -27,8 +27,8 @@ import ( "sigs.k8s.io/yaml" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" ) // Chartfile runs a set of linter rules related to Chart.yaml file diff --git a/pkg/lint/rules/chartfile_test.go b/pkg/chart/v2/lint/rules/chartfile_test.go similarity index 99% rename from pkg/lint/rules/chartfile_test.go rename to pkg/chart/v2/lint/rules/chartfile_test.go index 1719a2011..5a1ad2f24 100644 --- a/pkg/lint/rules/chartfile_test.go +++ b/pkg/chart/v2/lint/rules/chartfile_test.go @@ -24,8 +24,8 @@ import ( "testing" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" ) const ( diff --git a/pkg/lint/rules/crds.go b/pkg/chart/v2/lint/rules/crds.go similarity index 98% rename from pkg/lint/rules/crds.go rename to pkg/chart/v2/lint/rules/crds.go index 1b8a73139..49e30192a 100644 --- a/pkg/lint/rules/crds.go +++ b/pkg/chart/v2/lint/rules/crds.go @@ -28,8 +28,8 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" "helm.sh/helm/v4/pkg/chart/v2/loader" - "helm.sh/helm/v4/pkg/lint/support" ) // Crds lints the CRDs in the Linter. diff --git a/pkg/lint/rules/crds_test.go b/pkg/chart/v2/lint/rules/crds_test.go similarity index 95% rename from pkg/lint/rules/crds_test.go rename to pkg/chart/v2/lint/rules/crds_test.go index d497b29ba..e644f182f 100644 --- a/pkg/lint/rules/crds_test.go +++ b/pkg/chart/v2/lint/rules/crds_test.go @@ -21,7 +21,7 @@ import ( "github.com/stretchr/testify/assert" - "helm.sh/helm/v4/pkg/lint/support" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" ) const invalidCrdsDir = "./testdata/invalidcrdsdir" diff --git a/pkg/lint/rules/dependencies.go b/pkg/chart/v2/lint/rules/dependencies.go similarity index 96% rename from pkg/lint/rules/dependencies.go rename to pkg/chart/v2/lint/rules/dependencies.go index 16c9d6435..d944a016d 100644 --- a/pkg/lint/rules/dependencies.go +++ b/pkg/chart/v2/lint/rules/dependencies.go @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rules // import "helm.sh/helm/v4/pkg/lint/rules" +package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules" import ( "fmt" "strings" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" "helm.sh/helm/v4/pkg/chart/v2/loader" - "helm.sh/helm/v4/pkg/lint/support" ) // Dependencies runs lints against a chart's dependencies diff --git a/pkg/lint/rules/dependencies_test.go b/pkg/chart/v2/lint/rules/dependencies_test.go similarity index 98% rename from pkg/lint/rules/dependencies_test.go rename to pkg/chart/v2/lint/rules/dependencies_test.go index 1369b2372..08a6646cd 100644 --- a/pkg/lint/rules/dependencies_test.go +++ b/pkg/chart/v2/lint/rules/dependencies_test.go @@ -20,8 +20,8 @@ import ( "testing" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" ) func chartWithBadDependencies() chart.Chart { diff --git a/pkg/chart/v2/lint/rules/deprecations.go b/pkg/chart/v2/lint/rules/deprecations.go new file mode 100644 index 000000000..6eba316bc --- /dev/null +++ b/pkg/chart/v2/lint/rules/deprecations.go @@ -0,0 +1,106 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules" + +import ( + "fmt" + "strconv" + + "helm.sh/helm/v4/pkg/chart/common" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/deprecation" + kscheme "k8s.io/client-go/kubernetes/scheme" +) + +var ( + // This should be set in the Makefile based on the version of client-go being imported. + // These constants will be overwritten with LDFLAGS. The version components must be + // strings in order for LDFLAGS to set them. + k8sVersionMajor = "1" + k8sVersionMinor = "20" +) + +// deprecatedAPIError indicates than an API is deprecated in Kubernetes +type deprecatedAPIError struct { + Deprecated string + Message string +} + +func (e deprecatedAPIError) Error() string { + msg := e.Message + return msg +} + +func validateNoDeprecations(resource *k8sYamlStruct, kubeVersion *common.KubeVersion) error { + // if `resource` does not have an APIVersion or Kind, we cannot test it for deprecation + if resource.APIVersion == "" { + return nil + } + if resource.Kind == "" { + return nil + } + + majorVersion := k8sVersionMajor + minorVersion := k8sVersionMinor + + if kubeVersion != nil { + majorVersion = kubeVersion.Major + minorVersion = kubeVersion.Minor + } + + runtimeObject, err := resourceToRuntimeObject(resource) + if err != nil { + // do not error for non-kubernetes resources + if runtime.IsNotRegisteredError(err) { + return nil + } + return err + } + + major, err := strconv.Atoi(majorVersion) + if err != nil { + return err + } + minor, err := strconv.Atoi(minorVersion) + if err != nil { + return err + } + + if !deprecation.IsDeprecated(runtimeObject, major, minor) { + return nil + } + gvk := fmt.Sprintf("%s %s", resource.APIVersion, resource.Kind) + return deprecatedAPIError{ + Deprecated: gvk, + Message: deprecation.WarningMessage(runtimeObject), + } +} + +func resourceToRuntimeObject(resource *k8sYamlStruct) (runtime.Object, error) { + scheme := runtime.NewScheme() + kscheme.AddToScheme(scheme) + + gvk := schema.FromAPIVersionAndKind(resource.APIVersion, resource.Kind) + out, err := scheme.New(gvk) + if err != nil { + return nil, err + } + out.GetObjectKind().SetGroupVersionKind(gvk) + return out, nil +} diff --git a/pkg/lint/rules/deprecations_test.go b/pkg/chart/v2/lint/rules/deprecations_test.go similarity index 94% rename from pkg/lint/rules/deprecations_test.go rename to pkg/chart/v2/lint/rules/deprecations_test.go index 6add843ce..e153f67e6 100644 --- a/pkg/lint/rules/deprecations_test.go +++ b/pkg/chart/v2/lint/rules/deprecations_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package rules // import "helm.sh/helm/v4/pkg/lint/rules" +package rules // import "helm.sh/helm/v4/pkg/chart/v2/lint/rules" import "testing" diff --git a/pkg/lint/rules/template.go b/pkg/chart/v2/lint/rules/template.go similarity index 95% rename from pkg/lint/rules/template.go rename to pkg/chart/v2/lint/rules/template.go index b36153ec6..5c84d0f68 100644 --- a/pkg/lint/rules/template.go +++ b/pkg/chart/v2/lint/rules/template.go @@ -33,10 +33,12 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/yaml" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/engine" - "helm.sh/helm/v4/pkg/lint/support" ) // Templates lints the templates in the Linter. @@ -45,12 +47,12 @@ func Templates(linter *support.Linter, values map[string]interface{}, namespace } // TemplatesWithKubeVersion lints the templates in the Linter, allowing to specify the kubernetes version. -func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion) { +func TemplatesWithKubeVersion(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion) { TemplatesWithSkipSchemaValidation(linter, values, namespace, kubeVersion, false) } // TemplatesWithSkipSchemaValidation lints the templates in the Linter, allowing to specify the kubernetes version and if schema validation is enabled or not. -func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *chartutil.KubeVersion, skipSchemaValidation bool) { +func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string]interface{}, namespace string, kubeVersion *common.KubeVersion, skipSchemaValidation bool) { fpath := "templates/" templatesPath := filepath.Join(linter.ChartDir, fpath) @@ -74,12 +76,12 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string return } - options := chartutil.ReleaseOptions{ + options := common.ReleaseOptions{ Name: "test-release", Namespace: namespace, } - caps := chartutil.DefaultCapabilities.Copy() + caps := common.DefaultCapabilities.Copy() if kubeVersion != nil { caps.KubeVersion = *kubeVersion } @@ -90,12 +92,12 @@ func TemplatesWithSkipSchemaValidation(linter *support.Linter, values map[string return } - cvals, err := chartutil.CoalesceValues(chart, values) + cvals, err := util.CoalesceValues(chart, values) if err != nil { return } - valuesToRender, err := chartutil.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) + valuesToRender, err := util.ToRenderValuesWithSchemaValidation(chart, cvals, options, caps, skipSchemaValidation) if err != nil { linter.RunLinterRule(support.ErrorSev, fpath, err) return diff --git a/pkg/lint/rules/template_test.go b/pkg/chart/v2/lint/rules/template_test.go similarity index 98% rename from pkg/lint/rules/template_test.go rename to pkg/chart/v2/lint/rules/template_test.go index 787bd6e4b..3e8e0b371 100644 --- a/pkg/lint/rules/template_test.go +++ b/pkg/chart/v2/lint/rules/template_test.go @@ -23,9 +23,10 @@ import ( "strings" "testing" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" ) const templateTestBasedir = "./testdata/albatross" @@ -189,7 +190,7 @@ func TestDeprecatedAPIFails(t *testing.T) { Version: "0.1.0", Icon: "satisfy-the-linting-gods.gif", }, - Templates: []*chart.File{ + Templates: []*common.File{ { Name: "templates/baddeployment.yaml", Data: []byte("apiVersion: apps/v1beta1\nkind: Deployment\nmetadata:\n name: baddep\nspec: {selector: {matchLabels: {foo: bar}}}"), @@ -249,7 +250,7 @@ func TestStrictTemplateParsingMapError(t *testing.T) { "key1": "val1", }, }, - Templates: []*chart.File{ + Templates: []*common.File{ { Name: "templates/configmap.yaml", Data: []byte(manifest), @@ -378,7 +379,7 @@ func TestEmptyWithCommentsManifests(t *testing.T) { Version: "0.1.0", Icon: "satisfy-the-linting-gods.gif", }, - Templates: []*chart.File{ + Templates: []*common.File{ { Name: "templates/empty-with-comments.yaml", Data: []byte("#@formatter:off\n"), diff --git a/pkg/lint/rules/testdata/albatross/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/albatross/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/albatross/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl b/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl new file mode 100644 index 000000000..24f76db73 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/albatross/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{define "name"}}{{default "nginx" .Values.nameOverride | trunc 63 | trimSuffix "-" }}{{end}} + +{{/* +Create a default fully qualified app name. + +We truncate at 63 chars because some Kubernetes name fields are limited to this +(by the DNS naming spec). +*/}} +{{define "fullname"}} +{{- $name := default "nginx" .Values.nameOverride -}} +{{printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{end}} diff --git a/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml b/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml new file mode 100644 index 000000000..a11e0e90e --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/albatross/templates/fail.yaml @@ -0,0 +1 @@ +{{ deliberateSyntaxError }} diff --git a/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml b/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml new file mode 100644 index 000000000..16bb27d55 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/albatross/templates/svc.yaml @@ -0,0 +1,19 @@ +# This is a service gateway to the replica set created by the deployment. +# Take a look at the deployment.yaml for general notes about this chart. +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.name }}" + labels: + app.kubernetes.io/managed-by: {{ .Release.Service | quote }} + app.kubernetes.io/instance: {{ .Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + kubeVersion: {{ .Capabilities.KubeVersion.Major }} +spec: + ports: + - port: {{default 80 .Values.httpPort | quote}} + targetPort: 80 + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{template "fullname" .}} diff --git a/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml b/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml new file mode 100644 index 000000000..74cc6a0dc --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/albatross/values.yaml @@ -0,0 +1 @@ +name: "mariner" diff --git a/pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/anotherbadchartfile/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/anotherbadchartfile/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml new file mode 100644 index 000000000..3564ede3e --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badchartfile/Chart.yaml @@ -0,0 +1,11 @@ +description: A Helm chart for Kubernetes +version: 0.0.0.0 +home: "" +type: application +dependencies: +- name: mariadb + version: 5.x.x + repository: https://charts.helm.sh/stable/ + condition: mariadb.enabled + tags: + - database diff --git a/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml b/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml new file mode 100644 index 000000000..9f367033b --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badchartfile/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/lint/rules/testdata/badchartname/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/badchartname/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/badchartname/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml b/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml new file mode 100644 index 000000000..9f367033b --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badchartname/values.yaml @@ -0,0 +1 @@ +# Default values for badchartfile. diff --git a/pkg/lint/rules/testdata/badcrdfile/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/badcrdfile/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/badcrdfile/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml b/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml new file mode 100644 index 000000000..468916053 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-apiversion.yaml @@ -0,0 +1,2 @@ +apiVersion: bad.k8s.io/v1beta1 +kind: CustomResourceDefinition diff --git a/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml b/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml new file mode 100644 index 000000000..523b97f85 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badcrdfile/crds/bad-crd.yaml @@ -0,0 +1,2 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: NotACustomResourceDefinition diff --git a/pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep b/pkg/chart/v2/lint/rules/testdata/badcrdfile/templates/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml b/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml new file mode 100644 index 000000000..2fffc7715 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badcrdfile/values.yaml @@ -0,0 +1 @@ +# Default values for badcrdfile. diff --git a/pkg/lint/rules/testdata/badvaluesfile/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/badvaluesfile/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/badvaluesfile/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml b/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml new file mode 100644 index 000000000..6c2ceb8db --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badvaluesfile/templates/badvaluesfile.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{.name | default "foo" | title}} diff --git a/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml b/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml new file mode 100644 index 000000000..b5a10271c --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/badvaluesfile/values.yaml @@ -0,0 +1,2 @@ +# Invalid value for badvaluesfile for testing lint fails with invalid yaml format +name= "value" diff --git a/pkg/lint/rules/testdata/goodone/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/goodone/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/goodone/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml b/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml new file mode 100644 index 000000000..1d7350f1d --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/goodone/crds/test-crd.yaml @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: tests.test.io +spec: + group: test.io + names: + kind: Test + listKind: TestList + plural: tests + singular: test + scope: Namespaced + versions: + - name : v1alpha2 + served: true + storage: true + - name : v1alpha1 + served: true + storage: false diff --git a/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml b/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml new file mode 100644 index 000000000..cd46f62c7 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/goodone/templates/goodone.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.name | default "foo" | lower }} diff --git a/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml b/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml new file mode 100644 index 000000000..92c3d9bb9 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/goodone/values.yaml @@ -0,0 +1 @@ +name: "goodone-here" diff --git a/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml new file mode 100644 index 000000000..0fd58d1d4 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/invalidchartfile/Chart.yaml @@ -0,0 +1,6 @@ +name: some-chart +apiVersion: v2 +apiVersion: v1 +description: A Helm chart for Kubernetes +version: 1.3.0 +icon: http://example.com diff --git a/pkg/chart/v2/lint/rules/testdata/invalidchartfile/values.yaml b/pkg/chart/v2/lint/rules/testdata/invalidchartfile/values.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/lint/rules/testdata/invalidcrdsdir/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/invalidcrdsdir/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds b/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/crds new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml b/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml new file mode 100644 index 000000000..6b1611a64 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/invalidcrdsdir/values.yaml @@ -0,0 +1 @@ +# Default values for invalidcrdsdir. diff --git a/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore b/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/malformed-template/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/pkg/lint/rules/testdata/malformed-template/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/malformed-template/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/malformed-template/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml b/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml new file mode 100644 index 000000000..213198fda --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/malformed-template/templates/bad.yaml @@ -0,0 +1 @@ +{ {- $relname := .Release.Name -}} diff --git a/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml b/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml new file mode 100644 index 000000000..1cc3182ea --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/malformed-template/values.yaml @@ -0,0 +1,82 @@ +# Default values for test. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/lint/rules/testdata/multi-template-fail/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/multi-template-fail/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/multi-template-fail/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml b/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml new file mode 100644 index 000000000..835be07be --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/multi-template-fail/templates/multi-fail.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config +data: + game.properties: cheat +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: -this:name-is-not_valid$ +data: + game.properties: empty diff --git a/pkg/lint/rules/testdata/v3-fail/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/v3-fail/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/v3-fail/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl new file mode 100644 index 000000000..0b89e723b --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "v3-fail.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "v3-fail.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "v3-fail.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "v3-fail.labels" -}} +helm.sh/chart: {{ include "v3-fail.chart" . }} +{{ include "v3-fail.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "v3-fail.selectorLabels" -}} +app.kubernetes.io/name: {{ include "v3-fail.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "v3-fail.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "v3-fail.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml new file mode 100644 index 000000000..6d651ab8e --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "v3-fail.fullname" . }} + labels: + nope: {{ .Release.Time }} + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "v3-fail.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "v3-fail.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "v3-fail.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml new file mode 100644 index 000000000..4790650d0 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/ingress.yaml @@ -0,0 +1,62 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "v3-fail.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "v3-fail.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + "helm.sh/hook": crd-install + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml new file mode 100644 index 000000000..79a0f40b0 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/v3-fail/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "v3-fail.fullname" . }} + annotations: + helm.sh/hook: crd-install + labels: + {{- include "v3-fail.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "v3-fail.selectorLabels" . | nindent 4 }} diff --git a/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml b/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml new file mode 100644 index 000000000..01d99b4e6 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/v3-fail/values.yaml @@ -0,0 +1,66 @@ +# Default values for v3-fail. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: [] + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/pkg/lint/rules/testdata/withsubchart/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/withsubchart/Chart.yaml diff --git a/pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml similarity index 100% rename from pkg/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml rename to pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/Chart.yaml diff --git a/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml new file mode 100644 index 000000000..6cb6cc2af --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/templates/subchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml new file mode 100644 index 000000000..422a359d5 --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/withsubchart/charts/subchart/values.yaml @@ -0,0 +1,2 @@ +subchart: + name: subchart \ No newline at end of file diff --git a/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml new file mode 100644 index 000000000..6cb6cc2af --- /dev/null +++ b/pkg/chart/v2/lint/rules/testdata/withsubchart/templates/mainchart.yaml @@ -0,0 +1,2 @@ +metadata: + name: {{ .Values.subchart.name | lower }} diff --git a/pkg/chart/v2/lint/rules/testdata/withsubchart/values.yaml b/pkg/chart/v2/lint/rules/testdata/withsubchart/values.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/lint/rules/values.go b/pkg/chart/v2/lint/rules/values.go similarity index 84% rename from pkg/lint/rules/values.go rename to pkg/chart/v2/lint/rules/values.go index 019e74fa7..5260bf8b3 100644 --- a/pkg/lint/rules/values.go +++ b/pkg/chart/v2/lint/rules/values.go @@ -21,8 +21,9 @@ import ( "os" "path/filepath" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" - "helm.sh/helm/v4/pkg/lint/support" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" ) // ValuesWithOverrides tests the values.yaml file. @@ -52,7 +53,7 @@ func validateValuesFileExistence(valuesPath string) error { } func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { - values, err := chartutil.ReadValuesFile(valuesPath) + values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) } @@ -62,8 +63,8 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err // We could change that. For now, though, we retain that strategy, and thus can // coalesce tables (like reuse-values does) instead of doing the full chart // CoalesceValues - coalescedValues := chartutil.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) - coalescedValues = chartutil.CoalesceTables(coalescedValues, values) + coalescedValues := util.CoalesceTables(make(map[string]interface{}, len(overrides)), overrides) + coalescedValues = util.CoalesceTables(coalescedValues, values) ext := filepath.Ext(valuesPath) schemaPath := valuesPath[:len(valuesPath)-len(ext)] + ".schema.json" @@ -74,5 +75,5 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err if err != nil { return err } - return chartutil.ValidateAgainstSingleSchema(coalescedValues, schema) + return util.ValidateAgainstSingleSchema(coalescedValues, schema) } diff --git a/pkg/chart/v2/lint/rules/values_test.go b/pkg/chart/v2/lint/rules/values_test.go new file mode 100644 index 000000000..348695785 --- /dev/null +++ b/pkg/chart/v2/lint/rules/values_test.go @@ -0,0 +1,169 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "helm.sh/helm/v4/internal/test/ensure" +) + +var nonExistingValuesFilePath = filepath.Join("/fake/dir", "values.yaml") + +const testSchema = ` +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "helm values test schema", + "type": "object", + "additionalProperties": false, + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "description": "Your username", + "type": "string" + }, + "password": { + "description": "Your password", + "type": "string" + } + } +} +` + +func TestValidateValuesYamlNotDirectory(t *testing.T) { + _ = os.Mkdir(nonExistingValuesFilePath, os.ModePerm) + defer os.Remove(nonExistingValuesFilePath) + + err := validateValuesFileExistence(nonExistingValuesFilePath) + if err == nil { + t.Errorf("validateValuesFileExistence to return a linter error, got no error") + } +} + +func TestValidateValuesFileWellFormed(t *testing.T) { + badYaml := ` + not:well[]{}formed + ` + tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { + t.Fatal("expected values file to fail parsing") + } +} + +func TestValidateValuesFileSchema(t *testing.T) { + yaml := "username: admin\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFileSchemaFailure(t *testing.T) { + // 1234 is an int, not a string. This should fail. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}) + if err == nil { + t.Fatal("expected values file to fail parsing") + } + + assert.Contains(t, err.Error(), "- at '/username': got number, want string") +} + +func TestValidateValuesFileSchemaOverrides(t *testing.T) { + yaml := "username: admin" + overrides := map[string]interface{}{ + "password": "swordfish", + } + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + if err := validateValuesFile(valfile, overrides); err != nil { + t.Fatalf("Failed validation with %s", err) + } +} + +func TestValidateValuesFile(t *testing.T) { + tests := []struct { + name string + yaml string + overrides map[string]interface{} + errorMessage string + }{ + { + name: "value added", + yaml: "username: admin", + overrides: map[string]interface{}{"password": "swordfish"}, + }, + { + name: "value not overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser"}, + errorMessage: "- at '/password': got null, want string", + }, + { + name: "value overridden", + yaml: "username: admin\npassword:", + overrides: map[string]interface{}{"username": "anotherUser", "password": "swordfish"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := ensure.TempFile(t, "values.yaml", []byte(tt.yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, tt.overrides) + + switch { + case err != nil && tt.errorMessage == "": + t.Errorf("Failed validation with %s", err) + case err == nil && tt.errorMessage != "": + t.Error("expected values file to fail parsing") + case err != nil && tt.errorMessage != "": + assert.Contains(t, err.Error(), tt.errorMessage, "Failed with unexpected error") + } + }) + } +} + +func createTestingSchema(t *testing.T, dir string) string { + t.Helper() + schemafile := filepath.Join(dir, "values.schema.json") + if err := os.WriteFile(schemafile, []byte(testSchema), 0700); err != nil { + t.Fatalf("Failed to write schema to tmpdir: %s", err) + } + return schemafile +} diff --git a/pkg/lint/support/doc.go b/pkg/chart/v2/lint/support/doc.go similarity index 91% rename from pkg/lint/support/doc.go rename to pkg/chart/v2/lint/support/doc.go index b007804dc..7e050b8c2 100644 --- a/pkg/lint/support/doc.go +++ b/pkg/chart/v2/lint/support/doc.go @@ -20,4 +20,4 @@ Package support contains tools for linting charts. Linting is the process of testing charts for errors or warnings regarding formatting, compilation, or standards compliance. */ -package support // import "helm.sh/helm/v4/pkg/lint/support" +package support // import "helm.sh/helm/v4/pkg/chart/v2/lint/support" diff --git a/pkg/chart/v2/lint/support/message.go b/pkg/chart/v2/lint/support/message.go new file mode 100644 index 000000000..5efbc7a61 --- /dev/null +++ b/pkg/chart/v2/lint/support/message.go @@ -0,0 +1,76 @@ +/* +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 support + +import "fmt" + +// Severity indicates the severity of a Message. +const ( + // UnknownSev indicates that the severity of the error is unknown, and should not stop processing. + UnknownSev = iota + // InfoSev indicates information, for example missing values.yaml file + InfoSev + // WarningSev indicates that something does not meet code standards, but will likely function. + WarningSev + // ErrorSev indicates that something will not likely function. + ErrorSev +) + +// sev matches the *Sev states. +var sev = []string{"UNKNOWN", "INFO", "WARNING", "ERROR"} + +// Linter encapsulates a linting run of a particular chart. +type Linter struct { + Messages []Message + // The highest severity of all the failing lint rules + HighestSeverity int + ChartDir string +} + +// Message describes an error encountered while linting. +type Message struct { + // Severity is one of the *Sev constants + Severity int + Path string + Err error +} + +func (m Message) Error() string { + return fmt.Sprintf("[%s] %s: %s", sev[m.Severity], m.Path, m.Err.Error()) +} + +// NewMessage creates a new Message struct +func NewMessage(severity int, path string, err error) Message { + return Message{Severity: severity, Path: path, Err: err} +} + +// RunLinterRule returns true if the validation passed +func (l *Linter) RunLinterRule(severity int, path string, err error) bool { + // severity is out of bound + if severity < 0 || severity >= len(sev) { + return false + } + + if err != nil { + l.Messages = append(l.Messages, NewMessage(severity, path, err)) + + if severity > l.HighestSeverity { + l.HighestSeverity = severity + } + } + return err == nil +} diff --git a/pkg/chart/v2/lint/support/message_test.go b/pkg/chart/v2/lint/support/message_test.go new file mode 100644 index 000000000..ce5b5e42e --- /dev/null +++ b/pkg/chart/v2/lint/support/message_test.go @@ -0,0 +1,79 @@ +/* +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 support + +import ( + "errors" + "testing" +) + +var errLint = errors.New("lint failed") + +func TestRunLinterRule(t *testing.T) { + var tests = []struct { + Severity int + LintError error + ExpectedMessages int + ExpectedReturn bool + ExpectedHighestSeverity int + }{ + {InfoSev, errLint, 1, false, InfoSev}, + {WarningSev, errLint, 2, false, WarningSev}, + {ErrorSev, errLint, 3, false, ErrorSev}, + // No error so it returns true + {ErrorSev, nil, 3, true, ErrorSev}, + // Retains highest severity + {InfoSev, errLint, 4, false, ErrorSev}, + // Invalid severity values + {4, errLint, 4, false, ErrorSev}, + {22, errLint, 4, false, ErrorSev}, + {-1, errLint, 4, false, ErrorSev}, + } + + linter := Linter{} + for _, test := range tests { + isValid := linter.RunLinterRule(test.Severity, "chart", test.LintError) + if len(linter.Messages) != test.ExpectedMessages { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.Messages should now have %d message, we got %d", test.Severity, test.LintError, test.ExpectedMessages, len(linter.Messages)) + } + + if linter.HighestSeverity != test.ExpectedHighestSeverity { + t.Errorf("RunLinterRule(%d, \"chart\", %v), linter.HighestSeverity should be %d, we got %d", test.Severity, test.LintError, test.ExpectedHighestSeverity, linter.HighestSeverity) + } + + if isValid != test.ExpectedReturn { + t.Errorf("RunLinterRule(%d, \"chart\", %v), should have returned %t but returned %t", test.Severity, test.LintError, test.ExpectedReturn, isValid) + } + } +} + +func TestMessage(t *testing.T) { + m := Message{ErrorSev, "Chart.yaml", errors.New("Foo")} + if m.Error() != "[ERROR] Chart.yaml: Foo" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{WarningSev, "templates/", errors.New("Bar")} + if m.Error() != "[WARNING] templates/: Bar" { + t.Errorf("Unexpected output: %s", m.Error()) + } + + m = Message{InfoSev, "templates/rc.yaml", errors.New("FooBar")} + if m.Error() != "[INFO] templates/rc.yaml: FooBar" { + t.Errorf("Unexpected output: %s", m.Error()) + } +} diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index 75c73e959..0c025e183 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -31,6 +31,7 @@ import ( utilyaml "k8s.io/apimachinery/pkg/util/yaml" "sigs.k8s.io/yaml" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) @@ -80,7 +81,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { // do not rely on assumed ordering of files in the chart and crash // if Chart.yaml was not coming early enough to initialize metadata for _, f := range files { - c.Raw = append(c.Raw, &chart.File{Name: f.Name, Data: f.Data}) + c.Raw = append(c.Raw, &common.File{Name: f.Name, Data: f.Data}) if f.Name == "Chart.yaml" { if c.Metadata == nil { c.Metadata = new(chart.Metadata) @@ -128,7 +129,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { return c, fmt.Errorf("cannot load requirements.yaml: %w", err) } if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) } // Deprecated: requirements.lock is deprecated use Chart.lock. case f.Name == "requirements.lock": @@ -143,14 +144,14 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { log.Printf("Warning: Dependency locking is handled in Chart.lock since apiVersion \"v2\". We recommend migrating to Chart.lock.") } if c.Metadata.APIVersion == chart.APIVersionV1 { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) } case strings.HasPrefix(f.Name, "templates/"): - c.Templates = append(c.Templates, &chart.File{Name: f.Name, Data: f.Data}) + c.Templates = append(c.Templates, &common.File{Name: f.Name, Data: f.Data}) case strings.HasPrefix(f.Name, "charts/"): if filepath.Ext(f.Name) == ".prov" { - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) continue } @@ -158,7 +159,7 @@ func LoadFiles(files []*BufferedFile) (*chart.Chart, error) { cname := strings.SplitN(fname, "/", 2)[0] subcharts[cname] = append(subcharts[cname], &BufferedFile{Name: fname, Data: f.Data}) default: - c.Files = append(c.Files, &chart.File{Name: f.Name, Data: f.Data}) + c.Files = append(c.Files, &common.File{Name: f.Name, Data: f.Data}) } } diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index 41154421c..c4ae646f6 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -30,6 +30,7 @@ import ( "testing" "time" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) @@ -543,7 +544,7 @@ foo: } } -func TestMergeValues(t *testing.T) { +func TestMergeValuesV2(t *testing.T) { nestedMap := map[string]interface{}{ "foo": "bar", "baz": map[string]string{ @@ -753,7 +754,7 @@ func verifyChartFileAndTemplate(t *testing.T, c *chart.Chart, name string) { } } -func verifyBomStripped(t *testing.T, files []*chart.File) { +func verifyBomStripped(t *testing.T, files []*common.File) { t.Helper() for _, file := range files { if bytes.HasPrefix(file.Data, utf8bom) { diff --git a/pkg/chart/v2/util/create.go b/pkg/chart/v2/util/create.go index a8ae3ab40..d7c1fe31c 100644 --- a/pkg/chart/v2/util/create.go +++ b/pkg/chart/v2/util/create.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/yaml" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" ) @@ -655,11 +656,11 @@ func CreateFrom(chartfile *chart.Metadata, dest, src string) error { schart.Metadata = chartfile - var updatedTemplates []*chart.File + var updatedTemplates []*common.File for _, template := range schart.Templates { newData := transform(string(template.Data), schart.Name()) - updatedTemplates = append(updatedTemplates, &chart.File{Name: template.Name, Data: newData}) + updatedTemplates = append(updatedTemplates, &common.File{Name: template.Name, Data: newData}) } schart.Templates = updatedTemplates diff --git a/pkg/chart/v2/util/dependencies.go b/pkg/chart/v2/util/dependencies.go index 1a2aa1c95..a52f09f82 100644 --- a/pkg/chart/v2/util/dependencies.go +++ b/pkg/chart/v2/util/dependencies.go @@ -22,11 +22,13 @@ import ( "github.com/mitchellh/copystructure" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" chart "helm.sh/helm/v4/pkg/chart/v2" ) // ProcessDependencies checks through this chart's dependencies, processing accordingly. -func ProcessDependencies(c *chart.Chart, v Values) error { +func ProcessDependencies(c *chart.Chart, v common.Values) error { if err := processDependencyEnabled(c, v, ""); err != nil { return err } @@ -34,7 +36,7 @@ func ProcessDependencies(c *chart.Chart, v Values) error { } // processDependencyConditions disables charts based on condition path value in values -func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath string) { +func processDependencyConditions(reqs []*chart.Dependency, cvals common.Values, cpath string) { if reqs == nil { return } @@ -50,7 +52,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s break } slog.Warn("returned non-bool value", "path", c, "chart", r.Name) - } else if _, ok := err.(ErrNoValue); !ok { + } else if _, ok := err.(common.ErrNoValue); !ok { // this is a real error slog.Warn("the method PathValue returned error", slog.Any("error", err)) } @@ -60,7 +62,7 @@ func processDependencyConditions(reqs []*chart.Dependency, cvals Values, cpath s } // processDependencyTags disables charts based on tags in values -func processDependencyTags(reqs []*chart.Dependency, cvals Values) { +func processDependencyTags(reqs []*chart.Dependency, cvals common.Values) { if reqs == nil { return } @@ -177,7 +179,7 @@ Loop: for _, lr := range c.Metadata.Dependencies { lr.Enabled = true } - cvals, err := CoalesceValues(c, v) + cvals, err := util.CoalesceValues(c, v) if err != nil { return err } @@ -232,6 +234,8 @@ func pathToMap(path string, data map[string]interface{}) map[string]interface{} return set(parsePath(path), data) } +func parsePath(key string) []string { return strings.Split(key, ".") } + func set(path []string, data map[string]interface{}) map[string]interface{} { if len(path) == 0 { return nil @@ -249,12 +253,12 @@ func processImportValues(c *chart.Chart, merge bool) error { return nil } // combine chart values and empty config to get Values - var cvals Values + var cvals common.Values var err error if merge { - cvals, err = MergeValues(c, nil) + cvals, err = util.MergeValues(c, nil) } else { - cvals, err = CoalesceValues(c, nil) + cvals, err = util.CoalesceValues(c, nil) } if err != nil { return err @@ -282,9 +286,9 @@ func processImportValues(c *chart.Chart, merge bool) error { } // create value map from child to be merged into parent if merge { - b = MergeTables(b, pathToMap(parent, vv.AsMap())) + b = util.MergeTables(b, pathToMap(parent, vv.AsMap())) } else { - b = CoalesceTables(b, pathToMap(parent, vv.AsMap())) + b = util.CoalesceTables(b, pathToMap(parent, vv.AsMap())) } case string: child := "exports." + iv @@ -298,9 +302,9 @@ func processImportValues(c *chart.Chart, merge bool) error { continue } if merge { - b = MergeTables(b, vm.AsMap()) + b = util.MergeTables(b, vm.AsMap()) } else { - b = CoalesceTables(b, vm.AsMap()) + b = util.CoalesceTables(b, vm.AsMap()) } } } @@ -315,14 +319,14 @@ func processImportValues(c *chart.Chart, merge bool) error { // deep copying the cvals as there are cases where pointers can end // up in the cvals when they are copied onto b in ways that break things. cvals = deepCopyMap(cvals) - c.Values = MergeTables(cvals, b) + c.Values = util.MergeTables(cvals, b) } else { // Trimming the nil values from cvals is needed for backwards compatibility. // Previously, the b value had been populated with cvals along with some // overrides. This caused the coalescing functionality to remove the // nil/null values. This trimming is for backwards compat. cvals = trimNilValues(cvals) - c.Values = CoalesceTables(cvals, b) + c.Values = util.CoalesceTables(cvals, b) } return nil @@ -355,6 +359,12 @@ func trimNilValues(vals map[string]interface{}) map[string]interface{} { return valsCopyMap } +// istable is a special-purpose function to see if the present thing matches the definition of a YAML table. +func istable(v interface{}) bool { + _, ok := v.(map[string]interface{}) + return ok +} + // processDependencyImportValues imports specified chart values from child to parent. func processDependencyImportValues(c *chart.Chart, merge bool) error { for _, d := range c.Dependencies() { diff --git a/pkg/chart/v2/util/dependencies_test.go b/pkg/chart/v2/util/dependencies_test.go index d645d7bf5..c817b0b89 100644 --- a/pkg/chart/v2/util/dependencies_test.go +++ b/pkg/chart/v2/util/dependencies_test.go @@ -21,6 +21,7 @@ import ( "strconv" "testing" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" ) @@ -221,7 +222,7 @@ func TestProcessDependencyImportValues(t *testing.T) { if err := processDependencyImportValues(c, false); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { @@ -251,7 +252,7 @@ func TestProcessDependencyImportValues(t *testing.T) { t.Error("expect nil value not found but found it") } switch xerr := err.(type) { - case ErrNoValue: + case common.ErrNoValue: // We found what we expected default: t.Errorf("expected an ErrNoValue but got %q instead", xerr) @@ -261,7 +262,7 @@ func TestProcessDependencyImportValues(t *testing.T) { if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc = Values(c.Values) + cc = common.Values(c.Values) val, err := cc.PathValue("ensurenull") if err != nil { t.Error("expect value but ensurenull was not found") @@ -291,7 +292,7 @@ func TestProcessDependencyImportValuesFromSharedDependencyToAliases(t *testing.T e["foo.grandchild.defaults.defaultValue"] = "42" e["bar.grandchild.defaults.defaultValue"] = "42" - cValues := Values(c.Values) + cValues := common.Values(c.Values) for kk, vv := range e { pv, err := cValues.PathValue(kk) if err != nil { @@ -329,7 +330,7 @@ func TestProcessDependencyImportValuesMultiLevelPrecedence(t *testing.T) { if err := processDependencyImportValues(c, true); err != nil { t.Fatalf("processing import values dependencies %v", err) } - cc := Values(c.Values) + cc := common.Values(c.Values) for kk, vv := range e { pv, err := cc.PathValue(kk) if err != nil { diff --git a/pkg/chart/v2/util/save.go b/pkg/chart/v2/util/save.go index 624a5b562..69a98924c 100644 --- a/pkg/chart/v2/util/save.go +++ b/pkg/chart/v2/util/save.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/yaml" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" ) @@ -76,7 +77,7 @@ func SaveDir(c *chart.Chart, dest string) error { } // Save templates and files - for _, o := range [][]*chart.File{c.Templates, c.Files} { + for _, o := range [][]*common.File{c.Templates, c.Files} { for _, f := range o { n := filepath.Join(outdir, f.Name) if err := writeFile(n, f.Data); err != nil { @@ -258,7 +259,7 @@ func validateName(name string) error { nname := filepath.Base(name) if nname != name { - return ErrInvalidChartName{name} + return common.ErrInvalidChartName{Name: name} } return nil diff --git a/pkg/chart/v2/util/save_test.go b/pkg/chart/v2/util/save_test.go index ff96331b5..ef822a82a 100644 --- a/pkg/chart/v2/util/save_test.go +++ b/pkg/chart/v2/util/save_test.go @@ -29,6 +29,7 @@ import ( "testing" "time" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" ) @@ -47,7 +48,7 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), @@ -116,7 +117,7 @@ func TestSave(t *testing.T) { Lock: &chart.Lock{ Digest: "testdigest", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, } @@ -156,7 +157,7 @@ func TestSavePreservesTimestamps(t *testing.T) { "imageName": "testimage", "imageId": 42, }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, Schema: []byte("{\n \"title\": \"Values\"\n}"), @@ -222,10 +223,10 @@ func TestSaveDir(t *testing.T) { Name: "ahab", Version: "1.2.3", }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "scheherazade/shahryar.txt", Data: []byte("1,001 Nights")}, }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: path.Join(TemplatesDir, "nested", "dir", "thing.yaml"), Data: []byte("abc: {{ .Values.abc }}")}, }, } diff --git a/pkg/cli/values/options_test.go b/pkg/cli/values/options_test.go index 4dbc709f1..fe1afc5d2 100644 --- a/pkg/cli/values/options_test.go +++ b/pkg/cli/values/options_test.go @@ -294,7 +294,7 @@ func TestReadFileOriginal(t *testing.T) { } } -func TestMergeValues(t *testing.T) { +func TestMergeValuesCLI(t *testing.T) { tests := []struct { name string opts Options diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 40478c30e..55e3a842f 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -28,7 +28,7 @@ import ( "helm.sh/helm/v4/internal/test" "helm.sh/helm/v4/pkg/action" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/cli" kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" @@ -91,7 +91,7 @@ func executeActionCommandStdinC(store *storage.Storage, in *os.File, cmd string) actionConfig := &action.Configuration{ Releases: store, KubeClient: &kubefake.PrintingKubeClient{Out: io.Discard}, - Capabilities: chartutil.DefaultCapabilities, + Capabilities: common.DefaultCapabilities, } root, err := newRootCmdWithConfig(actionConfig, buf, args, SetupLogging) diff --git a/pkg/cmd/lint.go b/pkg/cmd/lint.go index 78083a7ea..71540f1be 100644 --- a/pkg/cmd/lint.go +++ b/pkg/cmd/lint.go @@ -27,10 +27,10 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v4/pkg/action" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/v2/lint/support" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/getter" - "helm.sh/helm/v4/pkg/lint/support" ) var longLintHelp = ` @@ -58,7 +58,7 @@ func newLintCmd(out io.Writer) *cobra.Command { } if kubeVersion != "" { - parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion) + parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) if err != nil { return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err) } diff --git a/pkg/cmd/status.go b/pkg/cmd/status.go index aa836f9f3..3d1309c3e 100644 --- a/pkg/cmd/status.go +++ b/pkg/cmd/status.go @@ -30,7 +30,7 @@ import ( coloroutput "helm.sh/helm/v4/internal/cli/output" "helm.sh/helm/v4/pkg/action" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common/util" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cmd/require" release "helm.sh/helm/v4/pkg/release/v1" @@ -197,7 +197,7 @@ func (s statusPrinter) WriteTable(out io.Writer) error { // Print an extra newline _, _ = fmt.Fprintln(out) - cfg, err := chartutil.CoalesceValues(s.release.Chart, s.release.Config) + cfg, err := util.CoalesceValues(s.release.Chart, s.release.Config) if err != nil { return err } diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index aaf848c9e..81c112d51 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -35,7 +35,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v4/pkg/action" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" @@ -69,7 +69,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { }, RunE: func(_ *cobra.Command, args []string) error { if kubeVersion != "" { - parsedKubeVersion, err := chartutil.ParseKubeVersion(kubeVersion) + parsedKubeVersion, err := common.ParseKubeVersion(kubeVersion) if err != nil { return fmt.Errorf("invalid kube version '%s': %s", kubeVersion, err) } @@ -93,7 +93,7 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client.ReleaseName = "release-name" client.Replace = true // Skip the name check client.ClientOnly = !validate - client.APIVersions = chartutil.VersionSet(extraAPIs) + client.APIVersions = common.VersionSet(extraAPIs) client.IncludeCRDs = includeCrds rel, err := runInstall(args, client, valueOpts, out) diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index d7375dcad..9b17f187d 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -24,6 +24,7 @@ import ( "strings" "testing" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" @@ -382,7 +383,7 @@ func prepareMockRelease(t *testing.T, releaseName string) (func(n string, v int, Description: "A Helm chart for Kubernetes", Version: "0.1.0", }, - Templates: []*chart.File{{Name: "templates/configmap.yaml", Data: configmapData}}, + Templates: []*common.File{{Name: "templates/configmap.yaml", Data: configmapData}}, } chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) if err := chartutil.SaveDir(cfile, tmpChart); err != nil { @@ -490,7 +491,7 @@ func prepareMockReleaseWithSecret(t *testing.T, releaseName string) (func(n stri Description: "A Helm chart for Kubernetes", Version: "0.1.0", }, - Templates: []*chart.File{{Name: "templates/configmap.yaml", Data: configmapData}, {Name: "templates/secret.yaml", Data: secretData}}, + Templates: []*common.File{{Name: "templates/configmap.yaml", Data: configmapData}, {Name: "templates/secret.yaml", Data: secretData}}, } chartPath := filepath.Join(tmpChart, cfile.Metadata.Name) if err := chartutil.SaveDir(cfile, tmpChart); err != nil { diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 6e47a0e39..a0ca17f08 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -30,8 +30,8 @@ import ( "k8s.io/client-go/rest" - chart "helm.sh/helm/v4/pkg/chart/v2" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + ci "helm.sh/helm/v4/pkg/chart" + "helm.sh/helm/v4/pkg/chart/common" ) // taken from https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 @@ -88,21 +88,21 @@ func New(config *rest.Config) Engine { // that section of the values will be passed into the "foo" chart. And if that // section contains a value named "bar", that value will be passed on to the // bar chart during render time. -func (e Engine) Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) { +func (e Engine) Render(chrt ci.Charter, values common.Values) (map[string]string, error) { tmap := allTemplates(chrt, values) return e.render(tmap) } // Render takes a chart, optional values, and value overrides, and attempts to // render the Go templates using the default options. -func Render(chrt *chart.Chart, values chartutil.Values) (map[string]string, error) { +func Render(chrt ci.Charter, values common.Values) (map[string]string, error) { return new(Engine).Render(chrt, values) } // RenderWithClient takes a chart, optional values, and value overrides, and attempts to // render the Go templates using the default options. This engine is client aware and so can have template // functions that interact with the client. -func RenderWithClient(chrt *chart.Chart, values chartutil.Values, config *rest.Config) (map[string]string, error) { +func RenderWithClient(chrt ci.Charter, values common.Values, config *rest.Config) (map[string]string, error) { var clientProvider ClientProvider = clientProviderFromConfig{config} return Engine{ clientProvider: &clientProvider, @@ -113,7 +113,7 @@ func RenderWithClient(chrt *chart.Chart, values chartutil.Values, config *rest.C // render the Go templates using the default options. This engine is client aware and so can have template // functions that interact with the client. // This function differs from RenderWithClient in that it lets you customize the way a dynamic client is constructed. -func RenderWithClientProvider(chrt *chart.Chart, values chartutil.Values, clientProvider ClientProvider) (map[string]string, error) { +func RenderWithClientProvider(chrt ci.Charter, values common.Values, clientProvider ClientProvider) (map[string]string, error) { return Engine{ clientProvider: &clientProvider, }.Render(chrt, values) @@ -124,7 +124,7 @@ type renderable struct { // tpl is the current template. tpl string // vals are the values to be supplied to the template. - vals chartutil.Values + vals common.Values // namespace prefix to the templates of the current chart basePath string } @@ -312,7 +312,7 @@ func (e Engine) render(tpls map[string]renderable) (rendered map[string]string, } // At render time, add information about the template that is being rendered. vals := tpls[filename].vals - vals["Template"] = chartutil.Values{"Name": filename, "BasePath": tpls[filename].basePath} + vals["Template"] = common.Values{"Name": filename, "BasePath": tpls[filename].basePath} var buf strings.Builder if err := t.ExecuteTemplate(&buf, filename, vals); err != nil { return map[string]string{}, reformatExecErrorMsg(filename, err) @@ -455,7 +455,7 @@ func (p byPathLen) Less(i, j int) bool { // allTemplates returns all templates for a chart and its dependencies. // // As it goes, it also prepares the values in a scope-sensitive manner. -func allTemplates(c *chart.Chart, vals chartutil.Values) map[string]renderable { +func allTemplates(c ci.Charter, vals common.Values) map[string]renderable { templates := make(map[string]renderable) recAllTpls(c, templates, vals) return templates @@ -465,40 +465,46 @@ func allTemplates(c *chart.Chart, vals chartutil.Values) map[string]renderable { // // As it recurses, it also sets the values to be appropriate for the template // scope. -func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil.Values) map[string]interface{} { +func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Values) map[string]interface{} { + vals := values.AsMap() subCharts := make(map[string]interface{}) - chartMetaData := struct { - chart.Metadata - IsRoot bool - }{*c.Metadata, c.IsRoot()} + accessor, err := ci.NewAccessor(c) + if err != nil { + slog.Error("error accessing chart", "error", err) + } + chartMetaData := accessor.MetadataAsMap() + fmt.Printf("metadata: %v\n", chartMetaData) + chartMetaData["IsRoot"] = accessor.IsRoot() next := map[string]interface{}{ "Chart": chartMetaData, - "Files": newFiles(c.Files), + "Files": newFiles(accessor.Files()), "Release": vals["Release"], "Capabilities": vals["Capabilities"], - "Values": make(chartutil.Values), + "Values": make(common.Values), "Subcharts": subCharts, } // If there is a {{.Values.ThisChart}} in the parent metadata, // copy that into the {{.Values}} for this template. - if c.IsRoot() { + if accessor.IsRoot() { next["Values"] = vals["Values"] - } else if vs, err := vals.Table("Values." + c.Name()); err == nil { + } else if vs, err := values.Table("Values." + accessor.Name()); err == nil { next["Values"] = vs } - for _, child := range c.Dependencies() { - subCharts[child.Name()] = recAllTpls(child, templates, next) + for _, child := range accessor.Dependencies() { + // TODO: Handle error + sub, _ := ci.NewAccessor(child) + subCharts[sub.Name()] = recAllTpls(child, templates, next) } - newParentID := c.ChartFullPath() - for _, t := range c.Templates { + newParentID := accessor.ChartFullPath() + for _, t := range accessor.Templates() { if t == nil { continue } - if !isTemplateValid(c, t.Name) { + if !isTemplateValid(accessor, t.Name) { continue } templates[path.Join(newParentID, t.Name)] = renderable{ @@ -512,14 +518,9 @@ func recAllTpls(c *chart.Chart, templates map[string]renderable, vals chartutil. } // isTemplateValid returns true if the template is valid for the chart type -func isTemplateValid(ch *chart.Chart, templateName string) bool { - if isLibraryChart(ch) { +func isTemplateValid(accessor ci.Accessor, templateName string) bool { + if accessor.IsLibraryChart() { return strings.HasPrefix(filepath.Base(templateName), "_") } return true } - -// isLibraryChart returns true if the chart is a library chart -func isLibraryChart(c *chart.Chart) bool { - return strings.EqualFold(c.Metadata.Type, "library") -} diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index f4228fbd7..7ac892cec 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -32,8 +32,9 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/fake" + "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/chart/common/util" chart "helm.sh/helm/v4/pkg/chart/v2" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" ) func TestSortTemplates(t *testing.T) { @@ -94,7 +95,7 @@ func TestRender(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/test1", Data: []byte("{{.Values.outer | title }} {{.Values.inner | title}}")}, {Name: "templates/test2", Data: []byte("{{.Values.global.callme | lower }}")}, {Name: "templates/test3", Data: []byte("{{.noValue}}")}, @@ -114,7 +115,7 @@ func TestRender(t *testing.T) { }, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } @@ -144,7 +145,7 @@ func TestRenderRefsOrdering(t *testing.T) { Name: "parent", Version: "1.2.3", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}parent value{{- end -}}`)}, {Name: "templates/test.yaml", Data: []byte(`{{ tpl "{{ include \"test\" . }}" . }}`)}, }, @@ -154,7 +155,7 @@ func TestRenderRefsOrdering(t *testing.T) { Name: "child", Version: "1.2.3", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/_helpers.tpl", Data: []byte(`{{- define "test" -}}child value{{- end -}}`)}, }, } @@ -165,7 +166,7 @@ func TestRenderRefsOrdering(t *testing.T) { } for i := 0; i < 100; i++ { - out, err := Render(parentChart, chartutil.Values{}) + out, err := Render(parentChart, common.Values{}) if err != nil { t.Fatalf("Failed to render templates: %s", err) } @@ -181,7 +182,7 @@ func TestRenderRefsOrdering(t *testing.T) { func TestRenderInternals(t *testing.T) { // Test the internals of the rendering tool. - vals := chartutil.Values{"Name": "one", "Value": "two"} + vals := common.Values{"Name": "one", "Value": "two"} tpls := map[string]renderable{ "one": {tpl: `Hello {{title .Name}}`, vals: vals}, "two": {tpl: `Goodbye {{upper .Value}}`, vals: vals}, @@ -218,7 +219,7 @@ func TestRenderWithDNS(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/test1", Data: []byte("{{getHostByName \"helm.sh\"}}")}, }, Values: map[string]interface{}{}, @@ -228,7 +229,7 @@ func TestRenderWithDNS(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } @@ -355,7 +356,7 @@ func TestRenderWithClientProvider(t *testing.T) { } for name, exp := range cases { - c.Templates = append(c.Templates, &chart.File{ + c.Templates = append(c.Templates, &common.File{ Name: path.Join("templates", name), Data: []byte(exp.template), }) @@ -365,7 +366,7 @@ func TestRenderWithClientProvider(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } @@ -391,7 +392,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { Name: "moby", Version: "1.2.3", }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/error", Data: []byte(`{{ lookup "v1" "Error" "" "" }}`)}, }, Values: map[string]interface{}{}, @@ -401,7 +402,7 @@ func TestRenderWithClientProvider_error(t *testing.T) { "Values": map[string]interface{}{}, } - v, err := chartutil.CoalesceValues(c, vals) + v, err := util.CoalesceValues(c, vals) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } @@ -448,7 +449,7 @@ func TestParallelRenderInternals(t *testing.T) { } func TestParseErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} tplsUndefinedFunction := map[string]renderable{ "undefined_function": {tpl: `{{foo}}`, vals: vals}, @@ -464,7 +465,7 @@ func TestParseErrors(t *testing.T) { } func TestExecErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} cases := []struct { name string tpls map[string]renderable @@ -528,7 +529,7 @@ linebreak`, } func TestFailErrors(t *testing.T) { - vals := chartutil.Values{"Values": map[string]interface{}{}} + vals := common.Values{"Values": map[string]interface{}{}} failtpl := `All your base are belong to us{{ fail "This is an error" }}` tplsFailed := map[string]renderable{ @@ -559,14 +560,14 @@ func TestFailErrors(t *testing.T) { func TestAllTemplates(t *testing.T) { ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "ch1"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/foo", Data: []byte("foo")}, {Name: "templates/bar", Data: []byte("bar")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "laboratory mice"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/pinky", Data: []byte("pinky")}, {Name: "templates/brain", Data: []byte("brain")}, }, @@ -575,13 +576,13 @@ func TestAllTemplates(t *testing.T) { dep2 := &chart.Chart{ Metadata: &chart.Metadata{Name: "same thing we do every night"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/innermost", Data: []byte("innermost")}, }, } dep1.AddDependency(dep2) - tpls := allTemplates(ch1, chartutil.Values{}) + tpls := allTemplates(ch1, common.Values{}) if len(tpls) != 5 { t.Errorf("Expected 5 charts, got %d", len(tpls)) } @@ -590,19 +591,19 @@ func TestAllTemplates(t *testing.T) { func TestChartValuesContainsIsRoot(t *testing.T) { ch1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "parent"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, }, } dep1 := &chart.Chart{ Metadata: &chart.Metadata{Name: "child"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/isroot", Data: []byte("{{.Chart.IsRoot}}")}, }, } ch1.AddDependency(dep1) - out, err := Render(ch1, chartutil.Values{}) + out, err := Render(ch1, common.Values{}) if err != nil { t.Fatalf("failed to render templates: %s", err) } @@ -622,13 +623,13 @@ func TestRenderDependency(t *testing.T) { toptpl := `Hello {{template "myblock"}}` ch := &chart.Chart{ Metadata: &chart.Metadata{Name: "outerchart"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/outer", Data: []byte(toptpl)}, }, } ch.AddDependency(&chart.Chart{ Metadata: &chart.Metadata{Name: "innerchart"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/inner", Data: []byte(deptpl)}, }, }) @@ -660,7 +661,7 @@ func TestRenderNestedValues(t *testing.T) { deepest := &chart.Chart{ Metadata: &chart.Metadata{Name: "deepest"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: deepestpath, Data: []byte(`And this same {{.Values.what}} that smiles {{.Values.global.when}}`)}, {Name: checkrelease, Data: []byte(`Tomorrow will be {{default "happy" .Release.Name }}`)}, }, @@ -669,7 +670,7 @@ func TestRenderNestedValues(t *testing.T) { inner := &chart.Chart{ Metadata: &chart.Metadata{Name: "herrick"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: innerpath, Data: []byte(`Old {{.Values.who}} is still a-flyin'`)}, }, Values: map[string]interface{}{"who": "Robert", "what": "glasses"}, @@ -678,7 +679,7 @@ func TestRenderNestedValues(t *testing.T) { outer := &chart.Chart{ Metadata: &chart.Metadata{Name: "top"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: outerpath, Data: []byte(`Gather ye {{.Values.what}} while ye may`)}, {Name: subchartspath, Data: []byte(`The glorious Lamp of {{.Subcharts.herrick.Subcharts.deepest.Values.where}}, the {{.Subcharts.herrick.Values.what}}`)}, }, @@ -706,15 +707,15 @@ func TestRenderNestedValues(t *testing.T) { }, } - tmp, err := chartutil.CoalesceValues(outer, injValues) + tmp, err := util.CoalesceValues(outer, injValues) if err != nil { t.Fatalf("Failed to coalesce values: %s", err) } - inject := chartutil.Values{ + inject := common.Values{ "Values": tmp, "Chart": outer.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "dyin", }, } @@ -754,30 +755,30 @@ func TestRenderNestedValues(t *testing.T) { func TestRenderBuiltinValues(t *testing.T) { inner := &chart.Chart{ - Metadata: &chart.Metadata{Name: "Latium"}, - Templates: []*chart.File{ + Metadata: &chart.Metadata{Name: "Latium", APIVersion: chart.APIVersionV2}, + Templates: []*common.File{ {Name: "templates/Lavinia", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, {Name: "templates/From", Data: []byte(`{{.Files.author | printf "%s"}} {{.Files.Get "book/title.txt"}}`)}, }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "author", Data: []byte("Virgil")}, {Name: "book/title.txt", Data: []byte("Aeneid")}, }, } outer := &chart.Chart{ - Metadata: &chart.Metadata{Name: "Troy"}, - Templates: []*chart.File{ + Metadata: &chart.Metadata{Name: "Troy", APIVersion: chart.APIVersionV2}, + Templates: []*common.File{ {Name: "templates/Aeneas", Data: []byte(`{{.Template.Name}}{{.Chart.Name}}{{.Release.Name}}`)}, {Name: "templates/Amata", Data: []byte(`{{.Subcharts.Latium.Chart.Name}} {{.Subcharts.Latium.Files.author | printf "%s"}}`)}, }, } outer.AddDependency(inner) - inject := chartutil.Values{ + inject := common.Values{ "Values": "", "Chart": outer.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "Aeneid", }, } @@ -806,7 +807,7 @@ func TestRenderBuiltinValues(t *testing.T) { func TestAlterFuncMap_include(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conrad"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/quote", Data: []byte(`{{include "conrad/templates/_partial" . | indent 2}} dead.`)}, {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, }, @@ -815,16 +816,16 @@ func TestAlterFuncMap_include(t *testing.T) { // Check nested reference in include FuncMap d := &chart.Chart{ Metadata: &chart.Metadata{Name: "nested"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/quote", Data: []byte(`{{include "nested/templates/quote" . | indent 2}} dead.`)}, {Name: "templates/_partial", Data: []byte(`{{.Release.Name}} - he`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Values": "", "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "Mistah Kurtz", }, } @@ -849,19 +850,19 @@ func TestAlterFuncMap_include(t *testing.T) { func TestAlterFuncMap_require(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "conan"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/quote", Data: []byte(`All your base are belong to {{ required "A valid 'who' is required" .Values.who }}`)}, {Name: "templates/bases", Data: []byte(`All {{ required "A valid 'bases' is required" .Values.bases }} of them!`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "who": "us", "bases": 2, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "That 90s meme", }, } @@ -882,12 +883,12 @@ func TestAlterFuncMap_require(t *testing.T) { // test required without passing in needed values with lint mode on // verifies lint replaces required with an empty string (should not fail) - lintValues := chartutil.Values{ - "Values": chartutil.Values{ + lintValues := common.Values{ + "Values": common.Values{ "who": "us", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "That 90s meme", }, } @@ -911,17 +912,17 @@ func TestAlterFuncMap_require(t *testing.T) { func TestAlterFuncMap_tpl(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value}}" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -940,17 +941,17 @@ func TestAlterFuncMap_tpl(t *testing.T) { func TestAlterFuncMap_tplfunc(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/base", Data: []byte(`Evaluate tpl {{tpl "Value: {{ .Values.value | quote}}" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -969,17 +970,17 @@ func TestAlterFuncMap_tplfunc(t *testing.T) { func TestAlterFuncMap_tplinclude(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplFunction"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/base", Data: []byte(`{{ tpl "{{include ` + "`" + `TplFunction/templates/_partial` + "`" + ` . | quote }}" .}}`)}, {Name: "templates/_partial", Data: []byte(`{{.Template.Name}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "value": "myvalue", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1000,15 +1001,15 @@ func TestRenderRecursionLimit(t *testing.T) { // endless recursion should produce an error c := &chart.Chart{ Metadata: &chart.Metadata{Name: "bad"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/base", Data: []byte(`{{include "recursion" . }}`)}, {Name: "templates/recursion", Data: []byte(`{{define "recursion"}}{{include "recursion" . }}{{end}}`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Values": "", "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1030,7 +1031,7 @@ func TestRenderRecursionLimit(t *testing.T) { d := &chart.Chart{ Metadata: &chart.Metadata{Name: "overlook"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/quote", Data: []byte(repeatedIncl)}, {Name: "templates/_function", Data: []byte(printFunc)}, }, @@ -1054,23 +1055,23 @@ func TestRenderRecursionLimit(t *testing.T) { func TestRenderLoadTemplateForTplFromFile(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplLoadFromFile"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/base", Data: []byte(`{{ tpl (.Files.Get .Values.filename) . }}`)}, {Name: "templates/_function", Data: []byte(`{{define "test-function"}}test-function{{end}}`)}, }, - Files: []*chart.File{ + Files: []*common.File{ {Name: "test", Data: []byte(`{{ tpl (.Files.Get .Values.filename2) .}}`)}, {Name: "test2", Data: []byte(`{{include "test-function" .}}{{define "nested-define"}}nested-define-content{{end}} {{include "nested-define" .}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "filename": "test", "filename2": "test2", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1089,15 +1090,15 @@ func TestRenderLoadTemplateForTplFromFile(t *testing.T) { func TestRenderTplEmpty(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplEmpty"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/empty-string", Data: []byte(`{{tpl "" .}}`)}, {Name: "templates/empty-action", Data: []byte(`{{tpl "{{ \"\"}}" .}}`)}, {Name: "templates/only-defines", Data: []byte(`{{tpl "{{define \"not-invoked\"}}not-rendered{{end}}" .}}`)}, }, } - v := chartutil.Values{ + v := common.Values{ "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1123,7 +1124,7 @@ func TestRenderTplTemplateNames(t *testing.T) { // .Template.BasePath and .Name make it through c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplTemplateNames"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/default-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .}}`)}, {Name: "templates/default-name", Data: []byte(`{{tpl "{{ .Template.Name }}" .}}`)}, {Name: "templates/modified-basepath", Data: []byte(`{{tpl "{{ .Template.BasePath }}" .Values.dot}}`)}, @@ -1131,10 +1132,10 @@ func TestRenderTplTemplateNames(t *testing.T) { {Name: "templates/modified-field", Data: []byte(`{{tpl "{{ .Template.Field }}" .Values.dot}}`)}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ - "dot": chartutil.Values{ - "Template": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ + "dot": common.Values{ + "Template": common.Values{ "BasePath": "path/to/template", "Name": "name-of-template", "Field": "extra-field", @@ -1142,7 +1143,7 @@ func TestRenderTplTemplateNames(t *testing.T) { }, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1170,7 +1171,7 @@ func TestRenderTplRedefines(t *testing.T) { // Redefining a template inside 'tpl' does not affect the outer definition c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplRedefines"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/_partials", Data: []byte(`{{define "partial"}}original-in-partial{{end}}`)}, {Name: "templates/partial", Data: []byte( `before: {{include "partial" .}}\n{{tpl .Values.partialText .}}\nafter: {{include "partial" .}}`, @@ -1192,8 +1193,8 @@ func TestRenderTplRedefines(t *testing.T) { )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "partialText": `{{define "partial"}}redefined-in-tpl{{end}}tpl: {{include "partial" .}}`, "manifestText": `{{define "manifest"}}redefined-in-tpl{{end}}tpl: {{include "manifest" .}}`, "manifestOnlyText": `tpl: {{include "manifest-only" .}}`, @@ -1205,7 +1206,7 @@ func TestRenderTplRedefines(t *testing.T) { "innerText": `{{define "nested"}}redefined-in-inner-tpl{{end}}inner-tpl: {{include "nested" .}} {{include "nested-outer" . }}`, }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1236,16 +1237,16 @@ func TestRenderTplMissingKey(t *testing.T) { // Rendering a missing key results in empty/zero output. c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKey"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/manifest", Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{}, + v := common.Values{ + "Values": common.Values{}, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1269,16 +1270,16 @@ func TestRenderTplMissingKeyString(t *testing.T) { // Rendering a missing key results in error c := &chart.Chart{ Metadata: &chart.Metadata{Name: "TplMissingKeyStrict"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/manifest", Data: []byte( `missingValue: {{tpl "{{.Values.noSuchKey}}" .}}`, )}, }, } - v := chartutil.Values{ - "Values": chartutil.Values{}, + v := common.Values{ + "Values": common.Values{}, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } @@ -1301,7 +1302,7 @@ func TestRenderTplMissingKeyString(t *testing.T) { func TestNestedHelpersProducesMultilineStacktrace(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "NestedHelperFunctions"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/svc.yaml", Data: []byte( `name: {{ include "nested_helper.name" . }}`, )}, @@ -1324,9 +1325,9 @@ NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 executing "common.names.get_name" at <.Values.nonexistant.key>: nil pointer evaluating interface {}.key` - v := chartutil.Values{} + v := common.Values{} - val, _ := chartutil.CoalesceValues(c, v) + val, _ := util.CoalesceValues(c, v) vals := map[string]interface{}{ "Values": val.AsMap(), } @@ -1339,7 +1340,7 @@ NestedHelperFunctions/charts/common/templates/_helpers_2.tpl:1:49 func TestMultilineNoTemplateAssociatedError(t *testing.T) { c := &chart.Chart{ Metadata: &chart.Metadata{Name: "multiline"}, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/svc.yaml", Data: []byte( `name: {{ include "nested_helper.name" . }}`, )}, @@ -1357,9 +1358,9 @@ func TestMultilineNoTemplateAssociatedError(t *testing.T) { error calling include: template: no template "nested_helper.name" associated with template "gotpl"` - v := chartutil.Values{} + v := common.Values{} - val, _ := chartutil.CoalesceValues(c, v) + val, _ := util.CoalesceValues(c, v) vals := map[string]interface{}{ "Values": val.AsMap(), } @@ -1373,7 +1374,7 @@ func TestRenderCustomTemplateFuncs(t *testing.T) { // Create a chart with two templates that use custom functions c := &chart.Chart{ Metadata: &chart.Metadata{Name: "CustomFunc"}, - Templates: []*chart.File{ + Templates: []*common.File{ { Name: "templates/manifest", Data: []byte(`{{exclaim .Values.message}}`), @@ -1384,12 +1385,12 @@ func TestRenderCustomTemplateFuncs(t *testing.T) { }, }, } - v := chartutil.Values{ - "Values": chartutil.Values{ + v := common.Values{ + "Values": common.Values{ "message": "hello", }, "Chart": c.Metadata, - "Release": chartutil.Values{ + "Release": common.Values{ "Name": "TestRelease", }, } diff --git a/pkg/engine/files.go b/pkg/engine/files.go index 87166728c..f0a86988e 100644 --- a/pkg/engine/files.go +++ b/pkg/engine/files.go @@ -23,7 +23,7 @@ import ( "github.com/gobwas/glob" - chart "helm.sh/helm/v4/pkg/chart/v2" + "helm.sh/helm/v4/pkg/chart/common" ) // files is a map of files in a chart that can be accessed from a template. @@ -31,7 +31,7 @@ type files map[string][]byte // NewFiles creates a new files from chart files. // Given an []*chart.File (the format for files in a chart.Chart), extract a map of files. -func newFiles(from []*chart.File) files { +func newFiles(from []*common.File) files { files := make(map[string][]byte) for _, f := range from { files[f.Name] = f.Data diff --git a/pkg/engine/lookup_func.go b/pkg/engine/lookup_func.go index 605b43a48..18ed2b63b 100644 --- a/pkg/engine/lookup_func.go +++ b/pkg/engine/lookup_func.go @@ -35,7 +35,7 @@ type lookupFunc = func(apiversion string, resource string, namespace string, nam // NewLookupFunction returns a function for looking up objects in the cluster. // // If the resource does not exist, no error is raised. -func NewLookupFunction(config *rest.Config) lookupFunc { +func NewLookupFunction(config *rest.Config) lookupFunc { //nolint:revive return newLookupFunction(clientProviderFromConfig{config: config}) } diff --git a/pkg/release/v1/mock.go b/pkg/release/v1/mock.go index 3d3b0c2e2..c3a6594cc 100644 --- a/pkg/release/v1/mock.go +++ b/pkg/release/v1/mock.go @@ -20,6 +20,7 @@ import ( "fmt" "math/rand" + "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/time" ) @@ -98,7 +99,7 @@ func Mock(opts *MockReleaseOptions) *Release { }, }, }, - Templates: []*chart.File{ + Templates: []*common.File{ {Name: "templates/foo.tpl", Data: []byte(MockManifest)}, }, } diff --git a/pkg/release/v1/util/manifest_sorter.go b/pkg/release/v1/util/manifest_sorter.go index 21fdec7c6..6f7b4ea8b 100644 --- a/pkg/release/v1/util/manifest_sorter.go +++ b/pkg/release/v1/util/manifest_sorter.go @@ -26,7 +26,7 @@ import ( "sigs.k8s.io/yaml" - chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/chart/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -74,7 +74,7 @@ var events = map[string]release.HookEvent{ // // Files that do not parse into the expected format are simply placed into a map and // returned. -func SortManifests(files map[string]string, _ chartutil.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) { +func SortManifests(files map[string]string, _ common.VersionSet, ordering KindSortOrder) ([]*release.Hook, []Manifest, error) { result := &result{} var sortedFilePaths []string From a8151ef4fef684992a66956399fded22f7f24502 Mon Sep 17 00:00:00 2001 From: George Jenkins Date: Mon, 25 Aug 2025 11:45:49 -0700 Subject: [PATCH 30/55] Cleanup plugin config Signed-off-by: George Jenkins --- internal/plugin/config.go | 63 +++++-------------- internal/plugin/config_test.go | 56 +++++++++++++++++ internal/plugin/loader_test.go | 8 ++- internal/plugin/metadata.go | 30 +-------- internal/plugin/plugin_test.go | 4 +- internal/plugin/plugin_type_registry.go | 10 ++- internal/plugin/plugin_type_registry_test.go | 2 +- internal/plugin/runtime_subprocess_test.go | 2 +- internal/plugin/schema/cli.go | 19 ++++++ internal/plugin/schema/doc.go | 18 ++++++ internal/plugin/schema/getter.go | 21 ++++++- internal/plugin/schema/postrenderer.go | 6 ++ pkg/cmd/load_plugins.go | 4 +- pkg/cmd/plugin_list.go | 3 +- .../helm/plugins/postrenderer-v1/plugin.yaml | 4 -- pkg/getter/plugingetter.go | 2 +- pkg/getter/plugingetter_test.go | 2 +- 17 files changed, 161 insertions(+), 93 deletions(-) create mode 100644 internal/plugin/config_test.go create mode 100644 internal/plugin/schema/doc.go diff --git a/internal/plugin/config.go b/internal/plugin/config.go index e8bf4e356..e1f491779 100644 --- a/internal/plugin/config.go +++ b/internal/plugin/config.go @@ -16,72 +16,39 @@ limitations under the License. package plugin import ( + "bytes" "fmt" + "reflect" "go.yaml.in/yaml/v3" ) -// Config interface defines the methods that all plugin type configurations must implement +// Config represents an plugin type specific configuration +// It is expected to type assert (cast) the a Config to its expected underlying type (schema.ConfigCLIV1, schema.ConfigGetterV1, etc). 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 unmarshaConfig(pluginType string, configData map[string]any) (Config, error) { -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) - } + pluginTypeMeta, ok := pluginTypesIndex[pluginType] + if !ok { + return nil, fmt.Errorf("unknown plugin type %q", pluginType) } - return nil -} -func (c *ConfigPostrenderer) Validate() error { - // Config validation for postrenderer plugins - return nil -} + // TODO: Avoid (yaml) serialization/deserialization for type conversion here -func remarshalConfig[T Config](configData map[string]any) (Config, error) { data, err := yaml.Marshal(configData) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshel config data (plugin type %s): %w", pluginType, err) } - var config T - if err := yaml.Unmarshal(data, &config); err != nil { + config := reflect.New(pluginTypeMeta.configType) + d := yaml.NewDecoder(bytes.NewReader(data)) + d.KnownFields(true) + if err := d.Decode(config.Interface()); err != nil { return nil, err } - return config, nil + return config.Interface().(Config), nil } diff --git a/internal/plugin/config_test.go b/internal/plugin/config_test.go new file mode 100644 index 000000000..c51b77ff0 --- /dev/null +++ b/internal/plugin/config_test.go @@ -0,0 +1,56 @@ +/* +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" + "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/internal/plugin/schema" +) + +func TestUnmarshaConfig(t *testing.T) { + // Test unmarshalling a CLI plugin config + { + config, err := unmarshaConfig("cli/v1", map[string]any{ + "usage": "usage string", + "shortHelp": "short help string", + "longHelp": "long help string", + "ignoreFlags": true, + }) + require.NoError(t, err) + + require.IsType(t, &schema.ConfigCLIV1{}, config) + assert.Equal(t, schema.ConfigCLIV1{ + Usage: "usage string", + ShortHelp: "short help string", + LongHelp: "long help string", + IgnoreFlags: true, + }, *(config.(*schema.ConfigCLIV1))) + } + + // Test unmarshalling invalid config data + { + config, err := unmarshaConfig("cli/v1", map[string]any{ + "invalid field": "foo", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "field not found") + assert.Nil(t, config) + } +} diff --git a/internal/plugin/loader_test.go b/internal/plugin/loader_test.go index d214f7b6b..47c214910 100644 --- a/internal/plugin/loader_test.go +++ b/internal/plugin/loader_test.go @@ -22,6 +22,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "helm.sh/helm/v4/internal/plugin/schema" ) func TestPeekAPIVersion(t *testing.T) { @@ -73,7 +75,7 @@ func TestLoadDir(t *testing.T) { Version: "0.1.0", Type: "cli/v1", Runtime: "subprocess", - Config: &ConfigCLI{ + Config: &schema.ConfigCLIV1{ Usage: usage, ShortHelp: "echo hello message", LongHelp: "description", @@ -145,7 +147,7 @@ func TestLoadDirGetter(t *testing.T) { Type: "getter/v1", APIVersion: "v1", Runtime: "subprocess", - Config: &ConfigGetter{ + Config: &schema.ConfigGetterV1{ Protocols: []string{"myprotocol", "myprotocols"}, }, RuntimeConfig: &RuntimeConfigSubprocess{ @@ -173,7 +175,7 @@ func TestPostRenderer(t *testing.T) { Type: "postrenderer/v1", APIVersion: "v1", Runtime: "subprocess", - Config: &ConfigPostrenderer{}, + Config: &schema.ConfigPostRendererV1{}, RuntimeConfig: &RuntimeConfigSubprocess{ PlatformCommand: []PlatformCommand{ { diff --git a/internal/plugin/metadata.go b/internal/plugin/metadata.go index 1c4f02836..111c0599f 100644 --- a/internal/plugin/metadata.go +++ b/internal/plugin/metadata.go @@ -123,11 +123,11 @@ func buildLegacyConfig(m MetadataLegacy, pluginType string) Config { for _, d := range m.Downloaders { protocols = append(protocols, d.Protocols...) } - return &ConfigGetter{ + return &schema.ConfigGetterV1{ Protocols: protocols, } case "cli/v1": - return &ConfigCLI{ + return &schema.ConfigCLIV1{ 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 @@ -175,7 +175,7 @@ func buildLegacyRuntimeConfig(m MetadataLegacy) RuntimeConfig { func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) { - config, err := convertMetadataConfig(mv1.Type, mv1.Config) + config, err := unmarshaConfig(mv1.Type, mv1.Config) if err != nil { return nil, err } @@ -197,30 +197,6 @@ func fromMetadataV1(mv1 MetadataV1) (*Metadata, error) { }, 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 diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index a4de8e52a..b6c2245ff 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -17,6 +17,8 @@ package plugin import ( "testing" + + "helm.sh/helm/v4/internal/plugin/schema" ) func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginRuntime { @@ -46,7 +48,7 @@ func mockSubprocessCLIPlugin(t *testing.T, pluginName string) *SubprocessPluginR Type: "cli/v1", APIVersion: "v1", Runtime: "subprocess", - Config: &ConfigCLI{ + Config: &schema.ConfigCLIV1{ Usage: "Mock plugin", ShortHelp: "Mock plugin", LongHelp: "Mock plugin for testing", diff --git a/internal/plugin/plugin_type_registry.go b/internal/plugin/plugin_type_registry.go index 63450b823..da6546c47 100644 --- a/internal/plugin/plugin_type_registry.go +++ b/internal/plugin/plugin_type_registry.go @@ -81,13 +81,19 @@ var pluginTypes = []pluginTypeMeta{ pluginType: "cli/v1", inputType: reflect.TypeOf(schema.InputMessageCLIV1{}), outputType: reflect.TypeOf(schema.OutputMessageCLIV1{}), - configType: reflect.TypeOf(ConfigCLI{}), + configType: reflect.TypeOf(schema.ConfigCLIV1{}), }, { pluginType: "getter/v1", inputType: reflect.TypeOf(schema.InputMessageGetterV1{}), outputType: reflect.TypeOf(schema.OutputMessageGetterV1{}), - configType: reflect.TypeOf(ConfigGetter{}), + configType: reflect.TypeOf(schema.ConfigGetterV1{}), + }, + { + pluginType: "postrenderer/v1", + inputType: reflect.TypeOf(schema.InputMessagePostRendererV1{}), + outputType: reflect.TypeOf(schema.OutputMessagePostRendererV1{}), + configType: reflect.TypeOf(schema.ConfigPostRendererV1{}), }, } diff --git a/internal/plugin/plugin_type_registry_test.go b/internal/plugin/plugin_type_registry_test.go index ee8a44bb6..22f26262d 100644 --- a/internal/plugin/plugin_type_registry_test.go +++ b/internal/plugin/plugin_type_registry_test.go @@ -34,5 +34,5 @@ func TestMakeOutputMessage(t *testing.T) { func TestMakeConfig(t *testing.T) { ptm := pluginTypesIndex["getter/v1"] config := reflect.New(ptm.configType).Interface().(Config) - assert.IsType(t, &ConfigGetter{}, config) + assert.IsType(t, &schema.ConfigGetterV1{}, config) } diff --git a/internal/plugin/runtime_subprocess_test.go b/internal/plugin/runtime_subprocess_test.go index dab372027..243f4ad7c 100644 --- a/internal/plugin/runtime_subprocess_test.go +++ b/internal/plugin/runtime_subprocess_test.go @@ -45,7 +45,7 @@ func mockSubprocessCLIPluginErrorExit(t *testing.T, pluginName string, exitCode Type: "cli/v1", APIVersion: "v1", Runtime: "subprocess", - Config: &ConfigCLI{ + Config: &schema.ConfigCLIV1{ Usage: "Mock plugin", ShortHelp: "Mock plugin", LongHelp: "Mock plugin for testing", diff --git a/internal/plugin/schema/cli.go b/internal/plugin/schema/cli.go index 3976d3737..702b27e45 100644 --- a/internal/plugin/schema/cli.go +++ b/internal/plugin/schema/cli.go @@ -27,3 +27,22 @@ type InputMessageCLIV1 struct { type OutputMessageCLIV1 struct { Data *bytes.Buffer `json:"data"` } + +// ConfigCLIV1 represents the configuration for CLI plugins +type ConfigCLIV1 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"` +} + +func (c *ConfigCLIV1) Validate() error { + // Config validation for CLI plugins + return nil +} diff --git a/internal/plugin/schema/doc.go b/internal/plugin/schema/doc.go new file mode 100644 index 000000000..4b3fe5d49 --- /dev/null +++ b/internal/plugin/schema/doc.go @@ -0,0 +1,18 @@ +/* + 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 diff --git a/internal/plugin/schema/getter.go b/internal/plugin/schema/getter.go index f9840008e..2c5e81df1 100644 --- a/internal/plugin/schema/getter.go +++ b/internal/plugin/schema/getter.go @@ -14,10 +14,11 @@ package schema import ( + "fmt" "time" ) -// TODO: can we generate these plugin input/outputs? +// TODO: can we generate these plugin input/output messages? type GetterOptionsV1 struct { URL string @@ -45,3 +46,21 @@ type InputMessageGetterV1 struct { type OutputMessageGetterV1 struct { Data []byte `json:"data"` } + +// ConfigGetterV1 represents the configuration for download plugins +type ConfigGetterV1 struct { + // Protocols are the list of URL schemes supported by this downloader + Protocols []string `yaml:"protocols"` +} + +func (c *ConfigGetterV1) 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 +} diff --git a/internal/plugin/schema/postrenderer.go b/internal/plugin/schema/postrenderer.go index 82fd3059f..ef51a8a61 100644 --- a/internal/plugin/schema/postrenderer.go +++ b/internal/plugin/schema/postrenderer.go @@ -30,3 +30,9 @@ type InputMessagePostRendererV1 struct { type OutputMessagePostRendererV1 struct { Manifests *bytes.Buffer `json:"manifests"` } + +type ConfigPostRendererV1 struct{} + +func (c *ConfigPostRendererV1) Validate() error { + return nil +} diff --git a/pkg/cmd/load_plugins.go b/pkg/cmd/load_plugins.go index 75cfdc3cf..c0593f384 100644 --- a/pkg/cmd/load_plugins.go +++ b/pkg/cmd/load_plugins.go @@ -71,7 +71,7 @@ func loadCLIPlugins(baseCmd *cobra.Command, out io.Writer) { for _, plug := range found { var use, short, long string var ignoreFlags bool - if cliConfig, ok := plug.Metadata().Config.(*plugin.ConfigCLI); ok { + if cliConfig, ok := plug.Metadata().Config.(*schema.ConfigCLIV1); ok { use = cliConfig.Usage short = cliConfig.ShortHelp long = cliConfig.LongHelp @@ -340,7 +340,7 @@ func pluginDynamicComp(plug plugin.Plugin, cmd *cobra.Command, args []string, to } var ignoreFlags bool - if cliConfig, ok := subprocessPlug.Metadata().Config.(*plugin.ConfigCLI); ok { + if cliConfig, ok := subprocessPlug.Metadata().Config.(*schema.ConfigCLIV1); ok { ignoreFlags = cliConfig.IgnoreFlags } diff --git a/pkg/cmd/plugin_list.go b/pkg/cmd/plugin_list.go index 9b2895441..74e969e04 100644 --- a/pkg/cmd/plugin_list.go +++ b/pkg/cmd/plugin_list.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v4/internal/plugin" + "helm.sh/helm/v4/internal/plugin/schema" ) func newPluginListCmd(out io.Writer) *cobra.Command { @@ -106,7 +107,7 @@ func compListPlugins(_ string, ignoredPluginNames []string) []string { for _, p := range filteredPlugins { m := p.Metadata() var shortHelp string - if config, ok := m.Config.(*plugin.ConfigCLI); ok { + if config, ok := m.Config.(*schema.ConfigCLIV1); ok { shortHelp = config.ShortHelp } pNames = append(pNames, fmt.Sprintf("%s\t%s", p.Metadata().Name, shortHelp)) diff --git a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml index d4cd57a13..b6e8afa57 100644 --- a/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml +++ b/pkg/cmd/testdata/helmhome/helm/plugins/postrenderer-v1/plugin.yaml @@ -4,10 +4,6 @@ name: "postrenderer-v1" version: "1.2.3" type: postrenderer/v1 runtime: subprocess -config: - shortHelp: "echo test" - longHelp: "This echos test" - ignoreFlags: false runtimeConfig: platformCommand: - command: "${HELM_PLUGIN_DIR}/sed-test.sh" diff --git a/pkg/getter/plugingetter.go b/pkg/getter/plugingetter.go index b2dfb3e42..32dbc70c9 100644 --- a/pkg/getter/plugingetter.go +++ b/pkg/getter/plugingetter.go @@ -49,7 +49,7 @@ func collectGetterPlugins(settings *cli.EnvSettings) (Providers, error) { } results := make([]Provider, 0, len(plgs)) for _, plg := range plgs { - if c, ok := plg.Metadata().Config.(*plugin.ConfigGetter); ok { + if c, ok := plg.Metadata().Config.(*schema.ConfigGetterV1); ok { results = append(results, Provider{ Schemes: c.Protocols, New: pluginConstructorBuilder(plg), diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 23cfc80f8..8faaf7329 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -110,7 +110,7 @@ func (t *testPlugin) Metadata() plugin.Metadata { Type: "cli/v1", APIVersion: "v1", Runtime: "subprocess", - Config: &plugin.ConfigCLI{}, + Config: &schema.ConfigCLIV1{}, RuntimeConfig: &plugin.RuntimeConfigSubprocess{ PlatformCommand: []plugin.PlatformCommand{ { From 38d1a7376ff77a609874b3263427f711da946e32 Mon Sep 17 00:00:00 2001 From: Kamil Swiechowski Date: Fri, 11 Jul 2025 16:52:58 +0200 Subject: [PATCH 31/55] fix: throw warning when chart version is not semverv2 Signed-off-by: Kamil Swiechowski --- pkg/chart/v2/lint/lint_test.go | 13 +++++-- pkg/chart/v2/lint/rules/chartfile.go | 11 ++++++ pkg/chart/v2/lint/rules/chartfile_test.go | 39 ++++++++++++++++++- pkg/chart/v2/metadata.go | 2 +- ...hart-with-bad-subcharts-with-subcharts.txt | 1 + 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/pkg/chart/v2/lint/lint_test.go b/pkg/chart/v2/lint/lint_test.go index 3c777e2bb..bd3ec1f1f 100644 --- a/pkg/chart/v2/lint/lint_test.go +++ b/pkg/chart/v2/lint/lint_test.go @@ -42,12 +42,12 @@ const invalidChartFileDir = "rules/testdata/invalidchartfile" func TestBadChart(t *testing.T) { m := RunAll(badChartDir, values, namespace).Messages - if len(m) != 8 { + if len(m) != 9 { t.Errorf("Number of errors %v", len(m)) t.Errorf("All didn't fail with expected errors, got %#v", m) } - // There should be one INFO, one WARNING, and 2 ERROR messages, check for them - var i, w, e, e2, e3, e4, e5, e6 bool + // There should be one INFO, 2 WARNING and 2 ERROR messages, check for them + var i, w, w2, e, e2, e3, e4, e5, e6 bool for _, msg := range m { if msg.Severity == support.InfoSev { if strings.Contains(msg.Err.Error(), "icon is recommended") { @@ -83,8 +83,13 @@ func TestBadChart(t *testing.T) { e6 = true } } + if msg.Severity == support.WarningSev { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { + w2 = true + } + } } - if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w { + if !e || !e2 || !e3 || !e4 || !e5 || !i || !e6 || !w || !w2 { t.Errorf("Didn't find all the expected errors, got %#v", m) } } diff --git a/pkg/chart/v2/lint/rules/chartfile.go b/pkg/chart/v2/lint/rules/chartfile.go index 185f524a4..806363477 100644 --- a/pkg/chart/v2/lint/rules/chartfile.go +++ b/pkg/chart/v2/lint/rules/chartfile.go @@ -67,6 +67,7 @@ func Chartfile(linter *support.Linter) { linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartIconURL(chartFile)) linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartType(chartFile)) linter.RunLinterRule(support.ErrorSev, chartFileName, validateChartDependencies(chartFile)) + linter.RunLinterRule(support.WarningSev, chartFileName, validateChartVersionStrictSemVerV2(chartFile)) } func validateChartVersionType(data map[string]interface{}) error { @@ -158,6 +159,16 @@ func validateChartVersion(cf *chart.Metadata) error { return nil } +func validateChartVersionStrictSemVerV2(cf *chart.Metadata) error { + _, err := semver.StrictNewVersion(cf.Version) + + if err != nil { + return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version) + } + + return nil +} + func validateChartMaintainer(cf *chart.Metadata) error { for _, maintainer := range cf.Maintainers { if maintainer == nil { diff --git a/pkg/chart/v2/lint/rules/chartfile_test.go b/pkg/chart/v2/lint/rules/chartfile_test.go index 5a1ad2f24..ddaa72510 100644 --- a/pkg/chart/v2/lint/rules/chartfile_test.go +++ b/pkg/chart/v2/lint/rules/chartfile_test.go @@ -108,6 +108,35 @@ func TestValidateChartVersion(t *testing.T) { } } +func TestValidateChartVersionStrictSemVerV2(t *testing.T) { + var failTest = []struct { + Version string + ErrorMsg string + }{ + {"", "version '' is not a valid SemVerV2"}, + {"1", "version '1' is not a valid SemVerV2"}, + {"1.1", "version '1.1' is not a valid SemVerV2"}, + } + + var successTest = []string{"1.1.1", "0.0.1+build", "0.0.1-beta"} + + for _, test := range failTest { + badChart.Version = test.Version + err := validateChartVersionStrictSemVerV2(badChart) + if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { + t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + } + } + + for _, version := range successTest { + badChart.Version = version + err := validateChartVersionStrictSemVerV2(badChart) + if err != nil { + t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + } + } +} + func TestValidateChartMaintainer(t *testing.T) { var failTest = []struct { Name string @@ -226,7 +255,7 @@ func TestChartfile(t *testing.T) { linter := support.Linter{ChartDir: badChartDir} Chartfile(&linter) msgs := linter.Messages - expectedNumberOfErrorMessages := 6 + expectedNumberOfErrorMessages := 7 if len(msgs) != expectedNumberOfErrorMessages { t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) @@ -256,13 +285,16 @@ func TestChartfile(t *testing.T) { if !strings.Contains(msgs[5].Err.Error(), "dependencies are not valid in the Chart file with apiVersion") { t.Errorf("Unexpected message 5: %s", msgs[5].Err) } + if !strings.Contains(msgs[6].Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { + t.Errorf("Unexpected message 6: %s", msgs[6].Err) + } }) t.Run("Chart.yaml validity issues due to type mismatch", func(t *testing.T) { linter := support.Linter{ChartDir: anotherBadChartDir} Chartfile(&linter) msgs := linter.Messages - expectedNumberOfErrorMessages := 3 + expectedNumberOfErrorMessages := 4 if len(msgs) != expectedNumberOfErrorMessages { t.Errorf("Expected %d errors, got %d", expectedNumberOfErrorMessages, len(msgs)) @@ -280,5 +312,8 @@ func TestChartfile(t *testing.T) { if !strings.Contains(msgs[2].Err.Error(), "appVersion should be of type string") { t.Errorf("Unexpected message 2: %s", msgs[2].Err) } + if !strings.Contains(msgs[3].Err.Error(), "version '7.2445e+06' is not a valid SemVerV2") { + t.Errorf("Unexpected message 3: %s", msgs[3].Err) + } }) } diff --git a/pkg/chart/v2/metadata.go b/pkg/chart/v2/metadata.go index d213a3491..c46007863 100644 --- a/pkg/chart/v2/metadata.go +++ b/pkg/chart/v2/metadata.go @@ -52,7 +52,7 @@ type Metadata struct { Home string `json:"home,omitempty"` // Source is the URL to the source code of this chart Sources []string `json:"sources,omitempty"` - // A SemVer 2 conformant version string of the chart. Required. + // A version string of the chart. Required. Version string `json:"version,omitempty"` // A one-sentence description of the chart Description string `json:"description,omitempty"` diff --git a/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt index 7b445a69a..67ed58ec3 100644 --- a/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt +++ b/pkg/cmd/testdata/output/lint-chart-with-bad-subcharts-with-subcharts.txt @@ -9,6 +9,7 @@ [ERROR] Chart.yaml: apiVersion is required. The value must be either "v1" or "v2" [ERROR] Chart.yaml: version is required [INFO] Chart.yaml: icon is recommended +[WARNING] Chart.yaml: version '' is not a valid SemVerV2 [WARNING] templates/: directory does not exist [ERROR] : unable to load chart validation: chart.metadata.name is required From cd76ae1c934aeaa22842ef30c898e5888022973b Mon Sep 17 00:00:00 2001 From: Kamil Swiechowski Date: Wed, 3 Sep 2025 08:20:52 +0200 Subject: [PATCH 32/55] feat:strict compliance with semverv2 for chart/v3/linter Signed-off-by: Kamil Swiechowski --- internal/chart/v3/lint/lint_test.go | 2 +- internal/chart/v3/lint/rules/chartfile.go | 4 ++-- internal/chart/v3/lint/rules/chartfile_test.go | 8 +++++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/chart/v3/lint/lint_test.go b/internal/chart/v3/lint/lint_test.go index af44cc58d..6f5912ae7 100644 --- a/internal/chart/v3/lint/lint_test.go +++ b/internal/chart/v3/lint/lint_test.go @@ -60,7 +60,7 @@ func TestBadChartV3(t *testing.T) { } } if msg.Severity == support.ErrorSev { - if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVer") { + if strings.Contains(msg.Err.Error(), "version '0.0.0.0' is not a valid SemVerV2") { e = true } if strings.Contains(msg.Err.Error(), "name is required") { diff --git a/internal/chart/v3/lint/rules/chartfile.go b/internal/chart/v3/lint/rules/chartfile.go index e72a0d3b2..fc246ba80 100644 --- a/internal/chart/v3/lint/rules/chartfile.go +++ b/internal/chart/v3/lint/rules/chartfile.go @@ -140,9 +140,9 @@ func validateChartVersion(cf *chart.Metadata) error { return errors.New("version is required") } - version, err := semver.NewVersion(cf.Version) + version, err := semver.StrictNewVersion(cf.Version) if err != nil { - return fmt.Errorf("version '%s' is not a valid SemVer", cf.Version) + return fmt.Errorf("version '%s' is not a valid SemVerV2", cf.Version) } c, err := semver.NewConstraint(">0.0.0-0") diff --git a/internal/chart/v3/lint/rules/chartfile_test.go b/internal/chart/v3/lint/rules/chartfile_test.go index 070cc244d..57893e151 100644 --- a/internal/chart/v3/lint/rules/chartfile_test.go +++ b/internal/chart/v3/lint/rules/chartfile_test.go @@ -84,9 +84,11 @@ func TestValidateChartVersion(t *testing.T) { ErrorMsg string }{ {"", "version is required"}, - {"1.2.3.4", "version '1.2.3.4' is not a valid SemVer"}, - {"waps", "'waps' is not a valid SemVer"}, - {"-3", "'-3' is not a valid SemVer"}, + {"1.2.3.4", "version '1.2.3.4' is not a valid SemVerV2"}, + {"waps", "'waps' is not a valid SemVerV2"}, + {"-3", "'-3' is not a valid SemVerV2"}, + {"1.1", "'1.1' is not a valid SemVerV2"}, + {"1", "'1' is not a valid SemVerV2"}, } var successTest = []string{"0.0.1", "0.0.1+build", "0.0.1-beta"} From 031050675baae8bcf183cdb615d154f909db614b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:54:43 +0000 Subject: [PATCH 33/55] chore(deps): bump github.com/spf13/cobra from 1.9.1 to 1.10.1 Bumps [github.com/spf13/cobra](https://github.com/spf13/cobra) from 1.9.1 to 1.10.1. - [Release notes](https://github.com/spf13/cobra/releases) - [Commits](https://github.com/spf13/cobra/compare/v1.9.1...v1.10.1) --- updated-dependencies: - dependency-name: github.com/spf13/cobra dependency-version: 1.10.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index ab8797e6f..bbd273413 100644 --- a/go.mod +++ b/go.mod @@ -30,8 +30,8 @@ require ( 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/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/tetratelabs/wazero v1.9.0 go.yaml.in/yaml/v3 v3.0.4 diff --git a/go.sum b/go.sum index 076b6e5bd..cca32e249 100644 --- a/go.sum +++ b/go.sum @@ -300,11 +300,10 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= From 5445b6587b4af85be52e7c92ab3bcfb82cd7652f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 13:04:14 +0000 Subject: [PATCH 34/55] chore(deps): bump github.com/spf13/pflag from 1.0.7 to 1.0.10 Bumps [github.com/spf13/pflag](https://github.com/spf13/pflag) from 1.0.7 to 1.0.10. - [Release notes](https://github.com/spf13/pflag/releases) - [Commits](https://github.com/spf13/pflag/compare/v1.0.7...v1.0.10) --- updated-dependencies: - dependency-name: github.com/spf13/pflag dependency-version: 1.0.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index bbd273413..ca6101583 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/rubenv/sql-migrate v1.8.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.1 - github.com/spf13/pflag v1.0.9 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/tetratelabs/wazero v1.9.0 go.yaml.in/yaml/v3 v3.0.4 diff --git a/go.sum b/go.sum index cca32e249..471125d6f 100644 --- a/go.sum +++ b/go.sum @@ -302,8 +302,9 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= From 3e97f216cc19bd26ad970a5298d6802ac5fa2ccb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 21:06:24 +0000 Subject: [PATCH 35/55] chore(deps): bump actions/stale from 9.1.0 to 10.0.0 Bumps [actions/stale](https://github.com/actions/stale) from 9.1.0 to 10.0.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...3a9db7e6a41a89f618792c92c0e97cc736e1b13f) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/stale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 3417e1734..965410793 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -7,7 +7,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: 'This issue has been marked as stale because it has been open for 90 days with no activity. This thread will be automatically closed in 30 days if no further activity occurs.' From a645dfb7f8d1315b500535c1e0fa2b703f097c67 Mon Sep 17 00:00:00 2001 From: Kamil Swiechowski Date: Fri, 5 Sep 2025 13:10:31 +0200 Subject: [PATCH 36/55] fix:semverv2 lint test error message Signed-off-by: Kamil Swiechowski --- pkg/chart/v2/lint/rules/chartfile_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/chart/v2/lint/rules/chartfile_test.go b/pkg/chart/v2/lint/rules/chartfile_test.go index ddaa72510..692358426 100644 --- a/pkg/chart/v2/lint/rules/chartfile_test.go +++ b/pkg/chart/v2/lint/rules/chartfile_test.go @@ -124,7 +124,7 @@ func TestValidateChartVersionStrictSemVerV2(t *testing.T) { badChart.Version = test.Version err := validateChartVersionStrictSemVerV2(badChart) if err == nil || !strings.Contains(err.Error(), test.ErrorMsg) { - t.Errorf("validateChartVersion(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) + t.Errorf("validateChartVersionStrictSemVerV2(%s) to return \"%s\", got no error", test.Version, test.ErrorMsg) } } @@ -132,7 +132,7 @@ func TestValidateChartVersionStrictSemVerV2(t *testing.T) { badChart.Version = version err := validateChartVersionStrictSemVerV2(badChart) if err != nil { - t.Errorf("validateChartVersion(%s) to return no error, got a linter error", version) + t.Errorf("validateChartVersionStrictSemVerV2(%s) to return no error, got a linter error", version) } } } From b060911075950273c8d42c005dd5928d905c0ab0 Mon Sep 17 00:00:00 2001 From: Jesse Simpson Date: Sat, 6 Sep 2025 11:36:39 -0400 Subject: [PATCH 37/55] refactor: break out into functions and draft tests Signed-off-by: Jesse Simpson --- pkg/engine/engine.go | 61 +++++++++++++++++++++++++++++---------- pkg/engine/engine_test.go | 47 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index b42fa3219..a5532f73a 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -360,12 +360,56 @@ func parseTemplateExecErrorString(s string) (TraceableError, bool) { // Special case: "template: no template %q associated with template %q" // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 - if strings.HasPrefix(remainder, "no template ") { - return TraceableError{message: s}, true + traceableError, done := parseTemplateNoTemplateError(s, remainder) + if done { + return traceableError, done } // Executing form: ": executing \"\" at <>: [ template:...]" // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 + traceableError, done = parseTemplateExecutingAtErrorType(remainder) + if done { + return traceableError, done + } + + // Simple form: ": " + // Use LastIndex to avoid splitting colons within line:col info. + // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 + traceableError, done = parseTemplateSimpleErrorString(remainder) + if done { + return traceableError, done + } + + return TraceableError{}, false +} + +// Special case: "template: no template %q associated with template %q" +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 +func parseTemplateNoTemplateError(s string, remainder string) (TraceableError, bool) { + if strings.HasPrefix(remainder, "no template ") { + return TraceableError{message: s}, true + } + return TraceableError{}, false +} + +// Simple form: ": " +// Use LastIndex to avoid splitting colons within line:col info. +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 +func parseTemplateSimpleErrorString(remainder string) (TraceableError, bool) { + if sep := strings.LastIndex(remainder, ": "); sep != -1 { + templateName := remainder[:sep] + errMsg := remainder[sep+2:] + if cut := strings.Index(errMsg, " template:"); cut != -1 { + errMsg = errMsg[:cut] + } + return TraceableError{location: templateName, message: errMsg}, true + } + return TraceableError{}, false +} + +// Executing form: ": executing \"\" at <>: [ template:...]" +// Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 +func parseTemplateExecutingAtErrorType(remainder string) (TraceableError, bool) { if idx := strings.Index(remainder, ": executing "); idx != -1 { templateName := remainder[:idx] after := remainder[idx+len(": executing "):] @@ -404,19 +448,6 @@ func parseTemplateExecErrorString(s string) (TraceableError, bool) { executedFunction: "executing \"" + functionName + "\" at <" + locationName + ">:", }, true } - - // Simple form: ": " - // Use LastIndex to avoid splitting colons within line:col info. - // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 - if sep := strings.LastIndex(remainder, ": "); sep != -1 { - templateName := remainder[:sep] - errMsg := remainder[sep+2:] - if cut := strings.Index(errMsg, " template:"); cut != -1 { - errMsg = errMsg[:cut] - } - return TraceableError{location: templateName, message: errMsg}, true - } - return TraceableError{}, false } diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index f4228fbd7..3362fa37e 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -1428,3 +1428,50 @@ func TestRenderCustomTemplateFuncs(t *testing.T) { t.Errorf("Expected %q, got %q", expected, rendered) } } + +func TestTraceableError_SimpleForm(t *testing.T) { + testStrings := []string{ + "function_not_found/templates/secret.yaml: error calling include", + } + for _, errString := range testStrings { + trace, done := parseTemplateSimpleErrorString(errString) + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.message != "error calling include" { + t.Errorf("Expected %q, got %q", errString, trace.message) + } + } +} +func TestTraceableError_ExecutingForm(t *testing.T) { + testStrings := [][]string{ + {"template: executing \"function_not_found/templates/secret.yaml\" at : ", "function_not_found/templates/secret.yaml"}, + {"template: executing \"name\" at : ", "name"}, + } + for _, errTuple := range testStrings { + errString := errTuple[0] + expectedLocation := errTuple[1] + trace, done := parseTemplateExecutingAtErrorType(errString) + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.location != expectedLocation { + t.Errorf("Expected %q, got %q", expectedLocation, trace.location) + } + } +} + +func TestTraceableError_NoTemplateForm(t *testing.T) { + testStrings := []string{ + "no template \"common.names.get_name\" associated with template \"gotpl\"", + } + for _, errString := range testStrings { + trace, done := parseTemplateNoTemplateError(errString, "") + if !done { + t.Errorf("Expected parse to pass but did not") + } + if trace.message != errString { + t.Errorf("Expected %q, got %q", errString, trace.message) + } + } +} From 712cde46246588beb1fdf2e2119e4faf27a612e4 Mon Sep 17 00:00:00 2001 From: Jesse Simpson Date: Sun, 7 Sep 2025 10:42:24 -0400 Subject: [PATCH 38/55] test: passes now Signed-off-by: Jesse Simpson --- pkg/engine/engine_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 3362fa37e..8dd8f18fe 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -1445,8 +1445,8 @@ func TestTraceableError_SimpleForm(t *testing.T) { } func TestTraceableError_ExecutingForm(t *testing.T) { testStrings := [][]string{ - {"template: executing \"function_not_found/templates/secret.yaml\" at : ", "function_not_found/templates/secret.yaml"}, - {"template: executing \"name\" at : ", "name"}, + {"function_not_found/templates/secret.yaml:6:11: executing \"function_not_found/templates/secret.yaml\" at : ", "function_not_found/templates/secret.yaml:6:11"}, + {"divide_by_zero/templates/secret.yaml:6:11: executing \"divide_by_zero/templates/secret.yaml\" at : ", "divide_by_zero/templates/secret.yaml:6:11"}, } for _, errTuple := range testStrings { errString := errTuple[0] @@ -1466,7 +1466,7 @@ func TestTraceableError_NoTemplateForm(t *testing.T) { "no template \"common.names.get_name\" associated with template \"gotpl\"", } for _, errString := range testStrings { - trace, done := parseTemplateNoTemplateError(errString, "") + trace, done := parseTemplateNoTemplateError(errString, errString) if !done { t.Errorf("Expected parse to pass but did not") } From 1904ef6ad87c43cb0190ec45e3cf1cd03c7bdea8 Mon Sep 17 00:00:00 2001 From: Stephanie Hohenberg Date: Sun, 7 Sep 2025 11:01:16 -0400 Subject: [PATCH 39/55] Adapt test-coverage command to be able to run for a certain package Signed-off-by: Stephanie Hohenberg --- Makefile | 9 +++------ scripts/coverage.sh | 3 ++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 5e1bfc6c2..e3e6cb538 100644 --- a/Makefile +++ b/Makefile @@ -118,11 +118,12 @@ test-unit: go test $(GOFLAGS) -run ^TestHelmCreateChart_CheckDeprecatedWarnings$$ ./internal/chart/v3/lint/ $(TESTFLAGS) -ldflags '$(LDFLAGS)' +# To run the coverage for a specific package use: make test-coverage PKG=./pkg/action .PHONY: test-coverage test-coverage: @echo - @echo "==> Running unit tests with coverage <==" - @ ./scripts/coverage.sh + @echo "==> Running unit tests with coverage: $(PKG) <==" + @ ./scripts/coverage.sh $(PKG) .PHONY: test-style test-style: @@ -148,10 +149,6 @@ test-acceptance: build build-cross test-acceptance-completion: ACCEPTANCE_RUN_TESTS = shells.robot test-acceptance-completion: test-acceptance -.PHONY: coverage -coverage: - @scripts/coverage.sh - .PHONY: format format: $(GOIMPORTS) go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w -local helm.sh/helm diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 2164d94da..487d4eeee 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -19,9 +19,10 @@ set -euo pipefail covermode=${COVERMODE:-atomic} coverdir=$(mktemp -d /tmp/coverage.XXXXXXXXXX) profile="${coverdir}/cover.out" +target="${1:-./...}" # by default the whole repository is tested generate_cover_data() { - for d in $(go list ./...) ; do + for d in $(go list "$target"); do ( local output="${coverdir}/${d//\//-}.cover" go test -coverprofile="${output}" -covermode="$covermode" "$d" From e19d9fb6eec19f09e8fecb548b4674195221f3db Mon Sep 17 00:00:00 2001 From: Stephanie Hohenberg Date: Sun, 7 Sep 2025 10:50:02 -0400 Subject: [PATCH 40/55] Refactor unreachableKubeClient for testing into failingKubeClient Signed-off-by: Stephanie Hohenberg --- pkg/action/get_metadata_test.go | 15 +++------------ pkg/action/get_values_test.go | 7 ++++--- pkg/action/install_test.go | 16 ++++++++++++++++ pkg/action/list_test.go | 16 ++++++++++++++++ pkg/action/uninstall_test.go | 16 ++++++++++++++++ pkg/action/upgrade_test.go | 17 +++++++++++++++++ .../fake/{fake.go => failing_kube_client.go} | 8 ++++++++ 7 files changed, 80 insertions(+), 15 deletions(-) rename pkg/kube/fake/{fake.go => failing_kube_client.go} (96%) diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index 6ceb34951..ca612fed7 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -31,15 +31,6 @@ import ( helmtime "helm.sh/helm/v4/pkg/time" ) -// unreachableKubeClient is a test client that always returns an error for IsReachable -type unreachableKubeClient struct { - kubefake.PrintingKubeClient -} - -func (u *unreachableKubeClient) IsReachable() error { - return errors.New("connection refused") -} - func TestNewGetMetadata(t *testing.T) { cfg := actionConfigFixture(t) client := NewGetMetadata(cfg) @@ -424,9 +415,9 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) { func TestGetMetadata_Run_UnreachableKubeClient(t *testing.T) { cfg := actionConfigFixture(t) - cfg.KubeClient = &unreachableKubeClient{ - PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, - } + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + cfg.KubeClient = &failingKubeClient client := NewGetMetadata(cfg) diff --git a/pkg/action/get_values_test.go b/pkg/action/get_values_test.go index ec785b5c7..b8630c322 100644 --- a/pkg/action/get_values_test.go +++ b/pkg/action/get_values_test.go @@ -17,6 +17,7 @@ limitations under the License. package action import ( + "errors" "io" "testing" @@ -168,9 +169,9 @@ func TestGetValues_Run_EmptyValues(t *testing.T) { func TestGetValues_Run_UnreachableKubeClient(t *testing.T) { cfg := actionConfigFixture(t) - cfg.KubeClient = &unreachableKubeClient{ - PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, - } + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + cfg.KubeClient = &failingKubeClient client := NewGetValues(cfg) diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index 92bb64b4d..4c4890ec9 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -997,3 +997,19 @@ func TestUrlEqual(t *testing.T) { }) } } + +func TestInstallRun_UnreachableKubeClient(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + instAction := NewInstall(config) + instAction.ClientOnly = false + ctx, done := context.WithCancel(t.Context()) + res, err := instAction.RunWithContext(ctx, nil, nil) + + done() + assert.Nil(t, res) + assert.ErrorContains(t, err, "connection refused") +} diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go index b6f89fa1e..75737d635 100644 --- a/pkg/action/list_test.go +++ b/pkg/action/list_test.go @@ -17,10 +17,13 @@ limitations under the License. package action import ( + "errors" + "io" "testing" "github.com/stretchr/testify/assert" + kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" ) @@ -367,3 +370,16 @@ func TestSelectorList(t *testing.T) { assert.ElementsMatch(t, expectedFilteredList, res) }) } + +func TestListRun_UnreachableKubeClient(t *testing.T) { + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + lister := NewList(config) + result, err := lister.Run() + + assert.Nil(t, result) + assert.ErrorContains(t, err, "connection refused") +} diff --git a/pkg/action/uninstall_test.go b/pkg/action/uninstall_test.go index f7c9e5f44..7c7344383 100644 --- a/pkg/action/uninstall_test.go +++ b/pkg/action/uninstall_test.go @@ -17,7 +17,9 @@ limitations under the License. package action import ( + "errors" "fmt" + "io" "testing" "github.com/stretchr/testify/assert" @@ -151,3 +153,17 @@ func TestUninstallRelease_Cascade(t *testing.T) { require.Error(t, err) is.Contains(err.Error(), "failed to delete release: come-fail-away") } + +func TestUninstallRun_UnreachableKubeClient(t *testing.T) { + t.Helper() + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + client := NewUninstall(config) + result, err := client.Run("") + + assert.Nil(t, result) + assert.ErrorContains(t, err, "connection refused") +} diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index 7e27ef594..d31804b87 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -18,7 +18,9 @@ package action import ( "context" + "errors" "fmt" + "io" "reflect" "testing" "time" @@ -690,3 +692,18 @@ func TestGetUpgradeServerSideValue(t *testing.T) { } } + +func TestUpgradeRun_UnreachableKubeClient(t *testing.T) { + t.Helper() + config := actionConfigFixture(t) + failingKubeClient := kubefake.FailingKubeClient{PrintingKubeClient: kubefake.PrintingKubeClient{Out: io.Discard}, DummyResources: nil} + failingKubeClient.ConnectionError = errors.New("connection refused") + config.KubeClient = &failingKubeClient + + client := NewUpgrade(config) + vals := map[string]interface{}{} + result, err := client.Run("", buildChart(), vals) + + assert.Nil(t, result) + assert.ErrorContains(t, err, "connection refused") +} diff --git a/pkg/kube/fake/fake.go b/pkg/kube/fake/failing_kube_client.go similarity index 96% rename from pkg/kube/fake/fake.go rename to pkg/kube/fake/failing_kube_client.go index 588bba83d..154419ebf 100644 --- a/pkg/kube/fake/fake.go +++ b/pkg/kube/fake/failing_kube_client.go @@ -40,6 +40,7 @@ type FailingKubeClient struct { UpdateError error BuildError error BuildTableError error + ConnectionError error BuildDummy bool DummyResources kube.ResourceList BuildUnstructuredError error @@ -166,6 +167,13 @@ func (f *FailingKubeClient) GetWaiter(ws kube.WaitStrategy) (kube.Waiter, error) }, nil } +func (f *FailingKubeClient) IsReachable() error { + if f.ConnectionError != nil { + return f.ConnectionError + } + return f.PrintingKubeClient.IsReachable() +} + func createDummyResourceList() kube.ResourceList { var resInfo resource.Info resInfo.Name = "dummyName" From fae2736d1b56377aa9cde8f30fc1ed13a141f728 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:27:19 +0000 Subject: [PATCH 41/55] chore(deps): bump golang.org/x/term from 0.34.0 to 0.35.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.34.0 to 0.35.0. - [Commits](https://github.com/golang/term/compare/v0.34.0...v0.35.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.35.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index bbd273413..5852cf251 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( 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/term v0.35.0 golang.org/x/text v0.28.0 gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.0 @@ -164,7 +164,7 @@ require ( golang.org/x/net v0.42.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect diff --git a/go.sum b/go.sum index cca32e249..c8bdfc7b8 100644 --- a/go.sum +++ b/go.sum @@ -449,8 +449,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -458,8 +458,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= From 86c19fdc2a8daf3e4fe5a3748a590f9568598560 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 21:29:27 +0000 Subject: [PATCH 42/55] chore(deps): bump sigs.k8s.io/controller-runtime from 0.22.0 to 0.22.1 Bumps [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) from 0.22.0 to 0.22.1. - [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases) - [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md) - [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.22.0...v0.22.1) --- updated-dependencies: - dependency-name: sigs.k8s.io/controller-runtime dependency-version: 0.22.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index bbd273413..a666b19a3 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/kubectl v0.34.0 oras.land/oras-go/v2 v2.6.0 - sigs.k8s.io/controller-runtime v0.22.0 + sigs.k8s.io/controller-runtime v0.22.1 sigs.k8s.io/kustomize/kyaml v0.20.1 sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index cca32e249..25abf860c 100644 --- a/go.sum +++ b/go.sum @@ -531,8 +531,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= -sigs.k8s.io/controller-runtime v0.22.0 h1:mTOfibb8Hxwpx3xEkR56i7xSjB+nH4hZG37SrlCY5e0= -sigs.k8s.io/controller-runtime v0.22.0/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= +sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= +sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= From b539309aa226e92194d6c63533e682524b31b51b Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Tue, 9 Sep 2025 12:52:31 -0600 Subject: [PATCH 43/55] fix: idea gitignore entry Signed-off-by: Terry Howe --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7ea0717ed..0fd2c6bda 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.swp .DS_Store .coverage/ -.idea/ +.idea .vimrc .vscode/ .devcontainer/ From 62cd5d8ba8ece180a75cf8abab3877398efec36c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 21:14:00 +0000 Subject: [PATCH 44/55] chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.42.0 Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.41.0 to 0.42.0. - [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.42.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 735bd66bd..6b11a9902 100644 --- a/go.mod +++ b/go.mod @@ -35,9 +35,9 @@ require ( 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/crypto v0.42.0 golang.org/x/term v0.35.0 - golang.org/x/text v0.28.0 + golang.org/x/text v0.29.0 gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.0 k8s.io/apiextensions-apiserver v0.34.0 @@ -160,13 +160,13 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.16.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/tools v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/grpc v1.72.1 // indirect diff --git a/go.sum b/go.sum index 6bd7906f4..0039f5769 100644 --- a/go.sum +++ b/go.sum @@ -391,16 +391,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -414,8 +414,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -428,8 +428,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -467,8 +467,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -479,8 +479,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 072e2a689ad8ed72258f851e23c5d987f7979ffe Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Wed, 10 Sep 2025 15:19:17 +0200 Subject: [PATCH 45/55] Extend --skip-schema-validation for lint command When --skip-schema-validation is enabled, the lint command will now skip JSON schema validation for values.yaml files, allowing charts with schema validation errors to pass linting when the flag is used. This addresses the gap where --skip-schema-validation only applied to templates but not to values files, providing complete schema validation bypass when needed. Fixes: #13413 Signed-off-by: Suleiman Dibirov Signed-off-by: Benoit Tigeot --- internal/chart/v3/lint/lint.go | 2 +- internal/chart/v3/lint/rules/values.go | 13 +++++++---- internal/chart/v3/lint/rules/values_test.go | 24 ++++++++++++++++----- pkg/chart/v2/lint/lint.go | 2 +- pkg/chart/v2/lint/rules/values.go | 13 +++++++---- pkg/chart/v2/lint/rules/values_test.go | 24 ++++++++++++++++----- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/internal/chart/v3/lint/lint.go b/internal/chart/v3/lint/lint.go index 231bb6803..0cd949065 100644 --- a/internal/chart/v3/lint/lint.go +++ b/internal/chart/v3/lint/lint.go @@ -57,7 +57,7 @@ func RunAll(baseDir string, values map[string]interface{}, namespace string, opt } rules.Chartfile(&result) - rules.ValuesWithOverrides(&result, values) + rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation) rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) rules.Dependencies(&result) rules.Crds(&result) diff --git a/internal/chart/v3/lint/rules/values.go b/internal/chart/v3/lint/rules/values.go index adf2e2c52..0af9765dd 100644 --- a/internal/chart/v3/lint/rules/values.go +++ b/internal/chart/v3/lint/rules/values.go @@ -32,7 +32,7 @@ import ( // they are only tested for well-formedness. // // If additional values are supplied, they are coalesced into the values in values.yaml. -func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) { +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { file := "values.yaml" vf := filepath.Join(linter.ChartDir, file) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) @@ -41,7 +41,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter return } - linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides)) + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) } func validateValuesFileExistence(valuesPath string) error { @@ -52,7 +52,7 @@ func validateValuesFileExistence(valuesPath string) error { return nil } -func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { +func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -75,5 +75,10 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err if err != nil { return err } - return util.ValidateAgainstSingleSchema(coalescedValues, schema) + + if !skipSchemaValidation { + return util.ValidateAgainstSingleSchema(coalescedValues, schema) + } + + return nil } diff --git a/internal/chart/v3/lint/rules/values_test.go b/internal/chart/v3/lint/rules/values_test.go index 348695785..288b77436 100644 --- a/internal/chart/v3/lint/rules/values_test.go +++ b/internal/chart/v3/lint/rules/values_test.go @@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}) + err := validateValuesFile(valfile, map[string]interface{}{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -99,6 +99,20 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { assert.Contains(t, err.Error(), "- at '/username': got number, want string") } +func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) { + // 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, true) + if err != nil { + t.Fatal("expected values file to pass parsing because of skipSchemaValidation") + } +} + func TestValidateValuesFileSchemaOverrides(t *testing.T) { yaml := "username: admin" overrides := map[string]interface{}{ @@ -108,7 +122,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, overrides); err != nil { + if err := validateValuesFile(valfile, overrides, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -145,7 +159,7 @@ func TestValidateValuesFile(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, tt.overrides) + err := validateValuesFile(valfile, tt.overrides, false) switch { case err != nil && tt.errorMessage == "": diff --git a/pkg/chart/v2/lint/lint.go b/pkg/chart/v2/lint/lint.go index 773c9bc5e..b26d65a34 100644 --- a/pkg/chart/v2/lint/lint.go +++ b/pkg/chart/v2/lint/lint.go @@ -57,7 +57,7 @@ func RunAll(baseDir string, values map[string]interface{}, namespace string, opt } rules.Chartfile(&result) - rules.ValuesWithOverrides(&result, values) + rules.ValuesWithOverrides(&result, values, lo.SkipSchemaValidation) rules.TemplatesWithSkipSchemaValidation(&result, values, namespace, lo.KubeVersion, lo.SkipSchemaValidation) rules.Dependencies(&result) rules.Crds(&result) diff --git a/pkg/chart/v2/lint/rules/values.go b/pkg/chart/v2/lint/rules/values.go index 5260bf8b3..994a6a463 100644 --- a/pkg/chart/v2/lint/rules/values.go +++ b/pkg/chart/v2/lint/rules/values.go @@ -32,7 +32,7 @@ import ( // they are only tested for well-formedness. // // If additional values are supplied, they are coalesced into the values in values.yaml. -func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}) { +func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]interface{}, skipSchemaValidation bool) { file := "values.yaml" vf := filepath.Join(linter.ChartDir, file) fileExists := linter.RunLinterRule(support.InfoSev, file, validateValuesFileExistence(vf)) @@ -41,7 +41,7 @@ func ValuesWithOverrides(linter *support.Linter, valueOverrides map[string]inter return } - linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides)) + linter.RunLinterRule(support.ErrorSev, file, validateValuesFile(vf, valueOverrides, skipSchemaValidation)) } func validateValuesFileExistence(valuesPath string) error { @@ -52,7 +52,7 @@ func validateValuesFileExistence(valuesPath string) error { return nil } -func validateValuesFile(valuesPath string, overrides map[string]interface{}) error { +func validateValuesFile(valuesPath string, overrides map[string]interface{}, skipSchemaValidation bool) error { values, err := common.ReadValuesFile(valuesPath) if err != nil { return fmt.Errorf("unable to parse YAML: %w", err) @@ -75,5 +75,10 @@ func validateValuesFile(valuesPath string, overrides map[string]interface{}) err if err != nil { return err } - return util.ValidateAgainstSingleSchema(coalescedValues, schema) + + if !skipSchemaValidation { + return util.ValidateAgainstSingleSchema(coalescedValues, schema) + } + + return nil } diff --git a/pkg/chart/v2/lint/rules/values_test.go b/pkg/chart/v2/lint/rules/values_test.go index 348695785..288b77436 100644 --- a/pkg/chart/v2/lint/rules/values_test.go +++ b/pkg/chart/v2/lint/rules/values_test.go @@ -67,7 +67,7 @@ func TestValidateValuesFileWellFormed(t *testing.T) { ` tmpdir := ensure.TempFile(t, "values.yaml", []byte(badYaml)) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}); err == nil { + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err == nil { t.Fatal("expected values file to fail parsing") } } @@ -78,7 +78,7 @@ func TestValidateValuesFileSchema(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, map[string]interface{}{}); err != nil { + if err := validateValuesFile(valfile, map[string]interface{}{}, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -91,7 +91,7 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, map[string]interface{}{}) + err := validateValuesFile(valfile, map[string]interface{}{}, false) if err == nil { t.Fatal("expected values file to fail parsing") } @@ -99,6 +99,20 @@ func TestValidateValuesFileSchemaFailure(t *testing.T) { assert.Contains(t, err.Error(), "- at '/username': got number, want string") } +func TestValidateValuesFileSchemaFailureButWithSkipSchemaValidation(t *testing.T) { + // 1234 is an int, not a string. This should fail normally but pass with skipSchemaValidation. + yaml := "username: 1234\npassword: swordfish" + tmpdir := ensure.TempFile(t, "values.yaml", []byte(yaml)) + createTestingSchema(t, tmpdir) + + valfile := filepath.Join(tmpdir, "values.yaml") + + err := validateValuesFile(valfile, map[string]interface{}{}, true) + if err != nil { + t.Fatal("expected values file to pass parsing because of skipSchemaValidation") + } +} + func TestValidateValuesFileSchemaOverrides(t *testing.T) { yaml := "username: admin" overrides := map[string]interface{}{ @@ -108,7 +122,7 @@ func TestValidateValuesFileSchemaOverrides(t *testing.T) { createTestingSchema(t, tmpdir) valfile := filepath.Join(tmpdir, "values.yaml") - if err := validateValuesFile(valfile, overrides); err != nil { + if err := validateValuesFile(valfile, overrides, false); err != nil { t.Fatalf("Failed validation with %s", err) } } @@ -145,7 +159,7 @@ func TestValidateValuesFile(t *testing.T) { valfile := filepath.Join(tmpdir, "values.yaml") - err := validateValuesFile(valfile, tt.overrides) + err := validateValuesFile(valfile, tt.overrides, false) switch { case err != nil && tt.errorMessage == "": From 99e5fce71a51a948bcfde040cd9be77e02a431f2 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Wed, 10 Sep 2025 21:26:02 +0200 Subject: [PATCH 46/55] Fix deprecation warning for spf13/pflag from 1.0.7 to 1.0.10 Close: #31231 ``` Error: cmd/helm/root.go:165:2: SA1019: flags.ParseErrorsWhitelist is deprecated: use [FlagSet.ParseErrorsAllowlist] instead. This field will be removed in a future release. (staticcheck) ``` Signed-off-by: Benoit Tigeot --- pkg/cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 4753e51fe..4f1be88d6 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -173,7 +173,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg // those errors will be caught later during the call to cmd.Execution. // This call is required to gather configuration information prior to // execution. - flags.ParseErrorsWhitelist.UnknownFlags = true + flags.ParseErrorsAllowlist.UnknownFlags = true flags.Parse(args) logSetup(settings.Debug) From fcd082e03417d78dec8c105254cd967df8277f48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:12:10 +0000 Subject: [PATCH 47/55] chore(deps): bump the k8s-io group with 7 updates Bumps the k8s-io group with 7 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.34.0` | `0.34.1` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.34.0` | `0.34.1` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.34.0` | `0.34.1` | | [k8s.io/apiserver](https://github.com/kubernetes/apiserver) | `0.34.0` | `0.34.1` | | [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `0.34.0` | `0.34.1` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.34.0` | `0.34.1` | | [k8s.io/kubectl](https://github.com/kubernetes/kubectl) | `0.34.0` | `0.34.1` | Updates `k8s.io/api` from 0.34.0 to 0.34.1 - [Commits](https://github.com/kubernetes/api/compare/v0.34.0...v0.34.1) Updates `k8s.io/apiextensions-apiserver` from 0.34.0 to 0.34.1 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.34.0...v0.34.1) Updates `k8s.io/apimachinery` from 0.34.0 to 0.34.1 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.34.0...v0.34.1) Updates `k8s.io/apiserver` from 0.34.0 to 0.34.1 - [Commits](https://github.com/kubernetes/apiserver/compare/v0.34.0...v0.34.1) Updates `k8s.io/cli-runtime` from 0.34.0 to 0.34.1 - [Commits](https://github.com/kubernetes/cli-runtime/compare/v0.34.0...v0.34.1) Updates `k8s.io/client-go` from 0.34.0 to 0.34.1 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.34.0...v0.34.1) Updates `k8s.io/kubectl` from 0.34.0 to 0.34.1 - [Commits](https://github.com/kubernetes/kubectl/compare/v0.34.0...v0.34.1) --- updated-dependencies: - dependency-name: k8s.io/api dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/apiextensions-apiserver dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/apimachinery dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/apiserver dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/cli-runtime dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/client-go dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io - dependency-name: k8s.io/kubectl dependency-version: 0.34.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: k8s-io ... Signed-off-by: dependabot[bot] --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 5a69e5fd6..77e761de2 100644 --- a/go.mod +++ b/go.mod @@ -39,14 +39,14 @@ require ( golang.org/x/term v0.35.0 golang.org/x/text v0.29.0 gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.0 - k8s.io/apiextensions-apiserver v0.34.0 - k8s.io/apimachinery v0.34.0 - k8s.io/apiserver v0.34.0 - k8s.io/cli-runtime v0.34.0 - k8s.io/client-go v0.34.0 + k8s.io/api v0.34.1 + k8s.io/apiextensions-apiserver v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/apiserver v0.34.1 + k8s.io/cli-runtime v0.34.1 + k8s.io/client-go v0.34.1 k8s.io/klog/v2 v2.130.1 - k8s.io/kubectl v0.34.0 + k8s.io/kubectl v0.34.1 oras.land/oras-go/v2 v2.6.0 sigs.k8s.io/controller-runtime v0.22.1 sigs.k8s.io/kustomize/kyaml v0.20.1 @@ -174,7 +174,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 - k8s.io/component-base v0.34.0 // indirect + k8s.io/component-base v0.34.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/go.sum b/go.sum index f6086fc2e..9fa40e4d4 100644 --- a/go.sum +++ b/go.sum @@ -508,26 +508,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.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= -k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= -k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= -k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= -k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= -k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg= -k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ= -k8s.io/cli-runtime v0.34.0 h1:N2/rUlJg6TMEBgtQ3SDRJwa8XyKUizwjlOknT1mB2Cw= -k8s.io/cli-runtime v0.34.0/go.mod h1:t/skRecS73Piv+J+FmWIQA2N2/rDjdYSQzEE67LUUs8= -k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= -k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= -k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8= -k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= +k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= +k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= +k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/kubectl v0.34.0 h1:NcXz4TPTaUwhiX4LU+6r6udrlm0NsVnSkP3R9t0dmxs= -k8s.io/kubectl v0.34.0/go.mod h1:bmd0W5i+HuG7/p5sqicr0Li0rR2iIhXL0oUyLF3OjR4= +k8s.io/kubectl v0.34.1 h1:1qP1oqT5Xc93K+H8J7ecpBjaz511gan89KO9Vbsh/OI= +k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= From db50c37eda4ccb5d07bbd6733fd27926346f551f Mon Sep 17 00:00:00 2001 From: bennsimon Date: Fri, 12 Sep 2025 12:44:18 +0300 Subject: [PATCH 48/55] remove metadata output on helm template Signed-off-by: bennsimon --- pkg/engine/engine.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index a0ca17f08..7c858690f 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -473,7 +473,6 @@ func recAllTpls(c ci.Charter, templates map[string]renderable, values common.Val slog.Error("error accessing chart", "error", err) } chartMetaData := accessor.MetadataAsMap() - fmt.Printf("metadata: %v\n", chartMetaData) chartMetaData["IsRoot"] = accessor.IsRoot() next := map[string]interface{}{ From cfaf30083af5b32ae4611c7e58d8dbc2171e9331 Mon Sep 17 00:00:00 2001 From: yajianggroup Date: Fri, 12 Sep 2025 19:03:54 +0800 Subject: [PATCH 49/55] refactor: use strings.CutPrefix Signed-off-by: yajianggroup --- internal/plugin/installer/extractor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plugin/installer/extractor.go b/internal/plugin/installer/extractor.go index 9417a0535..407138197 100644 --- a/internal/plugin/installer/extractor.go +++ b/internal/plugin/installer/extractor.go @@ -185,8 +185,8 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error { func stripPluginName(name string) string { var strippedName string for suffix := range Extractors { - if strings.HasSuffix(name, suffix) { - strippedName = strings.TrimSuffix(name, suffix) + if before, ok := strings.CutSuffix(name, suffix); ok { + strippedName = before break } } From 934b4550d9f238b2b9537ea0ea14207d1d1f6ade Mon Sep 17 00:00:00 2001 From: Mile Druzijanic Date: Fri, 12 Sep 2025 20:42:37 +0200 Subject: [PATCH 50/55] improve fileutil test coverage Signed-off-by: Mile Druzijanic --- internal/fileutil/fileutil_test.go | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/fileutil/fileutil_test.go b/internal/fileutil/fileutil_test.go index 92920d3c4..881fbb49d 100644 --- a/internal/fileutil/fileutil_test.go +++ b/internal/fileutil/fileutil_test.go @@ -20,9 +20,12 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" ) +// TestAtomicWriteFile tests the happy path of AtomicWriteFile function. +// It verifies that the function correctly writes content to a file with the specified mode. func TestAtomicWriteFile(t *testing.T) { dir := t.TempDir() @@ -55,3 +58,64 @@ func TestAtomicWriteFile(t *testing.T) { mode, gotinfo.Mode()) } } + +// TestAtomicWriteFile_CreateTempError tests the error path when os.CreateTemp fails +func TestAtomicWriteFile_CreateTempError(t *testing.T) { + invalidPath := "/invalid/path/that/does/not/exist/testfile" + + reader := bytes.NewReader([]byte("test content")) + mode := os.FileMode(0644) + + err := AtomicWriteFile(invalidPath, reader, mode) + if err == nil { + t.Error("Expected error when CreateTemp fails, but got nil") + } +} + +// TestAtomicWriteFile_EmptyContent tests with empty content +func TestAtomicWriteFile_EmptyContent(t *testing.T) { + dir := t.TempDir() + testpath := filepath.Join(dir, "empty_helm") + + reader := bytes.NewReader([]byte("")) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error with empty content: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if len(got) != 0 { + t.Fatalf("expected empty content, got: %s", string(got)) + } +} + +// TestAtomicWriteFile_LargeContent tests with large content +func TestAtomicWriteFile_LargeContent(t *testing.T) { + dir := t.TempDir() + testpath := filepath.Join(dir, "large_test") + + // Create a large content string + largeContent := strings.Repeat("HELM", 1024*1024) + reader := bytes.NewReader([]byte(largeContent)) + mode := os.FileMode(0644) + + err := AtomicWriteFile(testpath, reader, mode) + if err != nil { + t.Errorf("AtomicWriteFile error with large content: %s", err) + } + + got, err := os.ReadFile(testpath) + if err != nil { + t.Fatal(err) + } + + if largeContent != string(got) { + t.Fatalf("expected large content to match, got different length: %d vs %d", len(largeContent), len(got)) + } +} From 9112687a7e0f6fc85f5736dda48c263c30496795 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Fri, 12 Sep 2025 14:12:23 -0600 Subject: [PATCH 51/55] Update pkg/engine/engine.go Co-authored-by: George Jenkins Signed-off-by: Terry Howe --- pkg/engine/engine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index a5532f73a..cce3c10c7 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -362,7 +362,7 @@ func parseTemplateExecErrorString(s string) (TraceableError, bool) { // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=191 traceableError, done := parseTemplateNoTemplateError(s, remainder) if done { - return traceableError, done + return traceableError, true } // Executing form: ": executing \"\" at <>: [ template:...]" From 91a65234ac52e1b693cf469a85faa78af79c2163 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Fri, 12 Sep 2025 14:26:53 -0600 Subject: [PATCH 52/55] Update pkg/engine/engine.go Co-authored-by: George Jenkins Signed-off-by: Terry Howe --- pkg/engine/engine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index cce3c10c7..8272889d3 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -369,7 +369,7 @@ func parseTemplateExecErrorString(s string) (TraceableError, bool) { // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=141 traceableError, done = parseTemplateExecutingAtErrorType(remainder) if done { - return traceableError, done + return traceableError, true } // Simple form: ": " From b2870379c88f7bf9a51fe0aa3c1c6442a91cda68 Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Fri, 12 Sep 2025 14:27:00 -0600 Subject: [PATCH 53/55] Update pkg/engine/engine.go Co-authored-by: George Jenkins Signed-off-by: Terry Howe --- pkg/engine/engine.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go index 8272889d3..9cbff272a 100644 --- a/pkg/engine/engine.go +++ b/pkg/engine/engine.go @@ -377,7 +377,7 @@ func parseTemplateExecErrorString(s string) (TraceableError, bool) { // Matches https://cs.opensource.google/go/go/+/refs/tags/go1.23.6:src/text/template/exec.go;l=138 traceableError, done = parseTemplateSimpleErrorString(remainder) if done { - return traceableError, done + return traceableError, true } return TraceableError{}, false From 1c67fbf108ec9849773897ae4ca1588dca5bdf1f Mon Sep 17 00:00:00 2001 From: reddaisyy Date: Mon, 15 Sep 2025 17:13:58 +0800 Subject: [PATCH 54/55] refactor: use strings.builder Signed-off-by: reddaisyy --- pkg/action/hooks_test.go | 9 +++++---- pkg/engine/engine_test.go | 6 +++--- pkg/strvals/literal_parser_test.go | 7 ++++--- pkg/strvals/parser_test.go | 7 ++++--- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/action/hooks_test.go b/pkg/action/hooks_test.go index 091155bc2..fb7d1b4ec 100644 --- a/pkg/action/hooks_test.go +++ b/pkg/action/hooks_test.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "reflect" + "strings" "testing" "time" @@ -112,15 +113,15 @@ spec: } func convertHooksToCommaSeparated(hookDefinitions []release.HookOutputLogPolicy) string { - var commaSeparated string + var commaSeparated strings.Builder for i, policy := range hookDefinitions { if i+1 == len(hookDefinitions) { - commaSeparated += policy.String() + commaSeparated.WriteString(policy.String()) } else { - commaSeparated += policy.String() + "," + commaSeparated.WriteString(policy.String() + ",") } } - return commaSeparated + return commaSeparated.String() } func TestInstallRelease_HookOutputLogsOnFailure(t *testing.T) { diff --git a/pkg/engine/engine_test.go b/pkg/engine/engine_test.go index 7ac892cec..364e96183 100644 --- a/pkg/engine/engine_test.go +++ b/pkg/engine/engine_test.go @@ -1024,15 +1024,15 @@ func TestRenderRecursionLimit(t *testing.T) { times := 4000 phrase := "All work and no play makes Jack a dull boy" printFunc := `{{define "overlook"}}{{printf "` + phrase + `\n"}}{{end}}` - var repeatedIncl string + var repeatedIncl strings.Builder for i := 0; i < times; i++ { - repeatedIncl += `{{include "overlook" . }}` + repeatedIncl.WriteString(`{{include "overlook" . }}`) } d := &chart.Chart{ Metadata: &chart.Metadata{Name: "overlook"}, Templates: []*common.File{ - {Name: "templates/quote", Data: []byte(repeatedIncl)}, + {Name: "templates/quote", Data: []byte(repeatedIncl.String())}, {Name: "templates/_function", Data: []byte(printFunc)}, }, } diff --git a/pkg/strvals/literal_parser_test.go b/pkg/strvals/literal_parser_test.go index 4e74423d6..6a76458f5 100644 --- a/pkg/strvals/literal_parser_test.go +++ b/pkg/strvals/literal_parser_test.go @@ -17,6 +17,7 @@ package strvals import ( "fmt" + "strings" "testing" "sigs.k8s.io/yaml" @@ -416,14 +417,14 @@ func TestParseLiteralInto(t *testing.T) { } func TestParseLiteralNestedLevels(t *testing.T) { - var keyMultipleNestedLevels string + var keyMultipleNestedLevels strings.Builder for i := 1; i <= MaxNestedNameLevel+2; i++ { tmpStr := fmt.Sprintf("name%d", i) if i <= MaxNestedNameLevel+1 { tmpStr = tmpStr + "." } - keyMultipleNestedLevels += tmpStr + keyMultipleNestedLevels.WriteString(tmpStr) } tests := []struct { @@ -439,7 +440,7 @@ func TestParseLiteralNestedLevels(t *testing.T) { "", }, { - str: keyMultipleNestedLevels + "=value", + str: keyMultipleNestedLevels.String() + "=value", err: true, errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel), }, diff --git a/pkg/strvals/parser_test.go b/pkg/strvals/parser_test.go index a0c67b791..73403fc52 100644 --- a/pkg/strvals/parser_test.go +++ b/pkg/strvals/parser_test.go @@ -17,6 +17,7 @@ package strvals import ( "fmt" + "strings" "testing" "sigs.k8s.io/yaml" @@ -757,13 +758,13 @@ func TestToYAML(t *testing.T) { } func TestParseSetNestedLevels(t *testing.T) { - var keyMultipleNestedLevels string + var keyMultipleNestedLevels strings.Builder for i := 1; i <= MaxNestedNameLevel+2; i++ { tmpStr := fmt.Sprintf("name%d", i) if i <= MaxNestedNameLevel+1 { tmpStr = tmpStr + "." } - keyMultipleNestedLevels += tmpStr + keyMultipleNestedLevels.WriteString(tmpStr) } tests := []struct { str string @@ -778,7 +779,7 @@ func TestParseSetNestedLevels(t *testing.T) { "", }, { - str: keyMultipleNestedLevels + "=value", + str: keyMultipleNestedLevels.String() + "=value", err: true, errStr: fmt.Sprintf("value name nested level is greater than maximum supported nested level of %d", MaxNestedNameLevel), From 3e1dd9a5dcaa8e75edd7dbab6803d753068b852c Mon Sep 17 00:00:00 2001 From: Terry Howe Date: Mon, 15 Sep 2025 12:28:09 -0600 Subject: [PATCH 55/55] chore: remove pkg/time which is no longer needed Signed-off-by: Terry Howe --- pkg/action/action.go | 2 +- pkg/action/action_test.go | 2 +- pkg/action/get_metadata_test.go | 19 ++- pkg/action/hooks.go | 7 +- pkg/action/install_test.go | 13 +- pkg/action/rollback.go | 3 +- pkg/action/uninstall.go | 3 +- pkg/action/upgrade_test.go | 3 +- pkg/cmd/flags_test.go | 4 +- pkg/cmd/helpers_test.go | 2 +- pkg/cmd/history.go | 13 +- pkg/cmd/list_test.go | 2 +- pkg/cmd/status_test.go | 7 +- .../output/status-with-resources.json | 2 +- pkg/cmd/testdata/output/status.json | 2 +- pkg/pusher/ocipusher.go | 3 +- pkg/registry/util.go | 3 +- pkg/registry/util_test.go | 11 +- pkg/release/v1/hook.go | 6 +- pkg/release/v1/info.go | 10 +- pkg/release/v1/mock.go | 2 +- pkg/release/v1/util/sorter_test.go | 3 +- pkg/time/ctime/ctime.go | 29 ---- pkg/time/ctime/ctime_linux.go | 30 ---- pkg/time/ctime/ctime_other.go | 27 ---- pkg/time/time.go | 92 ----------- pkg/time/time_test.go | 153 ------------------ 27 files changed, 55 insertions(+), 398 deletions(-) delete mode 100644 pkg/time/ctime/ctime.go delete mode 100644 pkg/time/ctime/ctime_linux.go delete mode 100644 pkg/time/ctime/ctime_other.go delete mode 100644 pkg/time/time.go delete mode 100644 pkg/time/time_test.go diff --git a/pkg/action/action.go b/pkg/action/action.go index bcf6ca8ef..1aa9f9d19 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -30,6 +30,7 @@ import ( "strings" "sync" "text/template" + "time" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/cli-runtime/pkg/genericclioptions" @@ -50,7 +51,6 @@ import ( releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" - "helm.sh/helm/v4/pkg/time" ) // Timestamper is a function capable of producing a timestamp.Timestamper. diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index b65e40024..78ca01089 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -24,6 +24,7 @@ import ( "log/slog" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +39,6 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" - "helm.sh/helm/v4/pkg/time" ) var verbose = flag.Bool("test.log", false, "enable test logging (debug by default)") diff --git a/pkg/action/get_metadata_test.go b/pkg/action/get_metadata_test.go index ca612fed7..7962a2133 100644 --- a/pkg/action/get_metadata_test.go +++ b/pkg/action/get_metadata_test.go @@ -28,7 +28,6 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) func TestNewGetMetadata(t *testing.T) { @@ -45,7 +44,7 @@ func TestGetMetadata_Run_BasicMetadata(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() rel := &release.Release{ Name: releaseName, @@ -86,7 +85,7 @@ func TestGetMetadata_Run_WithDependencies(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() dependencies := []*chart.Dependency{ { @@ -138,7 +137,7 @@ func TestGetMetadata_Run_WithDependenciesAliases(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() dependencies := []*chart.Dependency{ { @@ -194,7 +193,7 @@ func TestGetMetadata_Run_WithMixedDependencies(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() dependencies := []*chart.Dependency{ { @@ -268,7 +267,7 @@ func TestGetMetadata_Run_WithAnnotations(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() annotations := map[string]string{ "helm.sh/hook": "pre-install", @@ -313,13 +312,13 @@ func TestGetMetadata_Run_SpecificVersion(t *testing.T) { client.Version = 2 releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() rel1 := &release.Release{ Name: releaseName, Info: &release.Info{ Status: release.StatusSuperseded, - LastDeployed: helmtime.Time{Time: deployedTime.Time.Add(-time.Hour)}, + LastDeployed: deployedTime.Add(-time.Hour), }, Chart: &chart.Chart{ Metadata: &chart.Metadata{ @@ -384,7 +383,7 @@ func TestGetMetadata_Run_DifferentStatuses(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { releaseName := "test-release-" + tc.name - deployedTime := helmtime.Now() + deployedTime := time.Now() rel := &release.Release{ Name: releaseName, @@ -440,7 +439,7 @@ func TestGetMetadata_Run_EmptyAppVersion(t *testing.T) { client := NewGetMetadata(cfg) releaseName := "test-release" - deployedTime := helmtime.Now() + deployedTime := time.Now() rel := &release.Release{ Name: releaseName, diff --git a/pkg/action/hooks.go b/pkg/action/hooks.go index 458a6342c..4808bc054 100644 --- a/pkg/action/hooks.go +++ b/pkg/action/hooks.go @@ -29,7 +29,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) // execHook executes all of the hooks for the given hook event. @@ -62,7 +61,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, // Record the time at which the hook was applied to the cluster h.LastRun = release.HookExecution{ - StartedAt: helmtime.Now(), + StartedAt: time.Now(), Phase: release.HookPhaseRunning, } cfg.recordRelease(rl) @@ -76,7 +75,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, if _, err := cfg.KubeClient.Create( resources, kube.ClientCreateOptionServerSideApply(serverSideApply, false)); err != nil { - h.LastRun.CompletedAt = helmtime.Now() + h.LastRun.CompletedAt = time.Now() h.LastRun.Phase = release.HookPhaseFailed return fmt.Errorf("warning: Hook %s %s failed: %w", hook, h.Path, err) } @@ -88,7 +87,7 @@ func (cfg *Configuration) execHook(rl *release.Release, hook release.HookEvent, // Watch hook resources until they have completed err = waiter.WatchUntilReady(resources, timeout) // Note the time of success/failure - h.LastRun.CompletedAt = helmtime.Now() + h.LastRun.CompletedAt = time.Now() // Mark hook as succeeded or failed if err != nil { h.LastRun.Phase = release.HookPhaseFailed diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go index b2b1508be..9031372d7 100644 --- a/pkg/action/install_test.go +++ b/pkg/action/install_test.go @@ -49,7 +49,6 @@ import ( kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage/driver" - helmtime "helm.sh/helm/v4/pkg/time" ) type nameTemplateTestCase struct { @@ -856,32 +855,32 @@ func TestNameAndChartGenerateName(t *testing.T) { { "local filepath", "./chart", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, { "dot filepath", ".", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, { "empty filepath", "", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, { "packaged chart", "chart.tgz", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, { "packaged chart with .tar.gz extension", "chart.tar.gz", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, { "packaged chart with local extension", "./chart.tgz", - fmt.Sprintf("chart-%d", helmtime.Now().Unix()), + fmt.Sprintf("chart-%d", time.Now().Unix()), }, } diff --git a/pkg/action/rollback.go b/pkg/action/rollback.go index adaf22615..f56052988 100644 --- a/pkg/action/rollback.go +++ b/pkg/action/rollback.go @@ -26,7 +26,6 @@ import ( chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/kube" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) // Rollback is the action for rolling back to a given release. @@ -158,7 +157,7 @@ func (r *Rollback) prepareRollback(name string) (*release.Release, *release.Rele Config: previousRelease.Config, Info: &release.Info{ FirstDeployed: currentRelease.Info.FirstDeployed, - LastDeployed: helmtime.Now(), + LastDeployed: time.Now(), Status: release.StatusPendingRollback, Notes: previousRelease.Info.Notes, // Because we lose the reference to previous version elsewhere, we set the diff --git a/pkg/action/uninstall.go b/pkg/action/uninstall.go index 866be5d54..057c2118f 100644 --- a/pkg/action/uninstall.go +++ b/pkg/action/uninstall.go @@ -30,7 +30,6 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" "helm.sh/helm/v4/pkg/storage/driver" - helmtime "helm.sh/helm/v4/pkg/time" ) // Uninstall is the action for uninstalling releases. @@ -110,7 +109,7 @@ func (u *Uninstall) Run(name string) (*release.UninstallReleaseResponse, error) slog.Debug("uninstall: deleting release", "name", name) rel.Info.Status = release.StatusUninstalling - rel.Info.Deleted = helmtime.Now() + rel.Info.Deleted = time.Now() rel.Info.Description = "Deletion in progress (or silently failed)" res := &release.UninstallReleaseResponse{Release: rel} diff --git a/pkg/action/upgrade_test.go b/pkg/action/upgrade_test.go index d31804b87..0a436534f 100644 --- a/pkg/action/upgrade_test.go +++ b/pkg/action/upgrade_test.go @@ -34,7 +34,6 @@ import ( kubefake "helm.sh/helm/v4/pkg/kube/fake" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) func upgradeAction(t *testing.T) *Upgrade { @@ -260,7 +259,7 @@ func TestUpgradeRelease_ReuseValues(t *testing.T) { withValues(chartDefaultValues), withMetadataDependency(dependency), ) - now := helmtime.Now() + now := time.Now() existingValues := map[string]interface{}{ "subchart": map[string]interface{}{ "enabled": false, diff --git a/pkg/cmd/flags_test.go b/pkg/cmd/flags_test.go index dce748a6b..8d79716f0 100644 --- a/pkg/cmd/flags_test.go +++ b/pkg/cmd/flags_test.go @@ -19,19 +19,19 @@ package cmd import ( "fmt" "testing" + "time" "github.com/stretchr/testify/require" "helm.sh/helm/v4/pkg/action" chart "helm.sh/helm/v4/pkg/chart/v2" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) func outputFlagCompletionTest(t *testing.T, cmdName string) { t.Helper() releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { - info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + info.LastDeployed = time.Unix(1452902400, 0).UTC() return []*release.Release{{ Name: "athos", Namespace: "default", diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go index 55e3a842f..96bf6434b 100644 --- a/pkg/cmd/helpers_test.go +++ b/pkg/cmd/helpers_test.go @@ -22,6 +22,7 @@ import ( "os" "strings" "testing" + "time" shellwords "github.com/mattn/go-shellwords" "github.com/spf13/cobra" @@ -34,7 +35,6 @@ import ( release "helm.sh/helm/v4/pkg/release/v1" "helm.sh/helm/v4/pkg/storage" "helm.sh/helm/v4/pkg/storage/driver" - "helm.sh/helm/v4/pkg/time" ) func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() } diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index 9f029268c..f4dde95e4 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -31,7 +31,6 @@ import ( "helm.sh/helm/v4/pkg/cmd/require" release "helm.sh/helm/v4/pkg/release/v1" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" - helmtime "helm.sh/helm/v4/pkg/time" ) var historyHelp = ` @@ -84,12 +83,12 @@ func newHistoryCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { } type releaseInfo struct { - Revision int `json:"revision"` - Updated helmtime.Time `json:"updated"` - Status string `json:"status"` - Chart string `json:"chart"` - AppVersion string `json:"app_version"` - Description string `json:"description"` + Revision int `json:"revision"` + Updated time.Time `json:"updated,omitzero"` + Status string `json:"status"` + Chart string `json:"chart"` + AppVersion string `json:"app_version"` + Description string `json:"description"` } type releaseHistory []releaseInfo diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go index 82b25a768..22a948fff 100644 --- a/pkg/cmd/list_test.go +++ b/pkg/cmd/list_test.go @@ -18,10 +18,10 @@ package cmd import ( "testing" + "time" chart "helm.sh/helm/v4/pkg/chart/v2" release "helm.sh/helm/v4/pkg/release/v1" - "helm.sh/helm/v4/pkg/time" ) func TestListCmd(t *testing.T) { diff --git a/pkg/cmd/status_test.go b/pkg/cmd/status_test.go index cb4e23c59..8c251b76b 100644 --- a/pkg/cmd/status_test.go +++ b/pkg/cmd/status_test.go @@ -22,12 +22,11 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" release "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) func TestStatusCmd(t *testing.T) { releasesMockWithStatus := func(info *release.Info, hooks ...*release.Hook) []*release.Release { - info.LastDeployed = helmtime.Unix(1452902400, 0).UTC() + info.LastDeployed = time.Unix(1452902400, 0).UTC() return []*release.Release{{ Name: "flummoxed-chickadee", Namespace: "default", @@ -130,8 +129,8 @@ func TestStatusCmd(t *testing.T) { runTestCmd(t, tests) } -func mustParseTime(t string) helmtime.Time { - res, _ := helmtime.Parse(time.RFC3339, t) +func mustParseTime(t string) time.Time { + res, _ := time.Parse(time.RFC3339, t) return res } diff --git a/pkg/cmd/testdata/output/status-with-resources.json b/pkg/cmd/testdata/output/status-with-resources.json index 275e0cfc6..af512bfd1 100644 --- a/pkg/cmd/testdata/output/status-with-resources.json +++ b/pkg/cmd/testdata/output/status-with-resources.json @@ -1 +1 @@ -{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed"},"namespace":"default"} +{"name":"flummoxed-chickadee","info":{"last_deployed":"2016-01-16T00:00:00Z","status":"deployed"},"namespace":"default"} diff --git a/pkg/cmd/testdata/output/status.json b/pkg/cmd/testdata/output/status.json index 4b499c935..4727dd100 100644 --- a/pkg/cmd/testdata/output/status.json +++ b/pkg/cmd/testdata/output/status.json @@ -1 +1 @@ -{"name":"flummoxed-chickadee","info":{"first_deployed":"","last_deployed":"2016-01-16T00:00:00Z","deleted":"","status":"deployed","notes":"release notes"},"namespace":"default"} +{"name":"flummoxed-chickadee","info":{"last_deployed":"2016-01-16T00:00:00Z","status":"deployed","notes":"release notes"},"namespace":"default"} diff --git a/pkg/pusher/ocipusher.go b/pkg/pusher/ocipusher.go index 699d27caf..25682969b 100644 --- a/pkg/pusher/ocipusher.go +++ b/pkg/pusher/ocipusher.go @@ -29,7 +29,6 @@ import ( "helm.sh/helm/v4/internal/tlsutil" "helm.sh/helm/v4/pkg/chart/v2/loader" "helm.sh/helm/v4/pkg/registry" - "helm.sh/helm/v4/pkg/time/ctime" ) // OCIPusher is the default OCI backend handler @@ -91,7 +90,7 @@ func (pusher *OCIPusher) push(chartRef, href string) error { meta.Metadata.Version) // The time the chart was "created" is semantically the time the chart archive file was last written(modified) - chartArchiveFileCreatedTime := ctime.Modified(stat) + chartArchiveFileCreatedTime := stat.ModTime() pushOpts = append(pushOpts, registry.PushOptCreationTime(chartArchiveFileCreatedTime.Format(time.RFC3339))) _, err = client.Push(chartBytes, ref, pushOpts...) diff --git a/pkg/registry/util.go b/pkg/registry/util.go index b31ab63fe..6071c66c3 100644 --- a/pkg/registry/util.go +++ b/pkg/registry/util.go @@ -28,7 +28,6 @@ import ( "helm.sh/helm/v4/internal/tlsutil" chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" - helmtime "helm.sh/helm/v4/pkg/time" "github.com/Masterminds/semver/v3" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -157,7 +156,7 @@ func generateChartOCIAnnotations(meta *chart.Metadata, creationTime string) map[ chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationURL, meta.Home) if len(creationTime) == 0 { - creationTime = helmtime.Now().UTC().Format(time.RFC3339) + creationTime = time.Now().UTC().Format(time.RFC3339) } chartOCIAnnotations = addToMap(chartOCIAnnotations, ocispec.AnnotationCreated, creationTime) diff --git a/pkg/registry/util_test.go b/pkg/registry/util_test.go index c8ce4e4a4..a67bc853a 100644 --- a/pkg/registry/util_test.go +++ b/pkg/registry/util_test.go @@ -24,12 +24,11 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" chart "helm.sh/helm/v4/pkg/chart/v2" - helmtime "helm.sh/helm/v4/pkg/time" ) func TestGenerateOCIChartAnnotations(t *testing.T) { - nowString := helmtime.Now().Format(time.RFC3339) + nowString := time.Now().Format(time.RFC3339) tests := []struct { name string @@ -160,7 +159,7 @@ func TestGenerateOCIChartAnnotations(t *testing.T) { func TestGenerateOCIAnnotations(t *testing.T) { - nowString := helmtime.Now().Format(time.RFC3339) + nowString := time.Now().Format(time.RFC3339) tests := []struct { name string @@ -234,7 +233,7 @@ func TestGenerateOCIAnnotations(t *testing.T) { func TestGenerateOCICreatedAnnotations(t *testing.T) { - nowTime := helmtime.Now() + nowTime := time.Now() nowTimeString := nowTime.Format(time.RFC3339) chart := &chart.Metadata{ @@ -250,7 +249,7 @@ func TestGenerateOCICreatedAnnotations(t *testing.T) { } // Verify value of created artifact in RFC3339 format - if _, err := helmtime.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { + if _, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) } @@ -262,7 +261,7 @@ func TestGenerateOCICreatedAnnotations(t *testing.T) { t.Errorf("%s annotation not created", ocispec.AnnotationCreated) } - if createdTimeAnnotation, err := helmtime.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { + if createdTimeAnnotation, err := time.Parse(time.RFC3339, result[ocispec.AnnotationCreated]); err != nil { t.Errorf("%s annotation with value '%s' not in RFC3339 format", ocispec.AnnotationCreated, result[ocispec.AnnotationCreated]) // Verify creation annotation after time test began diff --git a/pkg/release/v1/hook.go b/pkg/release/v1/hook.go index 1ef5c1eb8..b7d3c3992 100644 --- a/pkg/release/v1/hook.go +++ b/pkg/release/v1/hook.go @@ -17,7 +17,7 @@ limitations under the License. package v1 import ( - "helm.sh/helm/v4/pkg/time" + "time" ) // HookEvent specifies the hook event @@ -97,9 +97,9 @@ type Hook struct { // A HookExecution records the result for the last execution of a hook for a given release. type HookExecution struct { // StartedAt indicates the date/time this hook was started - StartedAt time.Time `json:"started_at,omitempty"` + StartedAt time.Time `json:"started_at,omitzero"` // CompletedAt indicates the date/time this hook was completed. - CompletedAt time.Time `json:"completed_at,omitempty"` + CompletedAt time.Time `json:"completed_at,omitzero"` // Phase indicates whether the hook completed successfully Phase HookPhase `json:"phase"` } diff --git a/pkg/release/v1/info.go b/pkg/release/v1/info.go index ff98ab63e..cef7e9960 100644 --- a/pkg/release/v1/info.go +++ b/pkg/release/v1/info.go @@ -16,19 +16,19 @@ limitations under the License. package v1 import ( - "k8s.io/apimachinery/pkg/runtime" + "time" - "helm.sh/helm/v4/pkg/time" + "k8s.io/apimachinery/pkg/runtime" ) // Info describes release information. type Info struct { // FirstDeployed is when the release was first deployed. - FirstDeployed time.Time `json:"first_deployed,omitempty"` + FirstDeployed time.Time `json:"first_deployed,omitzero"` // LastDeployed is when the release was last deployed. - LastDeployed time.Time `json:"last_deployed,omitempty"` + LastDeployed time.Time `json:"last_deployed,omitzero"` // Deleted tracks when this object was deleted. - Deleted time.Time `json:"deleted"` + Deleted time.Time `json:"deleted,omitzero"` // Description is human-friendly "log entry" about this release. Description string `json:"description,omitempty"` // Status is the current state of the release diff --git a/pkg/release/v1/mock.go b/pkg/release/v1/mock.go index c3a6594cc..818cd777e 100644 --- a/pkg/release/v1/mock.go +++ b/pkg/release/v1/mock.go @@ -19,10 +19,10 @@ package v1 import ( "fmt" "math/rand" + "time" "helm.sh/helm/v4/pkg/chart/common" chart "helm.sh/helm/v4/pkg/chart/v2" - "helm.sh/helm/v4/pkg/time" ) // MockHookTemplate is the hook template used for all mock release objects. diff --git a/pkg/release/v1/util/sorter_test.go b/pkg/release/v1/util/sorter_test.go index 4628a5192..0889ddb94 100644 --- a/pkg/release/v1/util/sorter_test.go +++ b/pkg/release/v1/util/sorter_test.go @@ -21,7 +21,6 @@ import ( "time" rspb "helm.sh/helm/v4/pkg/release/v1" - helmtime "helm.sh/helm/v4/pkg/time" ) // note: this test data is shared with filter_test.go. @@ -34,7 +33,7 @@ var releases = []*rspb.Release{ } func tsRelease(name string, vers int, dur time.Duration, status rspb.Status) *rspb.Release { - info := &rspb.Info{Status: status, LastDeployed: helmtime.Now().Add(dur)} + info := &rspb.Info{Status: status, LastDeployed: time.Now().Add(dur)} return &rspb.Release{ Name: name, Version: vers, diff --git a/pkg/time/ctime/ctime.go b/pkg/time/ctime/ctime.go deleted file mode 100644 index 63a41c0bf..000000000 --- a/pkg/time/ctime/ctime.go +++ /dev/null @@ -1,29 +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 ctime - -import ( - "os" - "time" -) - -func Created(fi os.FileInfo) time.Time { - return modified(fi) -} - -func Modified(fi os.FileInfo) time.Time { - return modified(fi) -} diff --git a/pkg/time/ctime/ctime_linux.go b/pkg/time/ctime/ctime_linux.go deleted file mode 100644 index d8a6ea1a1..000000000 --- a/pkg/time/ctime/ctime_linux.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build linux - -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -package ctime - -import ( - "os" - "syscall" - "time" -) - -func modified(fi os.FileInfo) time.Time { - st := fi.Sys().(*syscall.Stat_t) - //nolint - return time.Unix(int64(st.Mtim.Sec), int64(st.Mtim.Nsec)) -} diff --git a/pkg/time/ctime/ctime_other.go b/pkg/time/ctime/ctime_other.go deleted file mode 100644 index 12afc6df2..000000000 --- a/pkg/time/ctime/ctime_other.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !linux - -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -package ctime - -import ( - "os" - "time" -) - -func modified(fi os.FileInfo) time.Time { - return fi.ModTime() -} diff --git a/pkg/time/time.go b/pkg/time/time.go deleted file mode 100644 index 16973b455..000000000 --- a/pkg/time/time.go +++ /dev/null @@ -1,92 +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 time contains a wrapper for time.Time in the standard library and -// associated methods. This package mainly exists to work around an issue in Go -// where the serializer doesn't omit an empty value for time: -// https://github.com/golang/go/issues/11939. As such, this can be removed if a -// proposal is ever accepted for Go -package time - -import ( - "bytes" - "time" -) - -// emptyString contains an empty JSON string value to be used as output -var emptyString = `""` - -// Time is a convenience wrapper around stdlib time, but with different -// marshalling and unmarshalling for zero values -type Time struct { - time.Time -} - -// Now returns the current time. It is a convenience wrapper around time.Now() -func Now() Time { - return Time{time.Now()} -} - -func (t Time) MarshalJSON() ([]byte, error) { - if t.IsZero() { - return []byte(emptyString), nil - } - - return t.Time.MarshalJSON() -} - -func (t *Time) UnmarshalJSON(b []byte) error { - if bytes.Equal(b, []byte("null")) { - return nil - } - // If it is empty, we don't have to set anything since time.Time is not a - // pointer and will be set to the zero value - if bytes.Equal([]byte(emptyString), b) { - return nil - } - - return t.Time.UnmarshalJSON(b) -} - -func Parse(layout, value string) (Time, error) { - t, err := time.Parse(layout, value) - return Time{Time: t}, err -} - -func ParseInLocation(layout, value string, loc *time.Location) (Time, error) { - t, err := time.ParseInLocation(layout, value, loc) - return Time{Time: t}, err -} - -func Date(year int, month time.Month, day, hour, minute, second, nanoSecond int, loc *time.Location) Time { - return Time{Time: time.Date(year, month, day, hour, minute, second, nanoSecond, loc)} -} - -func Unix(sec int64, nsec int64) Time { return Time{Time: time.Unix(sec, nsec)} } - -func (t Time) Add(d time.Duration) Time { return Time{Time: t.Time.Add(d)} } -func (t Time) AddDate(years int, months int, days int) Time { - return Time{Time: t.Time.AddDate(years, months, days)} -} -func (t Time) After(u Time) bool { return t.Time.After(u.Time) } -func (t Time) Before(u Time) bool { return t.Time.Before(u.Time) } -func (t Time) Equal(u Time) bool { return t.Time.Equal(u.Time) } -func (t Time) In(loc *time.Location) Time { return Time{Time: t.Time.In(loc)} } -func (t Time) Local() Time { return Time{Time: t.Time.Local()} } -func (t Time) Round(d time.Duration) Time { return Time{Time: t.Time.Round(d)} } -func (t Time) Sub(u Time) time.Duration { return t.Time.Sub(u.Time) } -func (t Time) Truncate(d time.Duration) Time { return Time{Time: t.Time.Truncate(d)} } -func (t Time) UTC() Time { return Time{Time: t.Time.UTC()} } diff --git a/pkg/time/time_test.go b/pkg/time/time_test.go deleted file mode 100644 index 342ca4a10..000000000 --- a/pkg/time/time_test.go +++ /dev/null @@ -1,153 +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 time - -import ( - "encoding/json" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ( - timeParseString = `"1977-09-02T22:04:05Z"` - timeString = "1977-09-02 22:04:05 +0000 UTC" -) - -func givenTime(t *testing.T) Time { - t.Helper() - result, err := Parse(time.RFC3339, "1977-09-02T22:04:05Z") - require.NoError(t, err) - return result -} - -func TestDate(t *testing.T) { - testingTime := givenTime(t) - got := Date(1977, 9, 2, 22, 04, 05, 0, time.UTC) - assert.Equal(t, timeString, got.String()) - assert.True(t, testingTime.Equal(got)) - assert.True(t, got.Equal(testingTime)) -} - -func TestNow(t *testing.T) { - testingTime := givenTime(t) - got := Now() - assert.True(t, testingTime.Before(got)) - assert.True(t, got.After(testingTime)) -} - -func TestTime_Add(t *testing.T) { - testingTime := givenTime(t) - got := testingTime.Add(time.Hour) - assert.Equal(t, timeString, testingTime.String()) - assert.Equal(t, "1977-09-02 23:04:05 +0000 UTC", got.String()) -} - -func TestTime_AddDate(t *testing.T) { - testingTime := givenTime(t) - got := testingTime.AddDate(1, 1, 1) - assert.Equal(t, "1978-10-03 22:04:05 +0000 UTC", got.String()) -} - -func TestTime_In(t *testing.T) { - testingTime := givenTime(t) - edt, err := time.LoadLocation("America/New_York") - assert.NoError(t, err) - got := testingTime.In(edt) - assert.Equal(t, "America/New_York", got.Location().String()) -} - -func TestTime_MarshalJSONNonZero(t *testing.T) { - testingTime := givenTime(t) - res, err := json.Marshal(testingTime) - assert.NoError(t, err) - assert.Equal(t, timeParseString, string(res)) -} - -func TestTime_MarshalJSONZeroValue(t *testing.T) { - res, err := json.Marshal(Time{}) - assert.NoError(t, err) - assert.Equal(t, `""`, string(res)) -} - -func TestTime_Round(t *testing.T) { - testingTime := givenTime(t) - got := testingTime.Round(time.Hour) - assert.Equal(t, timeString, testingTime.String()) - assert.Equal(t, "1977-09-02 22:00:00 +0000 UTC", got.String()) -} - -func TestTime_Sub(t *testing.T) { - testingTime := givenTime(t) - before, err := Parse(time.RFC3339, "1977-09-01T22:04:05Z") - require.NoError(t, err) - got := testingTime.Sub(before) - assert.Equal(t, "24h0m0s", got.String()) -} - -func TestTime_Truncate(t *testing.T) { - testingTime := givenTime(t) - got := testingTime.Truncate(time.Hour) - assert.Equal(t, timeString, testingTime.String()) - assert.Equal(t, "1977-09-02 22:00:00 +0000 UTC", got.String()) -} - -func TestTime_UTC(t *testing.T) { - edtTime, err := Parse(time.RFC3339, "1977-09-03T05:04:05+07:00") - require.NoError(t, err) - got := edtTime.UTC() - assert.Equal(t, timeString, got.String()) -} - -func TestTime_UnmarshalJSONNonZeroValue(t *testing.T) { - testingTime := givenTime(t) - var myTime Time - err := json.Unmarshal([]byte(timeParseString), &myTime) - assert.NoError(t, err) - assert.True(t, testingTime.Equal(myTime)) -} - -func TestTime_UnmarshalJSONEmptyString(t *testing.T) { - var myTime Time - err := json.Unmarshal([]byte(emptyString), &myTime) - assert.NoError(t, err) - assert.True(t, myTime.IsZero()) -} - -func TestTime_UnmarshalJSONNullString(t *testing.T) { - var myTime Time - err := json.Unmarshal([]byte("null"), &myTime) - assert.NoError(t, err) - assert.True(t, myTime.IsZero()) -} - -func TestTime_UnmarshalJSONZeroValue(t *testing.T) { - // This test ensures that we can unmarshal any time value that was output - // with the current go default value of "0001-01-01T00:00:00Z" - var myTime Time - err := json.Unmarshal([]byte(`"0001-01-01T00:00:00Z"`), &myTime) - assert.NoError(t, err) - assert.True(t, myTime.IsZero()) -} - -func TestUnix(t *testing.T) { - got := Unix(242085845, 0) - assert.Equal(t, int64(242085845), got.Unix()) - assert.Equal(t, timeString, got.UTC().String()) -}