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:
1 parent
d2ff87dcce
commit
bc047f2792
14 files changed
+1090
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
@@ -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 日本語 中文 한글
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default values for unicode-chart
|
||||
replicaCount: 1
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user