diff --git a/Makefile b/Makefile index d7422bf23..e8be16bd8 100644 --- a/Makefile +++ b/Makefile @@ -127,6 +127,15 @@ test-coverage: @echo "==> Running unit tests with coverage: $(PKG) <==" @ ./scripts/coverage.sh $(PKG) +# End-to-end tests run a real helm binary against a real OCI registry, and +# optionally a real Kubernetes cluster. They are behind the 'e2e' build tag and +# require configuration; see test/e2e/README.md. +.PHONY: test-e2e +test-e2e: + @echo + @echo "==> Running end-to-end tests <==" + go test $(GOFLAGS) -tags e2e -count=1 -v ./test/e2e/... + .PHONY: test-style test-style: golangci-lint run ./... diff --git a/pkg/cmd/oci_e2e_test.go b/pkg/cmd/oci_e2e_test.go deleted file mode 100644 index f8c1dc0a1..000000000 --- a/pkg/cmd/oci_e2e_test.go +++ /dev/null @@ -1,313 +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 cmd - -import ( - "fmt" - "os" - "path/filepath" - "testing" -) - -// TestOCIRegistryGHCREndToEnd tests push and pull against real GitHub Container Registry (GHCR) -// This test requires HELM_RUN_E2E=true, GHCR_USER and GHCR_TOKEN environment variables to be set -func TestOCIRegistryGHCREndToEnd(t *testing.T) { - if os.Getenv("HELM_RUN_E2E") == "" { - t.Skip("Skipping e2e test: HELM_RUN_E2E environment variable not set") - } - - ghcrUser := os.Getenv("GHCR_USER") - ghcrToken := os.Getenv("GHCR_TOKEN") - - if ghcrUser == "" || ghcrToken == "" { - t.Skip("Skipping GHCR test: GHCR_USER and GHCR_TOKEN environment variables must be set") - } - - // Setup test directories - workDir := t.TempDir() - registryConfigPath := filepath.Join(workDir, "config.json") - contentCache := t.TempDir() - - // GHCR registry configuration - ghcrRegistry := "ghcr.io/terryhowe" - - tests := []struct { - name string - chartPath string - chartName string - chartVersion string - repoPath string - pullArgs string - expectPullFile string - expectPullDir bool - }{ - { - name: "Push and pull basic chart to GHCR", - chartPath: "testdata/testcharts/test-0.1.0.tgz", - chartName: "test", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - expectPullFile: "./test-0.1.0.tgz", - expectPullDir: false, - }, - { - name: "Push and pull chart with untar from GHCR", - chartPath: "testdata/testcharts/compressedchart-0.1.0.tgz", - chartName: "compressedchart", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - pullArgs: "--untar", - expectPullFile: "./compressedchart", - expectPullDir: true, - }, - { - name: "Push and pull chart with hyphens to GHCR", - chartPath: "testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz", - chartName: "compressedchart-with-hyphens", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - expectPullFile: "./compressedchart-with-hyphens-0.1.0.tgz", - expectPullDir: false, - }, - { - name: "Push and pull chart with unicode description to GHCR", - chartPath: "testdata/testcharts/unicode-chart-0.1.0.tgz", - chartName: "unicode-chart", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - expectPullFile: "./unicode-chart-0.1.0.tgz", - expectPullDir: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a fresh pull directory for this test - pullDir := filepath.Join(workDir, tt.name) - if err := os.MkdirAll(pullDir, 0755); err != nil { - t.Fatal(err) - } - - // Construct the push remote (repository path only) - pushRemote := fmt.Sprintf("oci://%s/%s", ghcrRegistry, tt.repoPath) - - // Construct the pull reference (includes chart name and version) - pullRef := fmt.Sprintf("oci://%s/%s/%s:%s", - ghcrRegistry, - tt.repoPath, - tt.chartName, - tt.chartVersion) - - // Push the chart to GHCR - pushCmd := fmt.Sprintf("push %s %s --registry-config %s --username %s --password %s", - tt.chartPath, - pushRemote, - registryConfigPath, - ghcrUser, - ghcrToken) - - t.Logf("Executing push command to GHCR: %s", pushCmd) - _, pushOut, pushErr := executeActionCommand(pushCmd) - - if pushErr != nil { - t.Fatalf("push to GHCR failed: %v\nOutput: %s", pushErr, pushOut) - } - t.Logf("Push to GHCR successful. Output: %s", pushOut) - - // Pull the chart back from GHCR - pullCmd := fmt.Sprintf("pull %s -d '%s' --registry-config %s --content-cache %s --username %s --password %s", - pullRef, - pullDir, - registryConfigPath, - contentCache, - ghcrUser, - ghcrToken) - - if tt.pullArgs != "" { - pullCmd += " " + tt.pullArgs - } - - t.Logf("Executing pull command from GHCR: %s", pullCmd) - _, pullOut, pullErr := executeActionCommand(pullCmd) - - if pullErr != nil { - t.Fatalf("pull from GHCR failed: %v\nOutput: %s", pullErr, pullOut) - } - t.Logf("Pull from GHCR successful. Output: %s", pullOut) - - // Verify the pulled file exists - pulledFilePath := filepath.Join(pullDir, tt.expectPullFile) - fi, err := os.Stat(pulledFilePath) - if err != nil { - t.Errorf("expected file at %s but got error: %s", pulledFilePath, err) - } - - // Verify if it's a directory or file as expected - if fi.IsDir() != tt.expectPullDir { - t.Errorf("expected directory=%t, but got directory=%t", tt.expectPullDir, fi.IsDir()) - } - }) - } -} - -// TestOCIRegistryGHCRAuthFailure tests authentication failures with real GHCR -// This test requires HELM_RUN_E2E=true and GHCR_USER environment variable to be set -func TestOCIRegistryGHCRAuthFailure(t *testing.T) { - if os.Getenv("HELM_RUN_E2E") == "" { - t.Skip("Skipping e2e test: HELM_RUN_E2E environment variable not set") - } - - ghcrUser := os.Getenv("GHCR_USER") - - if ghcrUser == "" { - t.Skip("Skipping GHCR auth failure test: GHCR_USER environment variable must be set") - } - - // Setup test directories - workDir := t.TempDir() - registryConfigPath := filepath.Join(workDir, "config.json") - - // GHCR registry configuration - ghcrRegistry := "ghcr.io/terryhowe" - - t.Run("Fail push with invalid credentials to GHCR", func(t *testing.T) { - pushRemote := fmt.Sprintf("oci://%s/helm-e2e-test", ghcrRegistry) - - // Try to push with invalid credentials - pushCmd := fmt.Sprintf("push testdata/testcharts/test-0.1.0.tgz %s --registry-config %s --username %s --password %s", - pushRemote, - registryConfigPath, - ghcrUser, - "invalid-token-12345") - - t.Logf("Executing push command with invalid credentials to GHCR") - _, _, pushErr := executeActionCommand(pushCmd) - - if pushErr == nil { - t.Fatal("expected push to fail with invalid credentials but it succeeded") - } - t.Logf("Got expected authentication error: %v", pushErr) - }) -} - -// TestOCIRegistryGHCRWithKubernetes tests the full end-to-end flow: -// push to GHCR -> install to Kubernetes -> uninstall from Kubernetes -// This test requires HELM_RUN_E2E=true, GHCR_USER and GHCR_TOKEN environment variables and access to a Kubernetes cluster -func TestOCIRegistryGHCRWithKubernetes(t *testing.T) { - if os.Getenv("HELM_RUN_E2E") == "" { - t.Skip("Skipping e2e test: HELM_RUN_E2E environment variable not set") - } - - ghcrUser := os.Getenv("GHCR_USER") - ghcrToken := os.Getenv("GHCR_TOKEN") - - if ghcrUser == "" || ghcrToken == "" { - t.Skip("Skipping GHCR+K8s test: GHCR_USER and GHCR_TOKEN environment variables must be set") - } - - // Setup test directories - workDir := t.TempDir() - registryConfigPath := filepath.Join(workDir, "config.json") - - // GHCR registry configuration - ghcrRegistry := "ghcr.io/terryhowe" - testNamespace := "helm-e2e-test" - - tests := []struct { - name string - chartPath string - chartName string - chartVersion string - repoPath string - releaseName string - }{ - { - name: "Install basic chart from GHCR to K8s", - chartPath: "testdata/testcharts/test-0.1.0.tgz", - chartName: "test", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - releaseName: "test-release", - }, - { - name: "Install chart with unicode from GHCR to K8s", - chartPath: "testdata/testcharts/unicode-chart-0.1.0.tgz", - chartName: "unicode-chart", - chartVersion: "0.1.0", - repoPath: "helm-e2e-test", - releaseName: "unicode-release", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Construct the push remote (repository path only) - pushRemote := fmt.Sprintf("oci://%s/%s", ghcrRegistry, tt.repoPath) - - // Construct the OCI reference for installation - ociRef := fmt.Sprintf("oci://%s/%s/%s", - ghcrRegistry, - tt.repoPath, - tt.chartName) - - // Push the chart to GHCR - pushCmd := fmt.Sprintf("push %s %s --registry-config %s --username %s --password %s", - tt.chartPath, - pushRemote, - registryConfigPath, - ghcrUser, - ghcrToken) - - t.Logf("Pushing chart to GHCR: %s", tt.chartName) - _, pushOut, pushErr := executeActionCommand(pushCmd) - if pushErr != nil { - t.Fatalf("push to GHCR failed: %v\nOutput: %s", pushErr, pushOut) - } - t.Logf("Push successful") - - // Install the chart to Kubernetes - installCmd := fmt.Sprintf("install %s %s --version %s --namespace %s --create-namespace --registry-config %s --username %s --password %s --wait --timeout 2m", - tt.releaseName, - ociRef, - tt.chartVersion, - testNamespace, - registryConfigPath, - ghcrUser, - ghcrToken) - - t.Logf("Installing chart to Kubernetes: %s", tt.releaseName) - _, installOut, installErr := executeActionCommand(installCmd) - if installErr != nil { - t.Fatalf("install to Kubernetes failed: %v\nOutput: %s", installErr, installOut) - } - t.Logf("Install successful. Output: %s", installOut) - - // Verify the release is installed by checking for chart resources in the namespace - // Note: We can't use helm list/status here because executeActionCommand creates separate storage contexts - t.Logf("Verifying installation succeeded (output shows deployed status)") - - // Clean up: Delete the namespace which will remove all resources - t.Logf("Cleaning up namespace: %s", testNamespace) - // Note: We'll delete the namespace at the end of all tests, not per-test - }) - } - - // Cleanup: delete the test namespace - t.Logf("Deleting test namespace: %s", testNamespace) - // Using Go's os/exec would be better but for simplicity in tests we'll rely on the namespace being cleaned up manually - // or by the next test run with --create-namespace -} diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 000000000..799fb00d4 --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,43 @@ +# Helm end-to-end tests + +These tests exercise a real `helm` binary against real external systems: an OCI +registry, and optionally a Kubernetes cluster. They are guarded by the `e2e` +build tag, so they are never compiled or run by `make test`. + +Unlike the unit tests, nothing here is faked. The registry is a real registry, +and the Kubernetes test installs into whatever cluster your current kubecontext +points at. + +## Running + +```console +$ export HELM_E2E_REGISTRY=ghcr.io/your-org +$ export HELM_E2E_USERNAME=your-user +$ export HELM_E2E_PASSWORD=your-token +$ make test-e2e +``` + +Any OCI registry works, not just GHCR. To run against a local registry: + +```console +$ export HELM_E2E_REGISTRY=localhost:5000/charts +$ export HELM_E2E_PLAIN_HTTP=true +``` + +## Configuration + +| Variable | Required | Description | +| ---------------------- | -------- | ---------------------------------------------------------------------------------------------- | +| `HELM_E2E_REGISTRY` | yes | Registry namespace to push to, without a scheme (e.g. `ghcr.io/your-org`). | +| `HELM_E2E_USERNAME` | yes | Username for the registry. | +| `HELM_E2E_PASSWORD` | yes | Password or token for the registry. Passed to helm on stdin and never logged. | +| `HELM_E2E_BIN` | no | Path to a prebuilt helm binary. Defaults to building one from the working tree. | +| `HELM_E2E_PLAIN_HTTP` | no | Set to use plain HTTP, for registries without TLS. | +| `HELM_E2E_KUBERNETES` | no | Set to run the install test against the current kubecontext. Off by default because it mutates. | +| `HELM_E2E_NAMESPACE` | no | Namespace for the Kubernetes test. Defaults to a unique `helm-e2e-` namespace. | + +Because the `e2e` build tag is already an explicit opt-in, a missing required +variable fails the test rather than silently skipping it. + +Each run pushes to repositories suffixed with a random run ID, so concurrent +runs and repeated runs in a shared namespace do not collide. diff --git a/test/e2e/helper_test.go b/test/e2e/helper_test.go new file mode 100644 index 000000000..91ed186ac --- /dev/null +++ b/test/e2e/helper_test.go @@ -0,0 +1,246 @@ +//go:build e2e + +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package e2e contains end-to-end tests that exercise a real helm binary +// against real external systems (an OCI registry, and optionally a Kubernetes +// cluster). +// +// These tests are excluded from the normal build by the "e2e" build tag and +// are never run by `make test`. Run them with `make test-e2e` after exporting +// the configuration described in the package README. +package e2e + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Environment variables used to configure the end-to-end tests. +const ( + // envRegistry is the registry namespace charts are pushed to, without a + // scheme, e.g. "ghcr.io/example-org" or "localhost:5000/charts". + envRegistry = "HELM_E2E_REGISTRY" + // envUsername is the username used to authenticate to envRegistry. + envUsername = "HELM_E2E_USERNAME" + // envPassword is the password or token used to authenticate to envRegistry. + envPassword = "HELM_E2E_PASSWORD" + // envHelmBin points at a prebuilt helm binary. When unset, one is built + // from the working tree. + envHelmBin = "HELM_E2E_BIN" + // envPlainHTTP requests plain HTTP for registries without TLS, which is + // useful when testing against a local registry. + envPlainHTTP = "HELM_E2E_PLAIN_HTTP" + // envKubernetes opts in to the tests that install charts into a real + // Kubernetes cluster using the ambient kubeconfig. + envKubernetes = "HELM_E2E_KUBERNETES" + // envNamespace is the namespace the Kubernetes tests install into. + envNamespace = "HELM_E2E_NAMESPACE" +) + +// harness carries the resolved configuration shared by the end-to-end tests. +type harness struct { + // helmBin is an absolute path to the helm binary under test. + helmBin string + // registry is the registry namespace charts are pushed to. + registry string + // username and password authenticate to the registry. They are only ever + // passed to helm over stdin and are never logged. + username string + password string + // plainHTTP is true when the registry should be reached over HTTP. + plainHTTP bool + // configPath is the registry credential file used for this run. + configPath string + // runID isolates the repositories written by a single test run so that + // concurrent or repeated runs do not collide. + runID string +} + +// newHarness resolves the end-to-end configuration, failing the test when a +// required value is missing. Because the "e2e" build tag is an explicit opt-in, +// a missing setting is a configuration error rather than a reason to skip. +func newHarness(t *testing.T) *harness { + t.Helper() + + h := &harness{ + registry: requireEnv(t, envRegistry), + username: requireEnv(t, envUsername), + password: requireEnv(t, envPassword), + plainHTTP: os.Getenv(envPlainHTTP) != "", + helmBin: helmBinary(t), + runID: runID(t), + } + return h +} + +// requireEnv returns the value of the named environment variable, failing the +// test with an actionable message when it is unset. +func requireEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s must be set to run the end-to-end tests; see test/e2e/README.md", name) + } + return v +} + +// helmBinary returns the helm binary to exercise, building one from the +// working tree when HELM_E2E_BIN is not set. +func helmBinary(t *testing.T) string { + t.Helper() + + if bin := os.Getenv(envHelmBin); bin != "" { + abs, err := filepath.Abs(bin) + if err != nil { + t.Fatalf("resolving %s=%q: %v", envHelmBin, bin, err) + } + if _, err := os.Stat(abs); err != nil { + t.Fatalf("%s=%q is not usable: %v", envHelmBin, bin, err) + } + return abs + } + + bin := filepath.Join(t.TempDir(), "helm") + build := exec.Command("go", "build", "-o", bin, "helm.sh/helm/v4/cmd/helm") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("building helm: %v\n%s", err, out) + } + return bin +} + +// runID returns a short random identifier unique to this test run. +func runID(t *testing.T) string { + t.Helper() + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + t.Fatalf("generating run id: %v", err) + } + return hex.EncodeToString(b) +} + +// registryHost returns the host portion of the configured registry, which is +// what `helm registry login` expects. +func (h *harness) registryHost() string { + host, _, _ := strings.Cut(h.registry, "/") + return host +} + +// repo returns an isolated repository path for this run, so that a shared +// registry namespace can serve many runs without interference. +func (h *harness) repo(name string) string { + return fmt.Sprintf("%s/%s-%s", h.registry, name, h.runID) +} + +// ref returns a full OCI reference for a chart within an isolated repository. +func (h *harness) ref(repo, chart, version string) string { + if version == "" { + return fmt.Sprintf("oci://%s/%s", repo, chart) + } + return fmt.Sprintf("oci://%s/%s:%s", repo, chart, version) +} + +// plainHTTPCommands are the helm subcommands that accept --plain-http. The +// flag is only appended for these so that the harness can still run commands +// such as `status` and `uninstall` against a plain HTTP registry. +var plainHTTPCommands = map[string]bool{"push": true, "pull": true, "install": true, "upgrade": true} + +// helm runs the helm binary with the given arguments and returns its combined +// output. Arguments are logged, so callers must never pass a credential. +func (h *harness) helm(t *testing.T, args ...string) (string, error) { + t.Helper() + if h.plainHTTP && len(args) > 0 && plainHTTPCommands[args[0]] { + args = append(args, "--plain-http") + } + t.Logf("helm %s", strings.Join(args, " ")) + cmd := exec.Command(h.helmBin, args...) + cmd.Env = append(os.Environ(), "HELM_REGISTRY_CONFIG="+h.registryConfig(t)) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// mustHelm runs helm and fails the test if the command does not succeed. +func (h *harness) mustHelm(t *testing.T, args ...string) string { + t.Helper() + out, err := h.helm(t, args...) + if err != nil { + t.Fatalf("helm %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return out +} + +// registryConfig returns the path to this run's registry credential file. The +// file lives under a per-test temporary directory so credentials never leak +// into the developer's real helm configuration. +func (h *harness) registryConfig(t *testing.T) string { + t.Helper() + if h.configPath == "" { + h.configPath = filepath.Join(t.TempDir(), "registry-config.json") + } + return h.configPath +} + +// login authenticates to the configured registry. The password is written to +// helm's stdin rather than passed as a flag so it cannot appear in process +// listings or test output. +func (h *harness) login(t *testing.T, password string) (string, error) { + t.Helper() + args := []string{"registry", "login", h.registryHost(), "--username", h.username, "--password-stdin"} + if h.plainHTTP { + args = append(args, "--plain-http") + } + t.Logf("helm %s", strings.Join(args, " ")) + cmd := exec.Command(h.helmBin, args...) + cmd.Env = append(os.Environ(), "HELM_REGISTRY_CONFIG="+h.registryConfig(t)) + cmd.Stdin = strings.NewReader(password) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// mustLogin authenticates with the configured credential and arranges for a +// logout once the test completes. +func (h *harness) mustLogin(t *testing.T) { + t.Helper() + out, err := h.login(t, h.password) + if err != nil { + t.Fatalf("registry login to %s failed: %v\n%s", h.registryHost(), err, out) + } + t.Cleanup(func() { + if out, err := h.helm(t, "registry", "logout", h.registryHost()); err != nil { + t.Logf("registry logout failed (ignored): %v\n%s", err, out) + } + }) +} + +// chart returns the path to a chart archive in this package's testdata. +func chart(t *testing.T, name string) string { + t.Helper() + path, err := filepath.Abs(filepath.Join("testdata", "testcharts", name)) + if err != nil { + t.Fatalf("resolving chart %q: %v", name, err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("chart %q is missing: %v", name, err) + } + return path +} diff --git a/test/e2e/oci_test.go b/test/e2e/oci_test.go new file mode 100644 index 000000000..03a382d84 --- /dev/null +++ b/test/e2e/oci_test.go @@ -0,0 +1,247 @@ +//go:build e2e + +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestOCIRegistryPushPull pushes chart archives to the configured OCI registry +// and pulls them back, verifying that the artifact round-trips intact. +func TestOCIRegistryPushPull(t *testing.T) { + h := newHarness(t) + h.mustLogin(t) + + repo := h.repo("push-pull") + contentCache := t.TempDir() + + tests := []struct { + name string + chart string + chartName string + chartVersion string + pullArgs []string + expectPullFile string + expectPullDir bool + }{ + { + name: "basic chart", + chart: "test-0.1.0.tgz", + chartName: "test", + chartVersion: "0.1.0", + expectPullFile: "test-0.1.0.tgz", + }, + { + name: "chart pulled with untar", + chart: "compressedchart-0.1.0.tgz", + chartName: "compressedchart", + chartVersion: "0.1.0", + pullArgs: []string{"--untar"}, + expectPullFile: "compressedchart", + expectPullDir: true, + }, + { + name: "chart name with hyphens", + chart: "compressedchart-with-hyphens-0.1.0.tgz", + chartName: "compressedchart-with-hyphens", + chartVersion: "0.1.0", + expectPullFile: "compressedchart-with-hyphens-0.1.0.tgz", + }, + { + name: "chart with unicode description", + chart: "unicode-chart-0.1.0.tgz", + chartName: "unicode-chart", + chartVersion: "0.1.0", + expectPullFile: "unicode-chart-0.1.0.tgz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pullDir := t.TempDir() + + h.mustHelm(t, "push", chart(t, tt.chart), "oci://"+repo) + + pullArgs := append([]string{ + "pull", h.ref(repo, tt.chartName, tt.chartVersion), + "--destination", pullDir, + "--content-cache", contentCache, + }, tt.pullArgs...) + h.mustHelm(t, pullArgs...) + + pulled := filepath.Join(pullDir, tt.expectPullFile) + fi, err := os.Stat(pulled) + if err != nil { + t.Fatalf("expected pulled chart at %s: %v", pulled, err) + } + if fi.IsDir() != tt.expectPullDir { + t.Errorf("expected directory=%t, got directory=%t", tt.expectPullDir, fi.IsDir()) + } + }) + } +} + +// TestOCIRegistryInvalidCredentials verifies that the registry rejects a bad +// credential with an authentication error, rather than any error at all. +func TestOCIRegistryInvalidCredentials(t *testing.T) { + h := newHarness(t) + + // Log in with a deliberately wrong password against the same registry and + // namespace the successful tests use, so the only variable is the + // credential itself. + out, err := h.login(t, "invalid-"+h.runID) + if err == nil { + t.Fatal("expected registry login to fail with an invalid credential, but it succeeded") + } + if !isAuthError(out) { + t.Fatalf("expected an authentication failure, got a different error:\n%s", out) + } + + // Seed the credential store directly with the same wrong password, so the + // push reaches the registry and is rejected by it rather than failing + // locally for want of any credential at all. + writeRegistryCredential(t, h.registryConfig(t), h.registryHost(), h.username, "invalid-"+h.runID) + + out, err = h.helm(t, "push", chart(t, "test-0.1.0.tgz"), "oci://"+h.repo("auth-failure")) + if err == nil { + t.Fatal("expected push to fail with an invalid credential, but it succeeded") + } + if !isAuthError(out) { + t.Fatalf("expected an authentication failure, got a different error:\n%s", out) + } +} + +// writeRegistryCredential writes a docker-style credential file so a test can +// present a specific credential to the registry without going through +// `helm registry login`, which refuses to store one the registry rejects. +func writeRegistryCredential(t *testing.T, path, host, username, password string) { + t.Helper() + cfg := struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + }{ + Auths: map[string]struct { + Auth string `json:"auth"` + }{ + host: {Auth: base64.StdEncoding.EncodeToString([]byte(username + ":" + password))}, + }, + } + b, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("encoding registry credential: %v", err) + } + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatalf("writing registry credential to %s: %v", path, err) + } +} + +// isAuthError reports whether the output describes an authentication or +// authorization failure, as opposed to a network, DNS, or naming error that +// would otherwise make the test pass for the wrong reason. +func isAuthError(out string) bool { + out = strings.ToLower(out) + for _, marker := range []string{ + "401", + "403", + "unauthorized", + "denied", + "authentication required", + "invalid username/password", + } { + if strings.Contains(out, marker) { + return true + } + } + return false +} + +// TestOCIRegistryInstallToKubernetes exercises the full flow of pushing a +// chart to an OCI registry and installing it into a real Kubernetes cluster +// using the ambient kubeconfig. It is opt-in because, unlike the registry +// tests, it mutates a cluster. +func TestOCIRegistryInstallToKubernetes(t *testing.T) { + if os.Getenv(envKubernetes) == "" { + t.Skipf("Skipping Kubernetes end-to-end test: set %s to run it against the current kubecontext", envKubernetes) + } + + h := newHarness(t) + h.mustLogin(t) + + namespace := os.Getenv(envNamespace) + if namespace == "" { + namespace = "helm-e2e-" + h.runID + } + repo := h.repo("install") + + tests := []struct { + name string + chart string + chartName string + chartVersion string + releaseName string + }{ + { + name: "basic chart", + chart: "test-0.1.0.tgz", + chartName: "test", + chartVersion: "0.1.0", + releaseName: "test-release", + }, + { + name: "chart with unicode description", + chart: "unicode-chart-0.1.0.tgz", + chartName: "unicode-chart", + chartVersion: "0.1.0", + releaseName: "unicode-release", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h.mustHelm(t, "push", chart(t, tt.chart), "oci://"+repo) + + t.Cleanup(func() { + if out, err := h.helm(t, "uninstall", tt.releaseName, "--namespace", namespace, "--ignore-not-found", "--wait"); err != nil { + t.Errorf("uninstalling %s failed: %v\n%s", tt.releaseName, err, out) + } + }) + + h.mustHelm(t, "install", tt.releaseName, h.ref(repo, tt.chartName, ""), + "--version", tt.chartVersion, + "--namespace", namespace, + "--create-namespace", + "--wait", + "--timeout", "2m", + ) + + // Read the release back from cluster storage to confirm the + // install really reached the API server. + out := h.mustHelm(t, "status", tt.releaseName, "--namespace", namespace, "--output", "json") + if !strings.Contains(out, `"status":"deployed"`) { + t.Errorf("expected release %s to be deployed, got:\n%s", tt.releaseName, out) + } + }) + } +} diff --git a/test/e2e/testdata/testcharts/compressedchart-0.1.0.tgz b/test/e2e/testdata/testcharts/compressedchart-0.1.0.tgz new file mode 100644 index 000000000..3c9c24d76 Binary files /dev/null and b/test/e2e/testdata/testcharts/compressedchart-0.1.0.tgz differ diff --git a/test/e2e/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz b/test/e2e/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz new file mode 100644 index 000000000..379210a92 Binary files /dev/null and b/test/e2e/testdata/testcharts/compressedchart-with-hyphens-0.1.0.tgz differ diff --git a/test/e2e/testdata/testcharts/test-0.1.0.tgz b/test/e2e/testdata/testcharts/test-0.1.0.tgz new file mode 100644 index 000000000..9ed772a7f Binary files /dev/null and b/test/e2e/testdata/testcharts/test-0.1.0.tgz differ diff --git a/test/e2e/testdata/testcharts/unicode-chart-0.1.0.tgz b/test/e2e/testdata/testcharts/unicode-chart-0.1.0.tgz new file mode 100644 index 000000000..9af1ce2c8 Binary files /dev/null and b/test/e2e/testdata/testcharts/unicode-chart-0.1.0.tgz differ