feature: automated OCI functional tests (#31590)

* feature: automated OCI end-to-end tests

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

* 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>

* test: address second round of review feedback on OCI e2e tests

- Verify pulled chart contents against the source fixture instead of only
  checking that a path of the expected name exists, so a registry serving a
  different artifact under the same tag fails the test.
- Skip the invalid-credential test against a registry that serves anonymous
  access, where there is no authentication to exercise.
- Delete the namespace the Kubernetes test creates, and wait for it to go,
  so runs do not accumulate namespaces. A caller-supplied
  HELM_E2E_NAMESPACE is left in place.

Signed-off-by: Terry Howe <thowe@nvidia.com>

* test: run OCI e2e tests across registry implementations

The suite has no registry-specific behavior, so covering a new registry
is a matter of pointing it at one. Add the CI wiring to do that, and make
the repository paths suitable for shared registries.

- Repository paths are now stable rather than per-run. The fixtures are
  immutable, so re-running overwrites each tag with identical content.
  This bounds the repositories a shared registry accumulates and lets
  registries that require repositories to exist before a push (ECR) have
  them created ahead of time. HELM_E2E_ISOLATE restores per-run paths.
- Add HELM_E2E_REPO to set the repository path under the registry.
- Add e2e.yml: local authenticated registry plus kind, no secrets, so it
  runs on pull requests from forks.
- Add e2e-registries.yml: nightly matrix over GHCR, Quay, and ECR with
  fail-fast disabled. A registry with no secrets configured is skipped,
  so they can be wired up one at a time.

Signed-off-by: Terry Howe <thowe@nvidia.com>

* test: address review feedback on e2e harness and workflow

- Build the helm binary by relative package path instead of the v4 module
  path, so the harness also works on branches where the module carries a
  different major version suffix.
- Scope Kubernetes release names to the run. A caller-supplied
  HELM_E2E_NAMESPACE may already hold releases, and a fixed name could
  collide with one that cleanup would then uninstall.
- Fail a partially configured registry leg in the nightly workflow
  instead of reporting it as configured and failing later in the harness
  or the AWS CLI. A leg with no registry set is still skipped.

Signed-off-by: Terry Howe <thowe@nvidia.com>

* test: honor HELM_KUBECONTEXT when deleting the e2e namespace

Helm subprocesses inherit HELM_KUBECONTEXT from the environment, but the
cleanup client loaded the kubeconfig's current-context. When the two
differ, releases are installed into one cluster while the namespace
delete is sent to another, where it returns NotFound and is treated as
success, leaking the namespace it was meant to remove.

Apply the same context override when building the cleanup client.

Signed-off-by: Terry Howe <thowe@nvidia.com>

* test: build e2e cleanup client from helm's own settings

Overriding only HELM_KUBECONTEXT still left the cleanup client able to
diverge from the helm subprocesses, which also honor HELM_KUBEAPISERVER,
HELM_KUBETOKEN, HELM_KUBECAFILE, HELM_KUBETLS_SERVER_NAME and
HELM_KUBEINSECURE_SKIP_TLS_VERIFY. Any of those could send the namespace
delete to a different endpoint, where NotFound reads as success and the
namespace leaks.

Resolve the client through cli.New().RESTClientGetter() instead, so it is
built exactly the way the subprocesses build theirs and stays in step
without mirroring each variable by hand.

Signed-off-by: Terry Howe <thowe@nvidia.com>

* test: run hosted e2e against docker hub in single-repository mode

Replace the GHCR, Quay and ECR legs of e2e-registries with a Docker Hub
job using the E2E_DOCKER_* secrets. Docker Hub does not nest repository
paths and a scoped token can only write to one repository, so add
HELM_E2E_SINGLE_REPO: each fixture is repackaged under that repository's
name with its own prerelease version, e.g. 0.1.0-unicode-chart.

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

* ci: read non-secret docker hub e2e settings from repository variables

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

* ci: run e2e workflow on main only

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

* ci: update kind, registry, httpd, checkout and setup-go in e2e workflows

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

* ci: serialize e2e-registries runs that share docker hub tags

Signed-off-by: Terry Howe <terrylhowe@gmail.com>

---------

Signed-off-by: Terry Howe <terrylhowe@gmail.com>
Signed-off-by: Terry Howe <thowe@nvidia.com>
This commit is contained in:
Terry Howe authored and GitHub committed 2026-10-20 18:02:08 -06:00
1 parent d2ff87dcce
commit bc047f2792
14 files changed
+1090

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
name: e2e-registries
# Runs the OCI end-to-end suite against hosted registries, to catch changes
# that break a particular registry rather than OCI in general.
#
# This workflow needs credentials, so it deliberately does not run on
# pull_request: fork PRs cannot read secrets and would fail for every outside
# contributor. Secret-free coverage against a local registry runs per-PR in
# e2e.yml.
#
# Required configuration. If none is set the job is skipped, so forks running
# this on a schedule do not fail. If only some is set, that is a
# misconfiguration and the job fails loudly rather than being silently skipped.
# Only the token is a secret; the rest are repository variables, so they show
# up unmasked in the logs.
#
# Docker Hub vars.E2E_DOCKER_USERNAME
# secrets.E2E_DOCKER_PASSWORD access token with write access to the repository
# vars.E2E_DOCKER_REPOSITORY e.g. helm-e2e
# vars.E2E_DOCKER_NAMESPACE optional, defaults to E2E_DOCKER_USERNAME
#
# Docker Hub does not nest repository paths, and a scoped access token may only
# be able to write to one repository, so the suite runs in single-repository
# mode: every fixture is pushed to <namespace>/<repository> under its own
# version. See test/e2e/README.md.
on:
schedule:
# Nightly, after most merges have landed.
- cron: "0 6 * * *"
workflow_dispatch:
permissions:
contents: read
# Runs share fixed tags in one Docker Hub repository, and repackaged fixtures
# differ byte for byte between runs, so an overlapping run could overwrite a
# tag between another run's push and pull. Queue runs rather than overlap.
concurrency:
group: e2e-registries
cancel-in-progress: false
env:
KIND_VERSION: v0.33.0
jobs:
dockerhub:
runs-on: ubuntu-latest
steps:
# Configuration is read as environment variables rather than interpolated
# into the script, so neither a credential nor a variable becomes shell
# source.
- name: Resolve registry configuration
id: config
env:
DOCKER_USERNAME: ${{ vars.E2E_DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.E2E_DOCKER_PASSWORD }}
DOCKER_REPOSITORY: ${{ vars.E2E_DOCKER_REPOSITORY }}
DOCKER_NAMESPACE: ${{ vars.E2E_DOCKER_NAMESPACE }}
run: |
set -euo pipefail
if [ -z "${DOCKER_USERNAME}${DOCKER_PASSWORD}${DOCKER_REPOSITORY}" ]; then
echo "no Docker Hub configuration set, skipping"
echo "configured=false" >> "$GITHUB_OUTPUT"
exit 0
fi
missing=""
[ -n "${DOCKER_USERNAME}" ] || missing="${missing} vars.E2E_DOCKER_USERNAME"
[ -n "${DOCKER_PASSWORD}" ] || missing="${missing} secrets.E2E_DOCKER_PASSWORD"
[ -n "${DOCKER_REPOSITORY}" ] || missing="${missing} vars.E2E_DOCKER_REPOSITORY"
if [ -n "${missing}" ]; then
echo "::error::Docker Hub is partially configured; missing:${missing}"
exit 1
fi
{
echo "HELM_E2E_REGISTRY=registry-1.docker.io/${DOCKER_NAMESPACE:-${DOCKER_USERNAME}}"
echo "HELM_E2E_USERNAME=${DOCKER_USERNAME}"
echo "HELM_E2E_PASSWORD=${DOCKER_PASSWORD}"
echo "HELM_E2E_SINGLE_REPO=${DOCKER_REPOSITORY}"
} >> "$GITHUB_ENV"
echo "configured=true" >> "$GITHUB_OUTPUT"
- name: Checkout source code
if: steps.config.outputs.configured == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1
- name: Add variables to environment file
if: steps.config.outputs.configured == 'true'
run: cat ".github/env" >> "$GITHUB_ENV"
- name: Setup Go
if: steps.config.outputs.configured == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # pin@7.0.0
with:
go-version: '${{ env.GOLANG_VERSION }}'
check-latest: true
- name: Create a kind cluster
if: steps.config.outputs.configured == 'true'
run: |
set -euo pipefail
go install "sigs.k8s.io/kind@${KIND_VERSION}"
kind create cluster --name helm-e2e --wait 120s
- name: Run end-to-end tests
if: steps.config.outputs.configured == 'true'
env:
HELM_E2E_KUBERNETES: "true"
run: make test-e2e
+85
View File
@@ -0,0 +1,85 @@
name: e2e
# Limited to main: the e2e package imports helm.sh/helm/v4, so it does not
# build on the v3 branches.
on:
push:
branches:
- "main"
pull_request:
branches:
- "main"
workflow_dispatch:
permissions:
contents: read
env:
KIND_VERSION: v0.33.0
REGISTRY_IMAGE: registry:3.1.2
HTPASSWD_IMAGE: httpd:2.4.69-alpine
jobs:
# Runs the OCI end-to-end suite against a local, authenticated registry and a
# kind cluster. Needs no secrets, so it runs on pull requests from forks.
# Cross-registry coverage lives in e2e-registries.yml.
local-registry:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1
- name: Add variables to environment file
run: cat ".github/env" >> "$GITHUB_ENV"
- name: Setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # pin@7.0.0
with:
go-version: '${{ env.GOLANG_VERSION }}'
check-latest: true
- name: Start an authenticated local registry
run: |
set -euo pipefail
mkdir -p "${RUNNER_TEMP}/registry-auth"
docker run --rm --entrypoint htpasswd "${HTPASSWD_IMAGE}" \
-Bbn e2euser e2epass > "${RUNNER_TEMP}/registry-auth/htpasswd"
docker run -d --name helm-e2e-registry -p 5000:5000 \
-v "${RUNNER_TEMP}/registry-auth:/auth" \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM=Registry \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
"${REGISTRY_IMAGE}"
# Wait for the registry to answer before handing it to the tests. An
# unauthenticated /v2/ must be rejected, which also confirms that the
# htpasswd file was picked up and the invalid-credential test will
# have something to assert against.
for _ in $(seq 1 30); do
if [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/v2/)" = "401" ]; then
echo "registry is up and requires authentication"
exit 0
fi
sleep 1
done
echo "local registry did not come up with authentication enabled"
docker logs helm-e2e-registry
exit 1
- name: Create a kind cluster
run: |
set -euo pipefail
go install "sigs.k8s.io/kind@${KIND_VERSION}"
kind create cluster --name helm-e2e --wait 120s
kubectl cluster-info --context kind-helm-e2e
- name: Run end-to-end tests
env:
HELM_E2E_REGISTRY: localhost:5000
HELM_E2E_REPO: helm-e2e
HELM_E2E_USERNAME: e2euser
HELM_E2E_PASSWORD: e2epass
HELM_E2E_PLAIN_HTTP: "true"
HELM_E2E_KUBERNETES: "true"
run: make test-e2e
- name: Collect registry logs on failure
if: failure()
run: docker logs helm-e2e-registry
+9
View File
@@ -112,6 +112,15 @@ test-coverage:
@echo "==> Running unit tests with coverage: $(PKG) <=="
@ ./scripts/coverage.sh $(PKG)
# End-to-end tests run a real helm binary against a real OCI registry, and
# optionally a real Kubernetes cluster. They are behind the 'e2e' build tag and
# require configuration; see test/e2e/README.md.
.PHONY: test-e2e
test-e2e:
@echo
@echo "==> Running end-to-end tests <=="
go test $(GOFLAGS) -tags e2e -count=1 -v ./test/e2e/...
.PHONY: test-style
test-style:
@EXPECTED_VERSION=$$(grep GOLANGCI_LINT_VERSION .github/env | cut -d= -f2); \
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
description: Test chart with unicode Kröpke 日本語 中文 한글
name: unicode-chart
version: 0.1.0
@@ -0,0 +1,3 @@
Thank you for installing {{ .Chart.Name }}.
This chart includes unicode: Kröpke 日本語 中文 한글
+2
View File
@@ -0,0 +1,2 @@
# Default values for unicode-chart
replicaCount: 1
+115
View File
@@ -0,0 +1,115 @@
# 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
```
A default `registry` (distribution) deployment serves anonymous access and accepts any
credential. Against such a registry there is no authentication to exercise, so
the invalid-credential test detects this and skips itself. Run against an
auth-enabled registry to cover that path.
## 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_REPO` | no | Repository path under the registry. Defaults to `helm-e2e`. |
| `HELM_E2E_ISOLATE` | no | Append a per-run suffix to the repository path. Off by default; see below. |
| `HELM_E2E_SINGLE_REPO` | no | Push every fixture into this one repository instead. See below. |
| `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.
## Repository layout
Helm appends the chart name to the push target, so a run touches one repository
per fixture:
```
<HELM_E2E_REGISTRY>/<HELM_E2E_REPO>/test
<HELM_E2E_REGISTRY>/<HELM_E2E_REPO>/compressedchart
<HELM_E2E_REGISTRY>/<HELM_E2E_REPO>/compressedchart-with-hyphens
<HELM_E2E_REGISTRY>/<HELM_E2E_REPO>/unicode-chart
```
That set is stable by default. The fixtures are immutable, so re-running
overwrites each tag with byte-identical content: concurrent runs do not
interfere, a shared registry does not accumulate repositories over time, and
registries that require a repository to exist before a push (ECR) can have
these created ahead of time.
Set `HELM_E2E_ISOLATE` to append a per-run suffix instead, which is useful when
several people share one registry namespace and you want a run to stand alone.
Note that stable paths stay safe only while every test pushes identical content
under a given tag; a test that pushes mutated content under a fixed tag would
need isolation.
## Single-repository mode
Some registries cannot hold the layout above. Docker Hub does not nest
repository paths, so `<namespace>/helm-e2e/test` is not a valid repository, and
an access token scoped to one repository cannot write to the others anyway.
Set `HELM_E2E_SINGLE_REPO` to the name of a repository that already exists
directly under `HELM_E2E_REGISTRY`. Because helm always pushes a chart to a
repository named after the chart, each fixture is repackaged under that name,
and its original name is folded into a prerelease version to keep the fixtures
apart:
```
<HELM_E2E_REGISTRY>/<HELM_E2E_SINGLE_REPO>:0.1.0-test
<HELM_E2E_REGISTRY>/<HELM_E2E_SINGLE_REPO>:0.1.0-compressedchart
<HELM_E2E_REGISTRY>/<HELM_E2E_SINGLE_REPO>:0.1.0-compressedchart-with-hyphens
<HELM_E2E_REGISTRY>/<HELM_E2E_SINGLE_REPO>:0.1.0-unicode-chart
```
`HELM_E2E_REPO` is ignored in this mode, and `HELM_E2E_ISOLATE` appends the run
ID to each version rather than to the repository path. Coverage that depends
on the chart's own name, such as hyphens in the name, does not apply here.
```console
$ export HELM_E2E_REGISTRY=registry-1.docker.io/your-namespace
$ export HELM_E2E_SINGLE_REPO=helm-e2e
```
## Running against hosted registries
The suite has no registry-specific behavior — everything comes from the
environment — so covering a new registry is a matter of pointing the same tests
at it. `.github/workflows/e2e-registries.yml` runs it against Docker Hub on a
schedule, in single-repository mode, to catch changes that break a particular
registry implementation rather than OCI in general.
A default `registry` serves anonymous access, so the invalid-credential test
skips itself there, as described above.
The Kubernetes test deletes the namespace it creates. If you supply
`HELM_E2E_NAMESPACE`, that namespace is left in place and only the releases are
uninstalled.
+341
View File
@@ -0,0 +1,341 @@
//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"
"helm.sh/helm/v4/pkg/chart/v2/loader"
chartutil "helm.sh/helm/v4/pkg/chart/v2/util"
)
// 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"
// envRepo is the repository path under the registry that charts are
// pushed to. Helm appends the chart name, so this is a prefix shared by
// every fixture.
envRepo = "HELM_E2E_REPO"
// envIsolate appends a per-run suffix to the repository path. It is off by
// default so that the set of repositories a run touches is stable and can
// be created ahead of time on registries that do not create repositories
// on push.
envIsolate = "HELM_E2E_ISOLATE"
// envSingleRepo pushes every fixture into this one repository, directly
// under envRegistry, telling them apart by version rather than by chart
// name. It is for registries that do not nest repository paths (Docker
// Hub) and for credentials scoped to a single repository.
envSingleRepo = "HELM_E2E_SINGLE_REPO"
// 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
// repoBase is the repository path charts are pushed under.
repoBase string
// isolate appends runID to repoBase, or to each fixture's version in
// single-repository mode.
isolate bool
// singleRepo, when set, is the one repository every fixture is pushed to.
singleRepo string
// 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) != "",
repoBase: envOr(envRepo, "helm-e2e"),
isolate: os.Getenv(envIsolate) != "",
singleRepo: os.Getenv(envSingleRepo),
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
}
// Build by relative package path rather than module path, so this works
// on any branch regardless of the module's major version suffix.
bin := filepath.Join(t.TempDir(), "helm")
build := exec.Command("go", "build", "-o", bin, filepath.Join("..", "..", "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 the repository path charts are pushed to. Helm appends the
// chart name, so a run touches one repository per fixture.
//
// The path is stable by default. Re-pushing a fixture overwrites the same tag
// with byte-identical content, so concurrent runs do not interfere, the set of
// repositories stays bounded, and registries that require repositories to
// exist before a push (ECR) can have them created ahead of time. Set
// HELM_E2E_ISOLATE to give a run its own repositories instead.
//
// In single-repository mode this is the registry namespace itself: every
// fixture is renamed to the repository, so helm appending the chart name lands
// each push in that one repository.
func (h *harness) repo() string {
if h.singleRepo != "" {
return h.registry
}
if h.isolate {
return fmt.Sprintf("%s/%s-%s", h.registry, h.repoBase, h.runID)
}
return fmt.Sprintf("%s/%s", h.registry, h.repoBase)
}
// envOr returns the value of the named environment variable, or def when it is
// unset.
func envOr(name, def string) string {
if v := os.Getenv(name); v != "" {
return v
}
return def
}
// 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)
}
})
}
// fixture is a chart archive ready to push, along with the name and version
// helm will push it under.
type fixture struct {
path string
name string
version string
}
// fixture returns the named chart archive from this package's testdata, whose
// Chart.yaml declares chartName and version.
//
// In single-repository mode the chart is repackaged under the repository's
// name, with the original chart name folded into a prerelease version (for
// example 0.1.0-unicode-chart), so that every fixture can share one repository
// without overwriting another. Chart-name-specific coverage, such as hyphens
// in the name, does not apply in this mode.
func (h *harness) fixture(t *testing.T, file, chartName, version string) fixture {
t.Helper()
path := chart(t, file)
if h.singleRepo == "" {
return fixture{path: path, name: chartName, version: version}
}
c, err := loader.Load(path)
if err != nil {
t.Fatalf("loading chart %s: %v", path, err)
}
c.Metadata.Name = h.singleRepo
c.Metadata.Version = fmt.Sprintf("%s-%s", version, chartName)
if h.isolate {
// Prefixed so a hex run ID that happens to be all digits is never a
// numeric prerelease identifier, which semver forbids leading zeros in.
c.Metadata.Version += ".run" + h.runID
}
out, err := chartutil.Save(c, t.TempDir())
if err != nil {
t.Fatalf("repackaging chart %s as %s: %v", path, h.singleRepo, err)
}
return fixture{path: out, name: c.Metadata.Name, version: c.Metadata.Version}
}
// 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
}
+422
View File
@@ -0,0 +1,422 @@
//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 (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
"helm.sh/helm/v4/pkg/cli"
)
// 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()
contentCache := t.TempDir()
tests := []struct {
name string
chart string
chartName string
chartVersion string
pullArgs []string
untar bool
}{
{
name: "basic chart",
chart: "test-0.1.0.tgz",
chartName: "test",
chartVersion: "0.1.0",
},
{
name: "chart pulled with untar",
chart: "compressedchart-0.1.0.tgz",
chartName: "compressedchart",
chartVersion: "0.1.0",
pullArgs: []string{"--untar"},
untar: true,
},
{
name: "chart name with hyphens",
chart: "compressedchart-with-hyphens-0.1.0.tgz",
chartName: "compressedchart-with-hyphens",
chartVersion: "0.1.0",
},
{
name: "chart with unicode description",
chart: "unicode-chart-0.1.0.tgz",
chartName: "unicode-chart",
chartVersion: "0.1.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pullDir := t.TempDir()
f := h.fixture(t, tt.chart, tt.chartName, tt.chartVersion)
h.mustHelm(t, "push", f.path, "oci://"+repo)
pullArgs := append([]string{
"pull", h.ref(repo, f.name, f.version),
"--destination", pullDir,
"--content-cache", contentCache,
}, tt.pullArgs...)
h.mustHelm(t, pullArgs...)
// helm saves a pulled archive under its own file name, and
// --untar expands it into a directory named after the chart.
expectPullFile := filepath.Base(f.path)
if tt.untar {
expectPullFile = f.name
}
pulled := filepath.Join(pullDir, expectPullFile)
fi, err := os.Stat(pulled)
if err != nil {
t.Fatalf("expected pulled chart at %s: %v", pulled, err)
}
if fi.IsDir() != tt.untar {
t.Errorf("expected directory=%t, got directory=%t", tt.untar, fi.IsDir())
}
// Verify the round-trip actually returned the chart we pushed,
// not merely some artifact under the same tag.
assertChartMatches(t, f.path, pullDir, tt.untar)
})
}
}
// 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 {
// An anonymous registry, such as a default registry:2 deployment,
// accepts any credential. There is no authentication to exercise, so
// skip rather than report a failure the registry cannot produce.
t.Skipf("Skipping invalid credential test: %s accepts unauthenticated access", h.registryHost())
}
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)
f := h.fixture(t, "test-0.1.0.tgz", "test", "0.1.0")
out, err = h.helm(t, "push", f.path, "oci://"+h.repo())
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)
// A caller-supplied namespace is left alone; one we create for the run is
// removed again so repeated runs do not accumulate namespaces in the
// cluster.
namespace := os.Getenv(envNamespace)
if namespace == "" {
namespace = "helm-e2e-" + h.runID
t.Cleanup(func() { deleteNamespace(t, namespace) })
}
repo := h.repo()
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) {
f := h.fixture(t, tt.chart, tt.chartName, tt.chartVersion)
h.mustHelm(t, "push", f.path, "oci://"+repo)
// Scope the release name to this run. A caller-supplied namespace
// may already hold releases, and a fixed name could collide with
// one, which cleanup would then uninstall.
releaseName := fmt.Sprintf("%s-%s", tt.releaseName, h.runID)
t.Cleanup(func() {
if out, err := h.helm(t, "uninstall", releaseName, "--namespace", namespace, "--ignore-not-found", "--wait"); err != nil {
t.Errorf("uninstalling %s failed: %v\n%s", releaseName, err, out)
}
})
h.mustHelm(t, "install", releaseName, h.ref(repo, f.name, ""),
"--version", f.version,
"--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", releaseName, "--namespace", namespace, "--output", "json")
if !strings.Contains(out, `"status":"deployed"`) {
t.Errorf("expected release %s to be deployed, got:\n%s", releaseName, out)
}
})
}
}
// assertChartMatches verifies that what was pulled into pullDir is the same
// chart as the fixture at src. Existence and file-vs-directory checks alone
// would pass if the registry served a different artifact under the same tag.
//
// A chart pulled without --untar is the pushed archive verbatim, so it is
// compared byte for byte. With --untar, helm expands the archive, so every
// regular file in the source archive is compared against its extracted
// counterpart.
func assertChartMatches(t *testing.T, src, pullDir string, untarred bool) {
t.Helper()
if !untarred {
assertFilesEqual(t, src, filepath.Join(pullDir, filepath.Base(src)))
return
}
want := archiveFiles(t, src)
if len(want) == 0 {
t.Fatalf("fixture %s contains no files", src)
}
for name, content := range want {
extracted := filepath.Join(pullDir, filepath.FromSlash(name))
got, err := os.ReadFile(extracted)
if err != nil {
t.Errorf("expected extracted file %s: %v", extracted, err)
continue
}
if !bytes.Equal(got, content) {
t.Errorf("extracted file %s does not match the pushed chart", name)
}
}
}
// assertFilesEqual compares two files byte for byte.
func assertFilesEqual(t *testing.T, want, got string) {
t.Helper()
wantBytes, err := os.ReadFile(want)
if err != nil {
t.Fatalf("reading %s: %v", want, err)
}
gotBytes, err := os.ReadFile(got)
if err != nil {
t.Fatalf("reading %s: %v", got, err)
}
if !bytes.Equal(wantBytes, gotBytes) {
t.Errorf("pulled chart %s does not match the pushed chart %s (%d vs %d bytes)",
got, want, len(gotBytes), len(wantBytes))
}
}
// archiveFiles returns the regular files in a gzipped tar archive, keyed by
// their slash-separated path within the archive.
func archiveFiles(t *testing.T, path string) map[string][]byte {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("opening %s: %v", path, err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatalf("reading %s as gzip: %v", path, err)
}
defer gz.Close()
files := map[string][]byte{}
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("reading %s as tar: %v", path, err)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
content, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("reading %s from %s: %v", hdr.Name, path, err)
}
files[hdr.Name] = content
}
return files
}
// deleteNamespace removes a namespace created by the test run. It is best
// effort in the sense that it reports a failure rather than aborting the test,
// but it does not silently leave the namespace behind.
func deleteNamespace(t *testing.T, namespace string) {
t.Helper()
// Resolve the cluster exactly the way the helm subprocesses do. They read
// their Kubernetes configuration from the environment (HELM_KUBECONTEXT,
// HELM_KUBEAPISERVER, HELM_KUBETOKEN, HELM_KUBECAFILE and friends), so
// building this client any other way risks installing releases into one
// cluster and sending the namespace delete to another. Such a delete
// returns NotFound, which would look like success while leaking the
// namespace. Going through helm's own settings keeps the two in step
// without having to mirror each variable here.
cfg, err := cli.New().RESTClientGetter().ToRESTConfig()
if err != nil {
t.Errorf("building kube client config to delete namespace %s: %v", namespace, err)
return
}
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
t.Errorf("building kube client to delete namespace %s: %v", namespace, err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
t.Logf("deleting namespace %s", namespace)
if err := client.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil {
if apierrors.IsNotFound(err) {
return
}
t.Errorf("deleting namespace %s: %v", namespace, err)
return
}
// Deletion is asynchronous. Wait for the namespace to actually go away so
// that a namespace wedged in Terminating is reported rather than quietly
// accumulating in the cluster.
err = wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) {
_, err := client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return true, nil
}
return false, err
})
if err != nil {
t.Errorf("waiting for namespace %s to be deleted: %v", namespace, err)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.