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