test: address review feedback on OCI e2e tests

Move the OCI end-to-end tests out of package cmd into a dedicated
test/e2e package behind an 'e2e' build tag, and drive a real helm binary
rather than executeActionCommand.

- Parameterize the registry, namespace, and credentials so the tests run
  against any OCI registry, not a hard-coded personal GHCR namespace.
- Isolate each run's repositories and namespace with a random run ID.
- Fail rather than skip when required configuration is missing, since the
  build tag is already an explicit opt-in.
- Pass the registry password over stdin so credentials never reach argv
  or test output.
- Assert an authentication-specific failure in the invalid credential
  test instead of accepting any error.
- Install through a real helm binary against the ambient kubecontext, so
  the Kubernetes test no longer runs against the fake kube client.
- Stop dereferencing the os.Stat result after a stat error.
- Add a 'make test-e2e' target and test/e2e/README.md.

Signed-off-by: Terry Howe <thowe@nvidia.com>
pull/31590/head
Terry Howe 2 weeks ago
parent 6453933519
commit 516d83d069
No known key found for this signature in database

@ -127,6 +127,15 @@ test-coverage:
@echo "==> Running unit tests with coverage: $(PKG) <==" @echo "==> Running unit tests with coverage: $(PKG) <=="
@ ./scripts/coverage.sh $(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 .PHONY: test-style
test-style: test-style:
golangci-lint run ./... golangci-lint run ./...

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

@ -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-<run id>` 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.

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

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

Binary file not shown.
Loading…
Cancel
Save