mirror of https://github.com/helm/helm
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
parent
6453933519
commit
516d83d069
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue