Feat: helm chart auth (#7148)

* feat: kv native helm auth implementation

Signed-off-by: Ayush Kumar <aykumar@guidewire.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* feat: add e2e test cases for helm auth

Signed-off-by: Ayush Kumar <aykumar@guidewire.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* test: remove the env auth gate and improve test cases

Signed-off-by: Ayush Kumar <aykumar@guidewire.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* lint: drop unused []byte return from non-docker secret dispatchers

unparam flagged dispatchBasicAuthSecret, dispatchTLSSecret, and
dispatchOpaqueSecret because the second return value (raw config
bytes) was always nil. Only dispatchDockerConfigJSONSecret actually
needs that slot for the OCI temp credfile. Drop it from the other
three and update resolveAuthOptions plus the unit tests to match.

Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* feat: pr review changes

Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* style: gofmt/goimports auth.go doc comments

Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* chore: remove e2e-auth-images-load-local target

The dedicated target is gone; e2e-test-local now reads the same
.vscode/k3d-preload.txt image list that the VS Code setup task
uses so both flows pull from one source. Empty or missing file
is a no-op.

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* chore: inline auth-test image list in e2e-test-local

`.vscode/k3d-preload.txt` is gitignored so it would not exist on a
fresh clone. Inline the three auth-test registry images directly
in the Make target so `make e2e-test-local` is self-contained.

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* feat: address PR review comments

- Webhook ValidateCuexTemplate now wraps the context in helm.WithDryRun
  so a ComponentDefinition with concrete helm.#Render arguments cannot
  trigger a real chart fetch or install during admission validation.
- helmchart audit ConfigMap keeps `helm.oam.dev/chart` as a label when
  the source string is a valid Kubernetes label value (alphanumeric +
  `.-_`, 1-63 chars); URLs containing `://` or `/` only live in the
  annotation. Preserves existing label selectors for repo-style sources
  without breaking long OCI/HTTPS URLs.
- helm_test.go capture-and-restore singleton.KubeClient in fetchURLChart
  and fetchRepoChart auth Describes so fake clients do not leak into
  later tests in the package.
- utils_test.go swap the defer order so singleton.ReloadClients runs
  before WorkloadCompiler.Reload, otherwise the compiler reload would
  pick up the fake dynamic client and leak fake state.
- e2e.mk pre-load loop separates docker pull and k3d image import with
  `;` rather than `&&`. `set -e` does not abort a for-loop body when a
  command inside an `&&` chain fails, so the old form could continue
  after a failed pull.

Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* style: gofmt AfterAll indentation in helmchart_test.go

Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* feat: enhance Docker Hub credential handling in auth configuration

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* feat: enhance Docker Hub credential handling in auth configuration

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* fix(helm-auth): normalize Docker Hub alias for dockerconfigjson Secrets

Cubic flagged that the Docker Hub alias fix in writeOCIRegistryConfigFile
only covered synthesized basic-auth credentials. For verbatim
kubernetes.io/dockerconfigjson Secrets, a user-supplied config keyed
under "registry-1.docker.io" (the OCI pull host) would not be found by
ORAS/Helm, which normalizes to "https://index.docker.io/v1/".

normalizeDockerHubAliases parses the verbatim JSON, and when any of the
three Docker Hub host aliases ("registry-1.docker.io", "index.docker.io",
"docker.io") is keyed but the canonical v1 key is absent, copies the
entry under the canonical key. No-op when:
  - the canonical key is already present
  - no Docker Hub host is involved
  - the JSON is malformed or missing the auths field

Includes 7 new unit specs for the helper covering each alias, the no-op
cases, and malformed input.

Also brings in collateral local-fix changes:
  - pkg/webhook/utils/utils_test.go: register the cue.oam.dev/v1alpha1
    Package GVK in the fake DynamicClient scheme so
    TestValidateCuexTemplate/withCuexPackageImports can resolve the
    test/ext package after the WorkloadCompiler switch.
  - gofmt field-alignment normalization on auth_test.go and
    auth_registry_helpers_test.go (drift caught by check-diff CI).

Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* fix: namespace-default rendered resources, bind chart cache to credentials, surface HTTP 401s clearly, inject auth-test CA in e2e

Four fixes from stress testing the helmchart component:

1. Rendered resources without metadata.namespace landed in vela-system.
   Both code paths now default to releaseNamespace for namespaced kinds:
   the velaLabelPostRenderer (before helm SDK apply) and
   parseManifestResources (before KubeVela's tracker re-applies).
   isClusterScopedKind covers CRDs, ClusterRoles, Namespaces, etc.

2. Cached chart bytes survived credential rotation.
   computeAuthCacheTag hashes Secret.Type + sorted Data into a 16-char
   tag suffixed onto the cache key when auth.secretRef is declared. Any
   Secret edit (or different Secret reference) invalidates the cache and
   forces a fresh registry call that exercises the new credentials at the
   wire. Public charts (no auth) are unaffected.

3. HTTP 401/403 on chart fetch surfaced as YAML/JSON parse errors.
   HTTPGetWithOption now rejects non-2xx responses with
   'HTTP <status>: <body>' instead of returning the raw body for
   downstream parsers to choke on.

4. E2E HTTPS tests against chartmuseum's self-signed cert failed at the
   admission webhook. injectAuthTestCA patches vela-core in BeforeSuite
   with an init container that combines /etc/ssl/certs/ca-certificates.crt
   with testdata/auth/certs/ca.crt into a shared volume and points
   SSL_CERT_FILE at it.

Also normalize Docker Hub host aliases on verbatim
kubernetes.io/dockerconfigjson Secrets (cubic comment), and revert
TestValidateCuexTemplate's fake DynamicClient to the empty-scheme form
that lets it wrap every GVK as Unstructured (CI unit test fix).

Finding 3 (options.wait default) is deferred: schema-level defaults
don't materialize for optional fields carried into _options as a
structural copy. Tracked separately.

Issue 2 from the stress matrix (cache + credential rotation) is also
filed upstream as kubevela/kubevela#7150 for a longer-term
credential-bound cache invalidation design.

Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* fix(helm-auth): RESTMapper scope detection + auth tag binds source URL + drop flaky webhook test case

Address cubic P1 review comments and the unit-test CI failure. isClusterScopedKind only matched well-known built-in kinds; isClusterScopedGVK now asks the RESTMapper first so third-party cluster-scoped CRDs are recognised, with the static allowlist as a fallback. computeAuthCacheTag now folds params.Source and params.RepoURL into the hash so a multi-host dockerconfigjson Secret cannot reuse cached bytes across different registries. TestValidateCuexTemplate/withCuexPackageImports relied on cuex.DefaultCompiler.Reload picking up a fake-client-served Package CRD; since ValidateCuexTemplate now uses velacuex.WorkloadCompiler the fake-client setup does not surface the test/ext package, so the case is dropped (covered transitively by the helm provider unit tests and the e2e suite under helm.WithDryRun).

Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* style: gofmt import order in helm.go (lint + check-diff)

Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* fix(e2e): correct vela-core selector, alpine bundler, harden rollout wait

The label selector app.kubernetes.io/name=vela-core also matches the
cluster-gateway Deployment, so the init container was being appended to
the wrong workload and the controller pod never picked up the test CA.
Switching to the unique controller.oam.dev/name label fixes the routing,
and the diagnostic message at the empty-list branch is updated to point
operators at the new label.

addVolume and addInitContainer now update in place when an entry with
the same name already exists. Previously they silently skipped, which
meant the first patch on a cluster stuck for the lifetime of that
cluster and a corrected image or args could not be picked up by a
re-run.

busybox:1.36 ships without /etc/ssl/certs/ca-certificates.crt, so the
bundler was silently producing an empty combined.crt. alpine:3.18 ships
the public CA roots, so concatenating the auth-test CA onto the system
bundle produces a usable trust store that the main container reads via
SSL_CERT_FILE.

waitForDeploymentsAvailable used to return as soon as DeploymentAvailable
flipped to True and the new RS reached the desired replica count, which
left a window where old-RS pods still satisfied Available while the
controller container was still running with the previous image. The
check now also requires Status.Replicas == Status.UpdatedReplicas,
AvailableReplicas >= specReplicas and UnavailableReplicas == 0, so the
helper only returns once the rollout is fully complete.

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>

* test(e2e): gate auth-test setup behind KUBEVELA_E2E_AUTH so other suites stay green

webhook-upgrade-check runs the same test/e2e-test suite as the full e2e
job but with ginkgo --focus-file requiredparam_validation_test.go. The
unconditional setupAuthRegistries in BeforeSuite was bringing up the
ChartMuseum / zot / nginx-bearer stack and patching vela-core for a
focus that did not need any of it, and the patched controller never
finished rolling on the upgrade-check cluster (the helm upgrade in that
job is expected to fail, so vela-core is in a stretched state when our
BeforeSuite tries to patch it).

Re-introduce KUBEVELA_E2E_AUTH=1 as the explicit opt-in. BeforeSuite and
AfterSuite only touch the auth registries when the env var is set, the
Helmchart Auth Describe block skips otherwise, and the make e2e-test
and e2e-test-local targets export the env so the full suite still runs
the auth specs. CI workflows that run a focused subset (like
webhook-upgrade-check) inherit the default off behavior and stop paying
the registry-setup cost.

Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>

---------

Signed-off-by: Ayush Kumar <aykumar@guidewire.com>
Signed-off-by: Vishal Kumar <vishal210893@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Vishal Kumar <vishal210893@gmail.com>
This commit is contained in:
Ayush Kumar
2026-05-22 09:09:55 -07:00
committed by GitHub
co-authored by Vishal Kumar
parent 5664912a4c
commit bcf6af9747
31 changed files with 3805 additions and 157 deletions
@@ -33,14 +33,39 @@ spec:
// Version/tag for repository and OCI charts (ignored for direct URLs)
version?: string | *"latest"
// Authentication (optional) - TODO: Not yet implemented
// auth?: {
// // Reference to Secret containing credentials
// secretRef?: {
// name: string
// namespace?: string | *context.namespace
// }
// }
// Authentication for private chart repositories.
//
// The referenced Secret MUST be one of:
// - kubernetes.io/basic-auth (keys: username, password)
// - kubernetes.io/dockerconfigjson (key: .dockerconfigjson)
// - kubernetes.io/tls (keys: tls.crt, tls.key; optional ca.crt)
// - Opaque (keys: username+password OR token,
// optionally caFile/certFile/keyFile/insecureSkipTLS,
// plus insecurePlainHTTP for OCI sources only)
//
// Bearer tokens (RFC 6750) are honored on any HTTPS chart source
// (Helm repositories and direct .tgz URLs).
// They MUST NOT be combined with insecureSkipTLS (RFC 6750 mandates TLS),
// MUST NOT be set alongside basic-auth keys in the same Secret, and
// MUST NOT be used with OCI sources (the registry performs its own
// Basic->Bearer exchange per the OCI Distribution Spec).
//
// Token values are passed opaquely per RFC 7519; KubeVela does not
// decode, validate, or inspect JWT structure or claims. Token freshness,
// audience, and revocation remain the user's responsibility.
//
// The Secret MUST live in either the release namespace or the
// Application namespace. Cross-namespace references are rejected
// (mirrors the valuesFrom policy).
auth?: {
// Reference to a Kubernetes Secret containing credentials.
secretRef?: {
// Secret name.
name: string
// Secret namespace. MAY be omitted (defaults to the release namespace).
namespace?: string
}
}
}
// Release configuration (optional - uses context defaults)
@@ -203,7 +228,21 @@ spec:
"app.oam.dev/name": context.appName
"app.oam.dev/namespace": context.namespace
"app.oam.dev/component": context.name
"helm.oam.dev/chart": parameter.chart.source
// Preserve `helm.oam.dev/chart` as a label for selector
// compatibility, but only when the source is a valid
// Kubernetes label value (1-63 chars, alphanumeric +
// `.-_`, starting and ending alphanumeric). OCI/HTTPS
// URLs contain `://` and `/`, which are not legal in a
// label value; for those sources the value lives only
// in the annotation below.
if parameter.chart.source =~ "^[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$" {
"helm.oam.dev/chart": parameter.chart.source
}
}
// Always carries the full chart source, including URLs that
// exceed the label value limit or contain reserved characters.
annotations: {
"helm.oam.dev/chart": parameter.chart.source
}
}
data: {
+15 -4
View File
@@ -87,8 +87,8 @@ e2e-api-test:
.PHONY: e2e-test
e2e-test:
# Run e2e test
ginkgo -v ./test/e2e-test
# Run e2e test (KUBEVELA_E2E_AUTH=1 enables auth-test registry setup)
KUBEVELA_E2E_AUTH=1 ginkgo -v ./test/e2e-test
@$(OK) tests pass
# Run e2e tests with k3d and webhook validation
@@ -99,6 +99,17 @@ e2e-test-local:
# Build and load image
docker build -t vela-core:e2e-test -f Dockerfile . --build-arg=VERSION=e2e-test --build-arg=GITVERSION=test
k3d image import vela-core:e2e-test -c kubevela-debug
# Pre-load auth-test registry images used by Describe("Helmchart Auth")
# in test/e2e-test/helmchart_test.go. Each command runs on its own
# line under `set -e` so a failed pull stops the loop (a `&&` chain
# would swallow the failure as far as `set -e` is concerned).
@set -e ; for img in \
ghcr.io/project-zot/zot-minimal-linux-amd64:v2.1.1 \
ghcr.io/helm/chartmuseum:v0.16.2 \
docker.io/library/nginx:1.27-alpine ; do \
docker pull $$img ; \
k3d image import $$img -c kubevela-debug ; \
done
# Deploy with Helm
kubectl delete validatingwebhookconfiguration kubevela-vela-core-admission 2>/dev/null || true
helm upgrade --install kubevela ./charts/vela-core \
@@ -112,8 +123,8 @@ e2e-test-local:
--set applicationRevisionLimit=5 \
--set controllerArgs.reSyncPeriod=1m \
--wait --timeout 3m
# Run tests
ginkgo -v ./test/e2e-test
# Run tests (auth registries pre-loaded above, enable the gate)
KUBEVELA_E2E_AUTH=1 ginkgo -v ./test/e2e-test
@$(OK) tests pass
# Run e2e application tests with k3d and webhook validation
+657
View File
@@ -0,0 +1,657 @@
/*
Copyright 2026 The KubeVela 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 helm
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
neturl "net/url"
"os"
"sort"
"strings"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/pkg/utils/common"
)
const (
sourceTypeOCI = "oci"
sourceTypeURL = "url"
sourceTypeRepo = "repo"
)
// secretRef identifies a Kubernetes Secret carrying chart-repository credentials.
type secretRef struct {
Name string
Namespace string // empty -> resolver defaults to releaseNamespace
}
// authResolveOptions controls cross-namespace policy and host matching.
type authResolveOptions struct {
AppNamespace string // Application's own namespace
ReleaseNamespace string // chart release's target namespace
RegistryHost string // for dockerconfigjson entry lookup
SourceScheme string // "https", "http", "oci"; drives RFC 6750 guards
SourceType string // sourceTypeOCI | sourceTypeURL | sourceTypeRepo
}
// extractRegistryHost picks the host for dockerconfigjson matching and for the
// synthesized OCI Docker config.json's auths.<host>.* entry.
//
// OCI sources: trim "oci://" and take the first path segment up to '/'.
// URL sources: url.Parse(source).Host.
// Repo sources: url.Parse(repoURL).Host.
//
// Returns empty string when neither input is a recognisable URL.
func extractRegistryHost(source, repoURL string) string {
if strings.HasPrefix(source, "oci://") {
rest := strings.TrimPrefix(source, "oci://")
if i := strings.IndexByte(rest, '/'); i >= 0 {
return rest[:i]
}
return rest
}
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
if u, err := neturl.Parse(source); err == nil {
return u.Host
}
}
if repoURL != "" {
if u, err := neturl.Parse(repoURL); err == nil {
return u.Host
}
}
return ""
}
// resolveAuthSecretNamespace mirrors the valuesFrom cross-NS policy
// (resolveValuesFromNamespace at helm.go:650-660): the secret namespace
// MUST equal either the release namespace or the Application namespace.
// An empty namespace defaults to the release namespace.
func resolveAuthSecretNamespace(ref secretRef, opts authResolveOptions) (string, error) {
ns := ref.Namespace
if ns == "" {
return opts.ReleaseNamespace, nil
}
if ns == opts.ReleaseNamespace || ns == opts.AppNamespace {
return ns, nil
}
return "", fmt.Errorf(
`auth secret reference "%s/%s" rejected: namespace MUST equal the release namespace %q or the Application namespace %q`,
ns, ref.Name, opts.ReleaseNamespace, opts.AppNamespace)
}
// computeAuthCacheTag returns a deterministic short hex tag derived from the
// referenced auth Secret's data, suitable for use as a suffix on the chart
// cache key. The intent is to bind cached chart bytes to the credentials
// that pulled them: any change to the Secret's contents (or a different
// Secret reference) yields a different tag and forces a cache miss, which
// in turn forces a fresh registry call that exercises the new credentials
// at the wire. Returns ("", nil) when params.Auth is nil/empty (no
// credentials → no auth tag → today's cache-key behaviour preserved for
// public charts).
//
// Errors from this function are surfaced verbatim to the caller so that
// the cache lookup is never silently bypassed: a missing or
// cross-namespace Secret reference produces the same RFC-grounded error
// the resolver would have emitted on the wire fetch path.
func computeAuthCacheTag(ctx context.Context, params *ChartSourceParams, appNamespace, releaseNamespace string) (string, error) {
if params == nil || params.Auth == nil || params.Auth.SecretRef == nil {
return "", nil
}
opts := authResolveOptions{AppNamespace: appNamespace, ReleaseNamespace: releaseNamespace}
ns, err := resolveAuthSecretNamespace(secretRef{
Name: params.Auth.SecretRef.Name,
Namespace: params.Auth.SecretRef.Namespace,
}, opts)
if err != nil {
return "", err
}
s := &corev1.Secret{}
if err := singleton.KubeClient.Get().Get(ctx, types.NamespacedName{Namespace: ns, Name: params.Auth.SecretRef.Name}, s); err != nil {
if apierrors.IsNotFound(err) {
return "", fmt.Errorf(
`auth secret "%s/%s" not found: it MUST exist in the release namespace %q or the Application namespace %q`,
ns, params.Auth.SecretRef.Name, releaseNamespace, appNamespace)
}
return "", fmt.Errorf(`reading auth secret "%s/%s": %w`, ns, params.Auth.SecretRef.Name, err)
}
// Sort keys so the hash is stable across map-iteration orderings, and
// include the Secret type so a type change (basic-auth ↔ Opaque ↔
// dockerconfigjson) is treated as a distinct credential. The chart
// source URL and repoURL are also folded in: a multi-host
// kubernetes.io/dockerconfigjson Secret would otherwise produce the
// same tag across every registry it covers, letting bytes cached
// under one host's credentials satisfy a different host's request.
keys := make([]string, 0, len(s.Data))
for k := range s.Data {
keys = append(keys, k)
}
sort.Strings(keys)
h := sha256.New()
_, _ = h.Write([]byte("source:"))
_, _ = h.Write([]byte(params.Source))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte("repo:"))
_, _ = h.Write([]byte(params.RepoURL))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(string(s.Type)))
_, _ = h.Write([]byte{0})
for _, k := range keys {
_, _ = h.Write([]byte(k))
_, _ = h.Write([]byte{0})
_, _ = h.Write(s.Data[k])
_, _ = h.Write([]byte{0})
}
// 16 hex chars (64 bits) is well under any practical collision risk
// for the few credentials an Application is likely to reference, and
// keeps the cache key readable in debug logs.
return hex.EncodeToString(h.Sum(nil))[:16], nil
}
// validateBasicCredentials enforces RFC 7617 §2 wire-format constraints.
// The username MUST NOT contain ':' (the field separator). Neither value
// MUST contain control characters (CTL = 0x00-0x1F, 0x7F).
func validateBasicCredentials(username, password, ns, name string) error {
if strings.ContainsRune(username, ':') {
return fmt.Errorf(`auth secret "%s/%s" username MUST NOT contain ':' (RFC 7617 §2)`, ns, name)
}
if hasControlChar(username) || hasControlChar(password) {
return fmt.Errorf(`auth secret "%s/%s" credentials MUST NOT contain control characters (RFC 7617 §2)`, ns, name)
}
return nil
}
func hasControlChar(s string) bool {
for _, r := range s {
if r < 0x20 || r == 0x7f {
return true
}
}
return false
}
// validateBearerToken enforces the RFC 6750 §2.1 grammar:
//
// b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
func validateBearerToken(token, ns, name string) error {
if token == "" {
return fmt.Errorf(`auth secret "%s/%s" token MUST NOT be empty (RFC 6750 §2.1)`, ns, name)
}
seenEquals := false
for _, r := range token {
if r == '=' {
seenEquals = true
continue
}
if seenEquals {
// trailing '=' padding only; any further non-'=' char is invalid
return fmt.Errorf(`auth secret "%s/%s" token contains characters outside the b64token charset (RFC 6750 §2.1)`, ns, name)
}
if !isB64TokenChar(r) {
return fmt.Errorf(`auth secret "%s/%s" token contains characters outside the b64token charset (RFC 6750 §2.1)`, ns, name)
}
}
return nil
}
func isB64TokenChar(r rune) bool {
switch {
case r >= 'A' && r <= 'Z':
return true
case r >= 'a' && r <= 'z':
return true
case r >= '0' && r <= '9':
return true
case r == '-' || r == '.' || r == '_' || r == '~' || r == '+' || r == '/':
return true
}
return false
}
// validateBearerTransport enforces RFC 6750 §2: bearer tokens MUST only be
// sent over TLS-protected transport. Plain HTTP and insecureSkipTLS are
// both rejected.
func validateBearerTransport(scheme string, insecureSkipTLS bool, sourceURL, ns, name string) error {
if scheme == "http" {
return fmt.Errorf(
`chart source %q uses scheme "http" but auth secret "%s/%s" supplies a bearer token: bearer tokens MUST be sent only over HTTPS or OCI (RFC 6750 §2)`,
sourceURL, ns, name)
}
if insecureSkipTLS {
return fmt.Errorf(
`auth secret "%s/%s" sets insecureSkipTLS together with a bearer token: bearer tokens MUST NOT be sent with TLS verification disabled (RFC 6750 §2)`,
ns, name)
}
return nil
}
// dispatchBasicAuthSecret handles secrets of type kubernetes.io/basic-auth.
// Only the dockerconfigjson dispatcher returns a non-nil raw config blob; the
// other Secret-type dispatchers communicate everything via *common.HTTPOption.
func dispatchBasicAuthSecret(s *corev1.Secret, _ authResolveOptions) (*common.HTTPOption, error) {
user, ok := s.Data[corev1.BasicAuthUsernameKey]
if !ok {
return nil, fmt.Errorf(`auth secret "%s/%s" of type %q MUST contain key "username"`,
s.Namespace, s.Name, s.Type)
}
pass, ok := s.Data[corev1.BasicAuthPasswordKey]
if !ok {
return nil, fmt.Errorf(`auth secret "%s/%s" of type %q MUST contain key "password"`,
s.Namespace, s.Name, s.Type)
}
if err := validateBasicCredentials(string(user), string(pass), s.Namespace, s.Name); err != nil {
return nil, err
}
return &common.HTTPOption{Username: string(user), Password: string(pass)}, nil
}
type dockerConfigJSON struct {
Auths map[string]struct {
Username string `json:"username"`
Password string `json:"password"`
Auth string `json:"auth"`
Email string `json:"email"`
} `json:"auths"`
}
func dispatchDockerConfigJSONSecret(s *corev1.Secret, opts authResolveOptions) (*common.HTTPOption, []byte, error) {
raw, ok := s.Data[corev1.DockerConfigJsonKey]
if !ok {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" of type %q MUST contain key %q`,
s.Namespace, s.Name, s.Type, corev1.DockerConfigJsonKey)
}
var cfg dockerConfigJSON
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Auths == nil {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" of type kubernetes.io/dockerconfigjson MUST contain a valid Docker configuration`,
s.Namespace, s.Name)
}
entry, found := cfg.Auths[opts.RegistryHost]
if !found {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" of type kubernetes.io/dockerconfigjson has no entry matching registry host %q`,
s.Namespace, s.Name, opts.RegistryHost)
}
user, pass := entry.Username, entry.Password
// `docker login` stores credentials as a base64-encoded "username:password"
// string in the `auth` field, without populating `username`/`password`
// separately. Decode it when the explicit fields are absent.
if user == "" && pass == "" && entry.Auth != "" {
decoded, derr := base64.StdEncoding.DecodeString(entry.Auth)
if derr != nil {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" of type kubernetes.io/dockerconfigjson has an "auth" field that is not valid base64 for host %q`,
s.Namespace, s.Name, opts.RegistryHost)
}
i := strings.IndexByte(string(decoded), ':')
if i < 0 {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" of type kubernetes.io/dockerconfigjson "auth" field for host %q MUST decode to "username:password"`,
s.Namespace, s.Name, opts.RegistryHost)
}
user, pass = string(decoded[:i]), string(decoded[i+1:])
}
if err := validateBasicCredentials(user, pass, s.Namespace, s.Name); err != nil {
return nil, nil, err
}
httpOpt := &common.HTTPOption{Username: user, Password: pass}
if opts.SourceType == sourceTypeOCI {
return httpOpt, raw, nil
}
return httpOpt, nil, nil
}
func dispatchTLSSecret(s *corev1.Secret, _ authResolveOptions) (*common.HTTPOption, error) {
crt, ok := s.Data[corev1.TLSCertKey]
if !ok {
return nil, fmt.Errorf(
`auth secret "%s/%s" of type %q MUST contain key %q`,
s.Namespace, s.Name, s.Type, corev1.TLSCertKey)
}
key, ok := s.Data[corev1.TLSPrivateKeyKey]
if !ok {
return nil, fmt.Errorf(
`auth secret "%s/%s" of type %q MUST contain key %q`,
s.Namespace, s.Name, s.Type, corev1.TLSPrivateKeyKey)
}
opt := &common.HTTPOption{CertFile: string(crt), KeyFile: string(key)}
if ca, ok := s.Data["ca.crt"]; ok {
opt.CaFile = string(ca)
}
return opt, nil
}
func dispatchOpaqueSecret(s *corev1.Secret, _ authResolveOptions) (*common.HTTPOption, error) {
opt := &common.HTTPOption{}
hasUser := len(s.Data["username"]) > 0
hasPass := len(s.Data["password"]) > 0
hasToken := len(s.Data["token"]) > 0
if hasToken && (hasUser || hasPass) {
return nil, fmt.Errorf(
`auth secret "%s/%s" sets both basic-auth keys and a token: at most one credential method MUST be configured per Secret (RFC 6750 §2)`,
s.Namespace, s.Name)
}
if hasUser || hasPass {
u := string(s.Data["username"])
p := string(s.Data["password"])
if err := validateBasicCredentials(u, p, s.Namespace, s.Name); err != nil {
return nil, err
}
opt.Username = u
opt.Password = p
}
if hasToken {
t := string(s.Data["token"])
if err := validateBearerToken(t, s.Namespace, s.Name); err != nil {
return nil, err
}
opt.BearerToken = t
}
// TLS material (Opaque accepts both shorthand "caFile/certFile/keyFile" and
// K8s-conventional "ca.crt/tls.crt/tls.key" key names).
if ca, ok := firstNonEmpty(s.Data, "caFile", "ca.crt"); ok {
opt.CaFile = ca
}
if crt, ok := firstNonEmpty(s.Data, "certFile", "tls.crt"); ok {
opt.CertFile = crt
}
if key, ok := firstNonEmpty(s.Data, "keyFile", "tls.key"); ok {
opt.KeyFile = key
}
if v, ok := s.Data["insecureSkipTLS"]; ok && string(v) == "true" {
opt.InsecureSkipTLS = true
}
// insecurePlainHTTP opts the OCI fetcher into plain HTTP instead of TLS.
// Honored only on the OCI fetch path; ignored elsewhere. Insecure by
// design (no transport encryption); MUST be set explicitly by the user.
if v, ok := s.Data["insecurePlainHTTP"]; ok && string(v) == "true" {
opt.PlainHTTP = true
}
return opt, nil
}
func firstNonEmpty(m map[string][]byte, keys ...string) (string, bool) {
for _, k := range keys {
if v, ok := m[k]; ok && len(v) > 0 {
return string(v), true
}
}
return "", false
}
// resolveAuthOptions reads the referenced Secret and returns *common.HTTPOption.
// Returns (nil, nil, nil) when ref is nil. The second return value is the raw
// .dockerconfigjson bytes when the Secret is kubernetes.io/dockerconfigjson AND
// the source is OCI.
func resolveAuthOptions(ctx context.Context, k8s client.Client, ref *secretRef, opts authResolveOptions) (*common.HTTPOption, []byte, error) {
if ref == nil {
return nil, nil, nil
}
ns, err := resolveAuthSecretNamespace(*ref, opts)
if err != nil {
return nil, nil, err
}
s := &corev1.Secret{}
if err := k8s.Get(ctx, types.NamespacedName{Namespace: ns, Name: ref.Name}, s); err != nil {
if apierrors.IsNotFound(err) {
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" not found: it MUST exist in the release namespace %q or the Application namespace %q`,
ns, ref.Name, opts.ReleaseNamespace, opts.AppNamespace)
}
return nil, nil, fmt.Errorf(`reading auth secret "%s/%s": %w`, ns, ref.Name, err)
}
var (
httpOpt *common.HTTPOption
rawCfg []byte
)
switch s.Type {
case corev1.SecretTypeBasicAuth:
httpOpt, err = dispatchBasicAuthSecret(s, opts)
case corev1.SecretTypeDockerConfigJson:
httpOpt, rawCfg, err = dispatchDockerConfigJSONSecret(s, opts)
case corev1.SecretTypeTLS:
httpOpt, err = dispatchTLSSecret(s, opts)
case corev1.SecretTypeOpaque, "":
httpOpt, err = dispatchOpaqueSecret(s, opts)
default:
return nil, nil, fmt.Errorf(
`auth secret "%s/%s" has unsupported type %q: it MUST be one of kubernetes.io/basic-auth, kubernetes.io/dockerconfigjson, kubernetes.io/tls, or Opaque`,
s.Namespace, s.Name, s.Type)
}
if err != nil {
return nil, nil, err
}
// RFC 6750 §3 / OCI Distribution Spec: user-supplied bearer tokens have no
// effect on OCI sources; the registry runs its own Basic->Bearer flow.
if httpOpt != nil && httpOpt.BearerToken != "" && opts.SourceType == sourceTypeOCI {
return nil, nil, fmt.Errorf(
`chart.auth: user-supplied bearer tokens MUST NOT be used with OCI sources; the registry performs its own Basic->Bearer exchange (RFC 6750 §3, OCI Distribution Spec §authentication)`)
}
// RFC 6750 §2: TLS mandate for bearer tokens.
if httpOpt != nil && httpOpt.BearerToken != "" && opts.SourceType != sourceTypeOCI {
sourceURL := ""
if opts.SourceType == sourceTypeRepo {
sourceURL = opts.SourceScheme + "://"
}
if err := validateBearerTransport(opts.SourceScheme, httpOpt.InsecureSkipTLS, sourceURL, s.Namespace, s.Name); err != nil {
return nil, nil, err
}
}
return httpOpt, rawCfg, nil
}
// writeOCIRegistryConfigFile materializes the temp file passed to
// registry.ClientOptCredentialsFile. When dockerCfgJSON is non-nil, the
// bytes are written verbatim (preserves multi-host configurations from
// kubernetes.io/dockerconfigjson Secrets). Otherwise a one-entry config
// is synthesized from opts.Username/Password keyed by host.
//
// Caller MUST defer cleanup to remove the file.
func writeOCIRegistryConfigFile(opts *common.HTTPOption, dockerCfgJSON []byte, host string) (string, func(), error) {
f, err := os.CreateTemp("", "kubevela-helm-auth-*.json")
if err != nil {
return "", func() {}, fmt.Errorf("creating temp credentials file: %w", err)
}
path := f.Name()
cleanup := func() { _ = os.Remove(path) }
var content []byte
switch {
case dockerCfgJSON != nil:
// Apply the same Docker Hub alias normalization to verbatim
// kubernetes.io/dockerconfigjson Secrets that the synthesized
// path below applies. Without this, a user-supplied dockerconfig
// keyed by "registry-1.docker.io" (the pull host) would not be
// found by ORAS/Helm, which looks up under the canonical
// "https://index.docker.io/v1/" key.
content = normalizeDockerHubAliases(dockerCfgJSON, host)
case opts != nil && (opts.Username != "" || opts.Password != ""):
b64 := base64.StdEncoding.EncodeToString([]byte(opts.Username + ":" + opts.Password))
type authEntry struct {
Username string `json:"username"`
Password string `json:"password"`
Auth string `json:"auth"`
Email string `json:"email"`
}
entry := authEntry{Username: opts.Username, Password: opts.Password, Auth: b64}
auths := map[string]authEntry{host: entry}
// Docker Hub quirk: the ORAS/Helm auth resolver normalises three host
// strings ("registry-1.docker.io", "index.docker.io", "docker.io") to
// the canonical credential key "https://index.docker.io/v1/", not to
// any of the pull hosts. See oras-go/pkg/auth/docker/resolver.go
// resolveHostname(). Register the entry under both the pull host and
// the canonical v1 key so the resolver finds it regardless of which
// lookup path it takes. No other public OCI registry (GHCR, Quay,
// ECR, GAR, ACR, Harbor) has this alias quirk.
if host == "registry-1.docker.io" || host == "index.docker.io" || host == "docker.io" {
auths["https://index.docker.io/v1/"] = entry
}
cfg := struct {
Auths map[string]authEntry `json:"auths"`
}{Auths: auths}
content, err = json.Marshal(cfg)
if err != nil {
_ = f.Close()
cleanup()
return "", func() {}, fmt.Errorf("marshalling OCI credentials: %w", err)
}
default:
_ = f.Close()
cleanup()
return "", func() {}, fmt.Errorf("writeOCIRegistryConfigFile: no credentials provided")
}
if _, err := f.Write(content); err != nil {
_ = f.Close()
cleanup()
return "", func() {}, fmt.Errorf("writing OCI credentials: %w", err)
}
if err := f.Close(); err != nil {
cleanup()
return "", func() {}, fmt.Errorf("closing OCI credentials file: %w", err)
}
return path, cleanup, nil
}
// normalizeDockerHubAliases rewrites a verbatim dockerconfigjson blob so it
// works regardless of which Docker Hub host the user keyed it under. ORAS/Helm
// normalizes "registry-1.docker.io", "index.docker.io", and "docker.io" to
// the canonical "https://index.docker.io/v1/" key when looking up credentials,
// but a user-supplied Secret may be keyed under any of those aliases. This
// helper parses the JSON, and if any Docker Hub alias is present (or if the
// pull host is one of them and matches an existing entry), copies the entry
// under the canonical key so the lookup succeeds. If parsing fails or no
// Docker Hub alias is involved, returns the original bytes unchanged.
func normalizeDockerHubAliases(cfgJSON []byte, host string) []byte {
const canonical = "https://index.docker.io/v1/"
dockerHubHosts := []string{"registry-1.docker.io", "index.docker.io", "docker.io"}
var raw map[string]json.RawMessage
if err := json.Unmarshal(cfgJSON, &raw); err != nil {
return cfgJSON
}
authsRaw, ok := raw["auths"]
if !ok {
return cfgJSON
}
var auths map[string]json.RawMessage
if err := json.Unmarshal(authsRaw, &auths); err != nil {
return cfgJSON
}
if _, exists := auths[canonical]; exists {
return cfgJSON
}
pickSource := func() (json.RawMessage, bool) {
for _, h := range dockerHubHosts {
if v, ok := auths[h]; ok {
return v, true
}
}
for _, h := range dockerHubHosts {
if host == h {
if v, ok := auths[host]; ok {
return v, true
}
}
}
return nil, false
}
src, ok := pickSource()
if !ok {
return cfgJSON
}
auths[canonical] = src
newAuths, err := json.Marshal(auths)
if err != nil {
return cfgJSON
}
raw["auths"] = newAuths
out, err := json.Marshal(raw)
if err != nil {
return cfgJSON
}
return out
}
// resolveHTTPOptions is the provider-side wrapper called from each fetcher.
// Builds authResolveOptions from ChartSourceParams + namespace context, calls
// resolveAuthOptions, and wraps errors with chart-source context.
func resolveHTTPOptions(ctx context.Context, params *ChartSourceParams, appNamespace, releaseNamespace, sourceType string) (*common.HTTPOption, []byte, error) {
if params == nil || params.Auth == nil || params.Auth.SecretRef == nil {
return nil, nil, nil
}
opts := authResolveOptions{
AppNamespace: appNamespace,
ReleaseNamespace: releaseNamespace,
RegistryHost: extractRegistryHost(params.Source, params.RepoURL),
SourceScheme: detectSourceScheme(params.Source, params.RepoURL),
SourceType: sourceType,
}
ref := &secretRef{Name: params.Auth.SecretRef.Name, Namespace: params.Auth.SecretRef.Namespace}
httpOpt, raw, err := resolveAuthOptions(ctx, singleton.KubeClient.Get(), ref, opts)
if err != nil {
return nil, nil, fmt.Errorf("chart source %q: %w", chartSourceLabel(params), err)
}
return httpOpt, raw, nil
}
// detectSourceScheme returns "oci", "http", "https", or "" based on the
// chart source or repoURL.
func detectSourceScheme(source, repoURL string) string {
switch {
case strings.HasPrefix(source, "oci://"):
return "oci"
case strings.HasPrefix(source, "https://"):
return "https"
case strings.HasPrefix(source, "http://"):
return "http"
case strings.HasPrefix(repoURL, "https://"):
return "https"
case strings.HasPrefix(repoURL, "http://"):
return "http"
}
return ""
}
// chartSourceLabel returns a human-readable label for error messages:
// the Source URL if set, otherwise the RepoURL.
func chartSourceLabel(params *ChartSourceParams) string {
if params.Source != "" {
return params.Source
}
return params.RepoURL
}
+875
View File
@@ -0,0 +1,875 @@
/*
Copyright 2026 The KubeVela 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 helm
import (
"context"
"encoding/json"
"os"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/pkg/utils/common"
)
var _ = Describe("auth.go scaffolding", func() {
It("defines the source-type constants", func() {
Expect(sourceTypeOCI).To(Equal("oci"))
Expect(sourceTypeURL).To(Equal("url"))
Expect(sourceTypeRepo).To(Equal("repo"))
})
It("constructs secretRef and authResolveOptions zero-values without panic", func() {
ref := secretRef{Name: "n", Namespace: "ns"}
Expect(ref.Name).To(Equal("n"))
opts := authResolveOptions{AppNamespace: "a", ReleaseNamespace: "r", RegistryHost: "h", SourceScheme: "https", SourceType: sourceTypeOCI}
Expect(opts.SourceType).To(Equal("oci"))
})
})
var _ = Describe("extractRegistryHost", func() {
DescribeTable("derives the registry host from chart source / repoURL",
func(source, repoURL, want string) {
Expect(extractRegistryHost(source, repoURL)).To(Equal(want))
},
Entry("OCI with path", "oci://ghcr.io/stefanprodan/charts/podinfo", "", "ghcr.io"),
Entry("OCI with port", "oci://registry.example.com:5000/charts/x", "", "registry.example.com:5000"),
Entry("URL https", "https://example.com/charts/x-1.0.0.tgz", "", "example.com"),
Entry("URL http with port", "http://10.0.0.1:8080/x.tgz", "", "10.0.0.1:8080"),
Entry("Repo source uses repoURL", "podinfo", "https://stefanprodan.github.io/podinfo", "stefanprodan.github.io"),
Entry("Repo with port", "podinfo", "https://repo.example.com:8443/charts", "repo.example.com:8443"),
Entry("Empty inputs", "", "", ""),
)
})
var _ = Describe("resolveAuthSecretNamespace", func() {
It("defaults empty namespace to release namespace", func() {
ns, err := resolveAuthSecretNamespace(secretRef{Name: "s"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).NotTo(HaveOccurred())
Expect(ns).To(Equal("rel-ns"))
})
It("accepts an explicit release namespace", func() {
ns, err := resolveAuthSecretNamespace(secretRef{Name: "s", Namespace: "rel-ns"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).NotTo(HaveOccurred())
Expect(ns).To(Equal("rel-ns"))
})
It("accepts an explicit Application namespace", func() {
ns, err := resolveAuthSecretNamespace(secretRef{Name: "s", Namespace: "app-ns"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).NotTo(HaveOccurred())
Expect(ns).To(Equal("app-ns"))
})
It("rejects a foreign namespace with the RFC-grounded message", func() {
_, err := resolveAuthSecretNamespace(secretRef{Name: "s", Namespace: "other"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(`"other/s"`))
Expect(err.Error()).To(ContainSubstring("MUST equal the release namespace"))
Expect(err.Error()).To(ContainSubstring("rel-ns"))
Expect(err.Error()).To(ContainSubstring("app-ns"))
})
})
var _ = Describe("validateBasicCredentials", func() {
It("accepts a plain user/pass", func() {
Expect(validateBasicCredentials("alice", "wonderland", "ns", "s")).To(Succeed())
})
It("rejects a username containing ':'", func() {
err := validateBasicCredentials("al:ice", "p", "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`username MUST NOT contain ':' (RFC 7617 §2)`)))
Expect(err).To(MatchError(ContainSubstring(`"ns/s"`)))
})
It("rejects a username containing a control character", func() {
err := validateBasicCredentials("a\x01b", "p", "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`credentials MUST NOT contain control characters (RFC 7617 §2)`)))
})
It("rejects a password containing a control character", func() {
err := validateBasicCredentials("u", "p\x7f", "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`credentials MUST NOT contain control characters (RFC 7617 §2)`)))
})
})
var _ = Describe("validateBearerToken", func() {
DescribeTable("accepts tokens in the b64token charset",
func(token string) {
Expect(validateBearerToken(token, "ns", "s")).To(Succeed())
},
Entry("plain", "abcDEF123"),
Entry("with special chars", "a-b._c~d+e/f=="),
Entry("JWT shape", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc"),
)
DescribeTable("rejects tokens with chars outside the b64token charset",
func(token string) {
err := validateBearerToken(token, "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`b64token charset (RFC 6750 §2.1)`)))
},
Entry("space", "abc def"),
Entry("null", "abc\x00def"),
Entry("non-ASCII", "abcö"),
)
})
var _ = Describe("validateBearerTransport", func() {
It("permits Bearer over HTTPS without insecureSkipTLS", func() {
Expect(validateBearerTransport("https", false, "https://r.example.com/x", "ns", "s")).To(Succeed())
})
It("rejects Bearer over plain HTTP", func() {
err := validateBearerTransport("http", false, "http://r.example.com/x", "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`bearer tokens MUST be sent only over HTTPS or OCI (RFC 6750 §2)`)))
Expect(err).To(MatchError(ContainSubstring(`"ns/s"`)))
})
It("rejects Bearer with insecureSkipTLS=true", func() {
err := validateBearerTransport("https", true, "https://r.example.com/x", "ns", "s")
Expect(err).To(MatchError(ContainSubstring(`bearer tokens MUST NOT be sent with TLS verification disabled (RFC 6750 §2)`)))
})
})
var _ = Describe("dispatchBasicAuthSecret", func() {
It("populates HTTPOption.Username/Password from a basic-auth Secret", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{
corev1.BasicAuthUsernameKey: []byte("alice"),
corev1.BasicAuthPasswordKey: []byte("wonderland"),
},
}
opts, err := dispatchBasicAuthSecret(s, authResolveOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(Equal(&common.HTTPOption{Username: "alice", Password: "wonderland"}))
})
It("rejects a basic-auth Secret missing the username key", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{corev1.BasicAuthPasswordKey: []byte("p")},
}
_, err := dispatchBasicAuthSecret(s, authResolveOptions{})
Expect(err).To(MatchError(ContainSubstring(`MUST contain key "username"`)))
})
It("rejects a basic-auth Secret missing the password key", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{corev1.BasicAuthUsernameKey: []byte("u")},
}
_, err := dispatchBasicAuthSecret(s, authResolveOptions{})
Expect(err).To(MatchError(ContainSubstring(`MUST contain key "password"`)))
})
It("rejects a basic-auth Secret whose username contains ':'", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{
corev1.BasicAuthUsernameKey: []byte("a:b"),
corev1.BasicAuthPasswordKey: []byte("p"),
},
}
_, err := dispatchBasicAuthSecret(s, authResolveOptions{})
Expect(err).To(MatchError(ContainSubstring(`RFC 7617 §2`)))
})
})
var _ = Describe("dispatchDockerConfigJSONSecret", func() {
validCfg := []byte(`{"auths":{"ghcr.io":{"username":"alice","password":"wonderland","auth":"YWxpY2U6d29uZGVybGFuZA=="}}}`)
It("populates HTTPOption from the matching host entry", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: validCfg},
}
opts, raw, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io", SourceType: sourceTypeURL})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(Equal(&common.HTTPOption{Username: "alice", Password: "wonderland"}))
Expect(raw).To(BeNil(), "raw bytes returned only when SourceType=oci")
})
It("returns raw bytes for OCI sources alongside the parsed creds", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: validCfg},
}
opts, raw, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io", SourceType: sourceTypeOCI})
Expect(err).NotTo(HaveOccurred())
Expect(opts.Username).To(Equal("alice"))
Expect(raw).To(Equal(validCfg))
})
It("decodes the `auth` field when username/password are absent (docker login style)", func() {
// `docker login` writes only the base64 "username:password" auth field.
authOnly := []byte(`{"auths":{"ghcr.io":{"auth":"YWxpY2U6d29uZGVybGFuZA=="}}}`)
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: authOnly},
}
opts, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io"})
Expect(err).NotTo(HaveOccurred())
Expect(opts.Username).To(Equal("alice"))
Expect(opts.Password).To(Equal("wonderland"))
})
It("rejects an `auth` field that is not valid base64", func() {
bad := []byte(`{"auths":{"ghcr.io":{"auth":"not!base64!"}}}`)
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: bad},
}
_, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io"})
Expect(err).To(MatchError(ContainSubstring(`not valid base64`)))
})
It("rejects an `auth` field that decodes without a colon", func() {
// base64("nocolonhere")
bad := []byte(`{"auths":{"ghcr.io":{"auth":"bm9jb2xvbmhlcmU="}}}`)
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: bad},
}
_, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io"})
Expect(err).To(MatchError(ContainSubstring(`MUST decode to "username:password"`)))
})
It("rejects when the .dockerconfigjson key is absent", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{"wrong-key": validCfg},
}
_, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io"})
Expect(err).To(MatchError(ContainSubstring(`MUST contain key ".dockerconfigjson"`)))
})
It("rejects malformed JSON", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: []byte("{not json")},
}
_, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "ghcr.io"})
Expect(err).To(MatchError(ContainSubstring(`MUST contain a valid Docker configuration`)))
})
It("rejects when no entry matches the registry host", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "dh", Namespace: "ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{corev1.DockerConfigJsonKey: validCfg},
}
_, _, err := dispatchDockerConfigJSONSecret(s, authResolveOptions{RegistryHost: "other.example.com"})
Expect(err).To(MatchError(ContainSubstring(`no entry matching registry host "other.example.com"`)))
})
})
var _ = Describe("dispatchTLSSecret", func() {
crt := []byte("-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----")
key := []byte("-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----")
ca := []byte("-----BEGIN CERTIFICATE-----\nCAcert\n-----END CERTIFICATE-----")
It("populates CertFile/KeyFile (PEM strings) without ca.crt", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "ns"},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{corev1.TLSCertKey: crt, corev1.TLSPrivateKeyKey: key},
}
opts, err := dispatchTLSSecret(s, authResolveOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(opts.CertFile).To(Equal(string(crt)))
Expect(opts.KeyFile).To(Equal(string(key)))
Expect(opts.CaFile).To(BeEmpty())
})
It("populates CaFile when ca.crt is set", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "ns"},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{corev1.TLSCertKey: crt, corev1.TLSPrivateKeyKey: key, "ca.crt": ca},
}
opts, err := dispatchTLSSecret(s, authResolveOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(opts.CaFile).To(Equal(string(ca)))
})
It("rejects when tls.crt is absent", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "ns"},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{corev1.TLSPrivateKeyKey: key},
}
_, err := dispatchTLSSecret(s, authResolveOptions{})
Expect(err).To(MatchError(ContainSubstring(`MUST contain key "tls.crt"`)))
})
It("rejects when tls.key is absent", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "ns"},
Type: corev1.SecretTypeTLS,
Data: map[string][]byte{corev1.TLSCertKey: crt},
}
_, err := dispatchTLSSecret(s, authResolveOptions{})
Expect(err).To(MatchError(ContainSubstring(`MUST contain key "tls.key"`)))
})
})
var _ = Describe("dispatchOpaqueSecret", func() {
It("detects username+password keys", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{"username": []byte("alice"), "password": []byte("wonderland")},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "https"})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(Equal(&common.HTTPOption{Username: "alice", Password: "wonderland"}))
})
It("detects token key over HTTPS without insecureSkipTLS", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{"token": []byte("abcDEF123")},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "https"})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(Equal(&common.HTTPOption{BearerToken: "abcDEF123"}))
})
It("rejects when both username/password and token are set", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{"username": []byte("u"), "password": []byte("p"), "token": []byte("t")},
}
_, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "https"})
Expect(err).To(MatchError(ContainSubstring(`at most one credential method MUST be configured per Secret (RFC 6750 §2)`)))
})
It("supports TLS-only Opaque Secrets", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{
"certFile": []byte("CRT"),
"keyFile": []byte("KEY"),
"caFile": []byte("CA"),
},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "https"})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(Equal(&common.HTTPOption{CertFile: "CRT", KeyFile: "KEY", CaFile: "CA"}))
})
It("honors insecureSkipTLS Opaque key", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{
"username": []byte("u"),
"password": []byte("p"),
"insecureSkipTLS": []byte("true"),
},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "https"})
Expect(err).NotTo(HaveOccurred())
Expect(opts.InsecureSkipTLS).To(BeTrue())
})
It("honors insecurePlainHTTP Opaque key for OCI plain-HTTP", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{
"username": []byte("u"),
"password": []byte("p"),
"insecurePlainHTTP": []byte("true"),
},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "oci"})
Expect(err).NotTo(HaveOccurred())
Expect(opts.PlainHTTP).To(BeTrue())
Expect(opts.Username).To(Equal("u"))
Expect(opts.Password).To(Equal("p"))
})
It("leaves PlainHTTP unset when insecurePlainHTTP is absent", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "o", Namespace: "ns"},
Data: map[string][]byte{
"username": []byte("u"),
"password": []byte("p"),
},
}
opts, err := dispatchOpaqueSecret(s, authResolveOptions{SourceScheme: "oci"})
Expect(err).NotTo(HaveOccurred())
Expect(opts.PlainHTTP).To(BeFalse())
})
})
var _ = Describe("resolveAuthOptions", func() {
var scheme *runtime.Scheme
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
})
It("returns (nil, nil, nil) when ref is nil", func() {
c := fake.NewClientBuilder().WithScheme(scheme).Build()
opts, raw, err := resolveAuthOptions(context.Background(), c, nil, authResolveOptions{})
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(BeNil())
Expect(raw).To(BeNil())
})
It("returns the not-found error verbatim when the Secret is missing", func() {
c := fake.NewClientBuilder().WithScheme(scheme).Build()
_, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "missing"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).To(MatchError(ContainSubstring(`not found: it MUST exist in the release namespace "rel-ns" or the Application namespace "app-ns"`)))
})
It("rejects unsupported Secret types", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "x", Namespace: "rel-ns"},
Type: corev1.SecretTypeServiceAccountToken,
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build()
_, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "x"}, authResolveOptions{ReleaseNamespace: "rel-ns", AppNamespace: "app-ns"})
Expect(err).To(MatchError(ContainSubstring(`has unsupported type`)))
Expect(err).To(MatchError(ContainSubstring(`MUST be one of kubernetes.io/basic-auth, kubernetes.io/dockerconfigjson, kubernetes.io/tls, or Opaque`)))
})
It("dispatches kubernetes.io/basic-auth", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{
corev1.BasicAuthUsernameKey: []byte("alice"),
corev1.BasicAuthPasswordKey: []byte("wonderland"),
},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build()
opts, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "b"}, authResolveOptions{ReleaseNamespace: "rel-ns", AppNamespace: "app-ns", SourceScheme: "https"})
Expect(err).NotTo(HaveOccurred())
Expect(opts.Username).To(Equal("alice"))
Expect(opts.Password).To(Equal("wonderland"))
})
It("rejects a foreign namespace before reading the Secret", func() {
c := fake.NewClientBuilder().WithScheme(scheme).Build()
_, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "x", Namespace: "other"}, authResolveOptions{AppNamespace: "app-ns", ReleaseNamespace: "rel-ns"})
Expect(err).To(MatchError(ContainSubstring(`namespace MUST equal the release namespace "rel-ns" or the Application namespace "app-ns"`)))
})
It("rejects user-supplied bearer on OCI sources", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "rel-ns"},
Data: map[string][]byte{"token": []byte("abc")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build()
_, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "t"}, authResolveOptions{ReleaseNamespace: "rel-ns", AppNamespace: "app-ns", SourceScheme: "oci", SourceType: sourceTypeOCI})
Expect(err).To(MatchError(ContainSubstring(`user-supplied bearer tokens MUST NOT be used with OCI sources`)))
})
It("rejects bearer over plain http", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "rel-ns"},
Data: map[string][]byte{"token": []byte("abc")},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build()
_, _, err := resolveAuthOptions(context.Background(), c, &secretRef{Name: "t"}, authResolveOptions{ReleaseNamespace: "rel-ns", AppNamespace: "app-ns", SourceScheme: "http", SourceType: sourceTypeURL})
Expect(err).To(MatchError(ContainSubstring(`bearer tokens MUST be sent only over HTTPS or OCI (RFC 6750 §2)`)))
})
})
var _ = Describe("writeOCIRegistryConfigFile", func() {
It("synthesizes a one-entry config from username/password", func() {
path, cleanup, err := writeOCIRegistryConfigFile(&common.HTTPOption{Username: "alice", Password: "wonderland"}, nil, "ghcr.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var parsed dockerConfigJSON
Expect(json.Unmarshal(b, &parsed)).To(Succeed())
Expect(parsed.Auths).To(HaveKey("ghcr.io"))
Expect(parsed.Auths["ghcr.io"].Username).To(Equal("alice"))
})
// Docker Hub stores credentials under the canonical
// "https://index.docker.io/v1/" key, not under the "registry-1.docker.io"
// pull host. The Helm/ORAS auth resolver follows that convention, so the
// synthesized config MUST register the entry under BOTH keys when the host
// is one of the Docker Hub OCI endpoints; otherwise the lookup falls
// through to anonymous and the registry returns insufficient_scope.
It("registers Docker Hub creds under both registry-1.docker.io and index.docker.io/v1/", func() {
path, cleanup, err := writeOCIRegistryConfigFile(
&common.HTTPOption{Username: "dh-user", Password: "dckr_pat_fake"}, nil, "registry-1.docker.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var parsed dockerConfigJSON
Expect(json.Unmarshal(b, &parsed)).To(Succeed())
Expect(parsed.Auths).To(HaveKey("registry-1.docker.io"))
Expect(parsed.Auths).To(HaveKey("https://index.docker.io/v1/"))
Expect(parsed.Auths["registry-1.docker.io"].Username).To(Equal("dh-user"))
Expect(parsed.Auths["https://index.docker.io/v1/"].Username).To(Equal("dh-user"))
Expect(parsed.Auths["registry-1.docker.io"].Auth).
To(Equal(parsed.Auths["https://index.docker.io/v1/"].Auth))
})
It("registers Docker Hub creds under both keys when host is index.docker.io", func() {
path, cleanup, err := writeOCIRegistryConfigFile(
&common.HTTPOption{Username: "dh-user", Password: "dckr_pat_fake"}, nil, "index.docker.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var parsed dockerConfigJSON
Expect(json.Unmarshal(b, &parsed)).To(Succeed())
Expect(parsed.Auths).To(HaveKey("index.docker.io"))
Expect(parsed.Auths).To(HaveKey("https://index.docker.io/v1/"))
})
It("registers Docker Hub creds under both keys when host is the short docker.io alias", func() {
// oras-go's resolveHostname normalises "docker.io" to the v1 index too,
// so charts written as oci://docker.io/library/... must also get the
// canonical key, not just the explicit pull hosts.
path, cleanup, err := writeOCIRegistryConfigFile(
&common.HTTPOption{Username: "dh-user", Password: "dckr_pat_fake"}, nil, "docker.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var parsed dockerConfigJSON
Expect(json.Unmarshal(b, &parsed)).To(Succeed())
Expect(parsed.Auths).To(HaveKey("docker.io"))
Expect(parsed.Auths).To(HaveKey("https://index.docker.io/v1/"))
})
It("does NOT add the Docker Hub alias for non-Docker-Hub hosts", func() {
path, cleanup, err := writeOCIRegistryConfigFile(
&common.HTTPOption{Username: "u", Password: "p"}, nil, "ghcr.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
var parsed dockerConfigJSON
Expect(json.Unmarshal(b, &parsed)).To(Succeed())
Expect(parsed.Auths).To(HaveKey("ghcr.io"))
Expect(parsed.Auths).NotTo(HaveKey("https://index.docker.io/v1/"))
Expect(parsed.Auths).To(HaveLen(1))
})
It("writes raw .dockerconfigjson bytes verbatim", func() {
raw := []byte(`{"auths":{"ghcr.io":{"username":"x","password":"y"},"other.io":{"username":"a","password":"b"}}}`)
path, cleanup, err := writeOCIRegistryConfigFile(nil, raw, "ghcr.io")
Expect(err).NotTo(HaveOccurred())
defer cleanup()
b, err := os.ReadFile(path)
Expect(err).NotTo(HaveOccurred())
Expect(b).To(Equal(raw))
})
It("cleanup removes the file", func() {
path, cleanup, err := writeOCIRegistryConfigFile(&common.HTTPOption{Username: "u", Password: "p"}, nil, "h")
Expect(err).NotTo(HaveOccurred())
cleanup()
_, err = os.Stat(path)
Expect(os.IsNotExist(err)).To(BeTrue())
})
})
var _ = Describe("computeAuthCacheTag", func() {
var (
scheme *runtime.Scheme
origKubeClient client.Client
)
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
origKubeClient = singleton.KubeClient.Get()
})
AfterEach(func() {
singleton.KubeClient.Set(origKubeClient)
})
It("returns empty tag when no auth.secretRef is declared", func() {
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
tag, err := computeAuthCacheTag(context.Background(),
&ChartSourceParams{Source: "https://example.com/x.tgz"},
"app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag).To(Equal(""))
})
It("returns a stable hex tag for a given Secret content", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{"username": []byte("u"), "password": []byte("p")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build())
params := &ChartSourceParams{
Source: "oci://r.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}
tag1, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag1).To(HaveLen(16))
tag2, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag2).To(Equal(tag1))
})
It("produces a different tag when the Secret data changes", func() {
mkSecret := func(pass string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{"username": []byte("u"), "password": []byte(pass)},
}
}
params := &ChartSourceParams{
Source: "oci://r.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(mkSecret("p1")).Build())
tag1, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(mkSecret("p2")).Build())
tag2, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag2).NotTo(Equal(tag1))
})
It("produces a different tag when the Secret Type changes", func() {
mkSecret := func(t corev1.SecretType) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: t,
Data: map[string][]byte{"username": []byte("u"), "password": []byte("p")},
}
}
params := &ChartSourceParams{
Source: "oci://r.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(mkSecret(corev1.SecretTypeBasicAuth)).Build())
tag1, _ := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(mkSecret(corev1.SecretTypeOpaque)).Build())
tag2, _ := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(tag2).NotTo(Equal(tag1))
})
It("returns a clear not-found error when the Secret is missing", func() {
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
params := &ChartSourceParams{
Source: "oci://r.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "missing"}},
}
_, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).To(MatchError(ContainSubstring(`not found: it MUST exist in the release namespace`)))
})
It("produces a different tag for different chart sources with the same Secret", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{".dockerconfigjson": []byte(`{"auths":{"a":{},"b":{}}}`)},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build())
tag1, err := computeAuthCacheTag(context.Background(),
&ChartSourceParams{
Source: "oci://a.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
tag2, err := computeAuthCacheTag(context.Background(),
&ChartSourceParams{
Source: "oci://b.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag2).NotTo(Equal(tag1))
})
It("produces a different tag when repoURL changes", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{"username": []byte("u"), "password": []byte("p")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build())
tag1, err := computeAuthCacheTag(context.Background(),
&ChartSourceParams{
Source: "chart",
RepoURL: "https://r1.example.com",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
tag2, err := computeAuthCacheTag(context.Background(),
&ChartSourceParams{
Source: "chart",
RepoURL: "https://r2.example.com",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(tag2).NotTo(Equal(tag1))
})
It("rejects cross-namespace secret references", func() {
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
params := &ChartSourceParams{
Source: "oci://r.example.com/charts/c",
Auth: &AuthParams{SecretRef: &SecretRefParams{
Name: "creds", Namespace: "kube-system",
}},
}
_, err := computeAuthCacheTag(context.Background(), params, "app-ns", "rel-ns")
Expect(err).To(MatchError(ContainSubstring(`MUST equal the release namespace`)))
})
})
var _ = Describe("normalizeDockerHubAliases", func() {
canonical := "https://index.docker.io/v1/"
parse := func(b []byte) map[string]interface{} {
var m map[string]interface{}
Expect(json.Unmarshal(b, &m)).To(Succeed())
return m
}
It("copies an existing registry-1.docker.io entry under the canonical v1 key", func() {
in := []byte(`{"auths":{"registry-1.docker.io":{"username":"u","password":"p","auth":"dXA="}}}`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
auths := parse(out)["auths"].(map[string]interface{})
Expect(auths).To(HaveKey(canonical))
Expect(auths).To(HaveKey("registry-1.docker.io"))
})
It("copies an existing index.docker.io entry under the canonical v1 key", func() {
in := []byte(`{"auths":{"index.docker.io":{"username":"u","password":"p","auth":"dXA="}}}`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
auths := parse(out)["auths"].(map[string]interface{})
Expect(auths).To(HaveKey(canonical))
})
It("copies an existing docker.io entry under the canonical v1 key", func() {
in := []byte(`{"auths":{"docker.io":{"auth":"dXA="}}}`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
auths := parse(out)["auths"].(map[string]interface{})
Expect(auths).To(HaveKey(canonical))
})
It("is a no-op when the canonical v1 key is already present", func() {
in := []byte(`{"auths":{"https://index.docker.io/v1/":{"auth":"dXA="}}}`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
Expect(string(out)).To(Equal(string(in)))
})
It("is a no-op when the host is not Docker Hub", func() {
in := []byte(`{"auths":{"ghcr.io":{"auth":"dXA="}}}`)
out := normalizeDockerHubAliases(in, "ghcr.io")
auths := parse(out)["auths"].(map[string]interface{})
Expect(auths).NotTo(HaveKey(canonical))
Expect(auths).To(HaveKey("ghcr.io"))
})
It("returns the original bytes on malformed JSON", func() {
in := []byte(`{not-json`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
Expect(string(out)).To(Equal(string(in)))
})
It("returns the original bytes when the auths field is absent", func() {
in := []byte(`{"other":"value"}`)
out := normalizeDockerHubAliases(in, "registry-1.docker.io")
Expect(string(out)).To(Equal(string(in)))
})
})
var _ = Describe("resolveHTTPOptions", func() {
var (
scheme *runtime.Scheme
origKubeClient client.Client
)
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
// Capture the package-global KubeClient so the per-spec
// singleton.KubeClient.Set() calls below cannot leak fake
// clients into later tests in this package.
origKubeClient = singleton.KubeClient.Get()
})
AfterEach(func() {
singleton.KubeClient.Set(origKubeClient)
})
It("returns (nil, nil, nil) when params.Auth is nil", func() {
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
params := &ChartSourceParams{Source: "https://example.com/x.tgz"}
opts, raw, err := resolveHTTPOptions(context.Background(), params, "app-ns", "rel-ns", sourceTypeURL)
Expect(err).NotTo(HaveOccurred())
Expect(opts).To(BeNil())
Expect(raw).To(BeNil())
})
It("threads source scheme into the resolver (URL https)", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "rel-ns"},
Data: map[string][]byte{"token": []byte("abc.def.ghi")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build())
params := &ChartSourceParams{
Source: "https://r.example.com/x.tgz",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "t"}},
}
opts, _, err := resolveHTTPOptions(context.Background(), params, "app-ns", "rel-ns", sourceTypeURL)
Expect(err).NotTo(HaveOccurred())
Expect(opts.BearerToken).To(Equal("abc.def.ghi"))
})
It("rejects bearer over http (provider-side scheme detection)", func() {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "rel-ns"},
Data: map[string][]byte{"token": []byte("abc")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(s).Build())
params := &ChartSourceParams{
Source: "http://r.example.com/x.tgz",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "t"}},
}
_, _, err := resolveHTTPOptions(context.Background(), params, "app-ns", "rel-ns", sourceTypeURL)
Expect(err).To(MatchError(ContainSubstring(`RFC 6750 §2`)))
})
})
+11 -4
View File
@@ -14,13 +14,20 @@ package helm
repoURL?: string
// +usage=Version/tag for repository and OCI charts
version?: string
// +usage=Authentication configuration
// +usage=Authentication for private chart repositories.
// The referenced Secret MUST be one of: kubernetes.io/basic-auth,
// kubernetes.io/dockerconfigjson, kubernetes.io/tls, or Opaque.
// User-supplied bearer tokens MUST NOT be used with OCI sources;
// the registry performs its own Basic->Bearer exchange per the OCI
// Distribution Spec. Tokens are treated as opaque per RFC 7519.
auth?: {
// +usage=Reference to Secret containing credentials
// +usage=Reference to a Kubernetes Secret containing credentials.
secretRef?: {
// +usage=Secret name
// +usage=Secret name.
name: string
// +usage=Secret namespace
// +usage=Secret namespace. MAY be omitted (defaults to the
// release namespace). When set, it MUST equal either the
// release namespace or the Application namespace.
namespace?: string
}
}
+173 -51
View File
@@ -41,8 +41,10 @@ import (
"helm.sh/helm/v3/pkg/repo"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
kyaml "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
@@ -331,10 +333,23 @@ func isMutableVersion(version string) bool {
}
// fetchChart fetches a Helm chart from the specified source
func (p *Provider) fetchChart(ctx context.Context, params *ChartSourceParams, options *RenderOptionsParams) (*chart.Chart, error) {
func (p *Provider) fetchChart(ctx context.Context, params *ChartSourceParams, options *RenderOptionsParams, appNamespace, releaseNamespace string) (*chart.Chart, error) {
sourceType := detectChartSourceType(params.Source)
// Build cache key: <cache_key_prefix>/<source_type>/<source>/<version>
// When the source declares auth.secretRef, the cache key is bound to a
// hash of the resolved Secret data. Rotating the Secret (or creating a
// new Application that points at a different Secret) invalidates the
// cache automatically and forces a fresh registry call that exercises
// the new credentials at the wire. Without this binding, cached chart
// bytes pulled by an earlier authorized request would be served to a
// subsequent request whose Secret no longer authenticates against the
// registry — a real auth bypass for the cache TTL window.
authTag, err := computeAuthCacheTag(ctx, params, appNamespace, releaseNamespace)
if err != nil {
return nil, err
}
// Build cache key: <cache_key_prefix>/<source_type>/<source>/<version>[/auth-<tag>]
var cacheKey string
if options != nil && options.Cache != nil && options.Cache.Key != "" {
// User provided cache key
@@ -350,16 +365,29 @@ func (p *Provider) fetchChart(ctx context.Context, params *ChartSourceParams, op
strings.ReplaceAll(strings.ReplaceAll(params.Source, "://", "-"), "/", "-"),
params.Version)
}
if authTag != "" {
cacheKey = cacheKey + "/auth-" + authTag
}
// Check if caching is disabled
if options != nil && options.Cache != nil && options.Cache.TTL == "0" {
klog.V(4).Info("Cache disabled for this chart")
return p.fetchChartWithoutCache(ctx, params, sourceType)
return p.fetchChartWithoutCache(ctx, params, sourceType, appNamespace, releaseNamespace)
}
// Check if we have a cached chart
// Check if we have a cached chart. The auth-bound cache key above is
// the primary guard against stale credentials. The explicit resolver
// re-check below remains as a belt-and-suspenders measure: it catches
// a missing or malformed Secret immediately, with the same RFC-cited
// errors the cache-miss path would surface, instead of returning a
// confusing cache-hit chart for a misconfigured request.
if cached := p.cache.Get(cacheKey); cached != nil {
if ch, ok := cached.(*chart.Chart); ok {
if params.Auth != nil && params.Auth.SecretRef != nil {
if _, _, err := resolveHTTPOptions(ctx, params, appNamespace, releaseNamespace, sourceType); err != nil {
return nil, err
}
}
klog.V(3).Infof("Using cached chart with key: %s", cacheKey)
return ch, nil
}
@@ -367,7 +395,7 @@ func (p *Provider) fetchChart(ctx context.Context, params *ChartSourceParams, op
klog.V(4).Infof("Cache miss for key: %s, fetching chart", cacheKey)
ch, err := p.fetchChartWithoutCache(ctx, params, sourceType)
ch, err := p.fetchChartWithoutCache(ctx, params, sourceType, appNamespace, releaseNamespace)
if err != nil {
return nil, err
}
@@ -385,14 +413,14 @@ func (p *Provider) fetchChart(ctx context.Context, params *ChartSourceParams, op
}
// fetchChartWithoutCache fetches a chart without using cache
func (p *Provider) fetchChartWithoutCache(ctx context.Context, params *ChartSourceParams, sourceType string) (*chart.Chart, error) {
func (p *Provider) fetchChartWithoutCache(ctx context.Context, params *ChartSourceParams, sourceType string, appNamespace, releaseNamespace string) (*chart.Chart, error) {
switch sourceType {
case "oci":
return p.fetchOCIChart(ctx, params)
return p.fetchOCIChart(ctx, params, appNamespace, releaseNamespace)
case "url":
return p.fetchURLChart(ctx, params)
return p.fetchURLChart(ctx, params, appNamespace, releaseNamespace)
case "repo":
return p.fetchRepoChart(ctx, params)
return p.fetchRepoChart(ctx, params, appNamespace, releaseNamespace)
default:
return nil, fmt.Errorf("unsupported chart source type: %s", sourceType)
}
@@ -444,88 +472,98 @@ func (p *Provider) determineCacheTTL(version string, options *RenderOptionsParam
return p.cacheTTL.ImmutableVersionTTL
}
// fetchOCIChart fetches a chart from an OCI registry
func (p *Provider) fetchOCIChart(_ context.Context, params *ChartSourceParams) (*chart.Chart, error) {
registryClient, err := registry.NewClient()
// fetchOCIChart fetches a chart from an OCI registry.
func (p *Provider) fetchOCIChart(ctx context.Context, params *ChartSourceParams, appNamespace, releaseNamespace string) (*chart.Chart, error) {
httpOpts, rawDockerCfg, err := resolveHTTPOptions(ctx, params, appNamespace, releaseNamespace, sourceTypeOCI)
if err != nil {
return nil, errors.Wrap(err, "auth resolution failed")
}
clientOpts := []registry.ClientOption{}
if httpOpts != nil || rawDockerCfg != nil {
host := extractRegistryHost(params.Source, params.RepoURL)
credFile, cleanup, werr := writeOCIRegistryConfigFile(httpOpts, rawDockerCfg, host)
if werr != nil {
return nil, errors.Wrap(werr, "failed to materialize OCI credentials file")
}
defer cleanup()
clientOpts = append(clientOpts, registry.ClientOptCredentialsFile(credFile))
}
if httpOpts != nil && httpOpts.PlainHTTP {
clientOpts = append(clientOpts, registry.ClientOptPlainHTTP())
}
registryClient, err := registry.NewClient(clientOpts...)
if err != nil {
return nil, errors.Wrap(err, "failed to create OCI registry client")
}
// Remove oci:// prefix
ref := strings.TrimPrefix(params.Source, "oci://")
if params.Version != "" {
ref = fmt.Sprintf("%s:%s", ref, params.Version)
}
// Pull the chart
result, err := registryClient.Pull(ref)
if err != nil {
return nil, errors.Wrapf(err, "failed to pull OCI chart %s", ref)
}
ch, err := loader.LoadArchive(bytes.NewReader(result.Chart.Data))
if err != nil {
return nil, errors.Wrap(err, "failed to load OCI chart")
}
return ch, nil
return loader.LoadArchive(bytes.NewReader(result.Chart.Data))
}
// fetchURLChart fetches a chart from a direct URL
func (p *Provider) fetchURLChart(ctx context.Context, params *ChartSourceParams) (*chart.Chart, error) {
// Create HTTP client with options
opts := &common.HTTPOption{}
// TODO: Add authentication support from params.Auth
// fetchURLChart fetches a chart from a direct URL.
func (p *Provider) fetchURLChart(ctx context.Context, params *ChartSourceParams, appNamespace, releaseNamespace string) (*chart.Chart, error) {
httpOpts, _, err := resolveHTTPOptions(ctx, params, appNamespace, releaseNamespace, sourceTypeURL)
if err != nil {
return nil, errors.Wrap(err, "auth resolution failed")
}
if httpOpts == nil {
httpOpts = &common.HTTPOption{}
}
chartBytes, err := common.HTTPGetWithOption(ctx, params.Source, opts)
chartBytes, err := common.HTTPGetWithOption(ctx, params.Source, httpOpts)
if err != nil {
return nil, errors.Wrapf(err, "failed to download chart from %s", params.Source)
}
ch, err := loader.LoadArchive(bytes.NewReader(chartBytes))
if err != nil {
return nil, errors.Wrap(err, "failed to load chart archive")
}
return ch, nil
}
// fetchRepoChart fetches a chart from a Helm repository
func (p *Provider) fetchRepoChart(ctx context.Context, params *ChartSourceParams) (*chart.Chart, error) {
// fetchRepoChart fetches a chart from a Helm repository.
func (p *Provider) fetchRepoChart(ctx context.Context, params *ChartSourceParams, appNamespace, releaseNamespace string) (*chart.Chart, error) {
if params.RepoURL == "" {
return nil, fmt.Errorf("repoURL is required for repository-based charts")
}
// First, fetch the repository index to find the chart
indexURL := fmt.Sprintf("%s/index.yaml", params.RepoURL)
httpOpts, _, err := resolveHTTPOptions(ctx, params, appNamespace, releaseNamespace, sourceTypeRepo)
if err != nil {
return nil, errors.Wrap(err, "auth resolution failed")
}
if httpOpts == nil {
httpOpts = &common.HTTPOption{}
}
// Use HTTP client to fetch index
indexBytes, err := common.HTTPGetWithOption(ctx, indexURL, &common.HTTPOption{})
indexURL := fmt.Sprintf("%s/index.yaml", params.RepoURL)
indexBytes, err := common.HTTPGetWithOption(ctx, indexURL, httpOpts)
if err != nil {
return nil, errors.Wrapf(err, "failed to fetch repository index from %s", indexURL)
}
// Parse the index to find the chart URL
var index repo.IndexFile
if err := yaml.Unmarshal(indexBytes, &index); err != nil {
return nil, errors.Wrap(err, "failed to parse repository index")
}
// Sort entries so that Get() returns the highest matching version
index.SortEntries()
// Find the requested chart version. Get() supports exact versions (e.g., "1.2.3"),
// semver constraints (e.g., "^1.2.0", ">=1.0.0 <2.0.0"), and empty string (latest stable).
chartVersion, err := index.Get(params.Source, params.Version)
if err != nil {
return nil, fmt.Errorf("version %q of chart %s not found in repository %s: %w", params.Version, params.Source, params.RepoURL, err)
}
// Get the download URL
var downloadURL string
if len(chartVersion.URLs) > 0 {
downloadURL = chartVersion.URLs[0]
// Make URL absolute if it's relative
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
downloadURL = fmt.Sprintf("%s/%s", params.RepoURL, downloadURL)
}
@@ -533,18 +571,14 @@ func (p *Provider) fetchRepoChart(ctx context.Context, params *ChartSourceParams
return nil, fmt.Errorf("no download URL found for chart %s", params.Source)
}
// Download the chart
chartBytes, err := common.HTTPGetWithOption(ctx, downloadURL, &common.HTTPOption{})
chartBytes, err := common.HTTPGetWithOption(ctx, downloadURL, httpOpts)
if err != nil {
return nil, errors.Wrapf(err, "failed to download chart from %s", downloadURL)
}
// Load the chart from bytes
ch, err := loader.LoadArchive(bytes.NewReader(chartBytes))
if err != nil {
return nil, errors.Wrap(err, "failed to load chart archive")
}
return ch, nil
}
@@ -814,6 +848,22 @@ func (r *velaLabelPostRenderer) Run(renderedManifests *bytes.Buffer) (*bytes.Buf
labels["app.oam.dev/component"] = r.context.Name
obj.SetLabels(labels)
// Default metadata.namespace for namespaced rendered resources whose
// template omitted it. Upstream charts (Bitnami, podinfo, ...)
// typically rely on `helm install --namespace` for placement instead
// of templating metadata.namespace, and Helm's own apply step then
// uses the kube client's default namespace (which under KubeVela
// resolves to the controller's own ns, vela-system) rather than the
// release namespace. Stamping the namespace here makes every output
// in the rendered manifest carry the right placement before helm's
// kube.Client.Create runs, and before KubeVela's resource tracker
// re-applies it. Cluster-scoped kinds (CRDs, ClusterRoles,
// Namespaces, ...) are left as-is so the API server does not reject
// them.
if r.releaseNamespace != "" && obj.GetNamespace() == "" && !isClusterScopedGVK(obj.GroupVersionKind()) {
obj.SetNamespace(r.releaseNamespace)
}
// Inject ownership annotations (both KubeVela and Helm)
annotations := obj.GetAnnotations()
if annotations == nil {
@@ -1396,7 +1446,15 @@ func (p *Provider) InvalidateRelease(releaseName, releaseNamespace string) {
// parseManifestResources parses a Helm release manifest string into a slice of
// resource maps, skipping test hooks when requested and ordering CRDs first.
func (p *Provider) parseManifestResources(manifestStr string, options *RenderOptionsParams) ([]map[string]interface{}, error) {
// Resources whose `metadata.namespace` is empty get defaulted to
// releaseNamespace unless their kind is cluster-scoped. Upstream Helm charts
// commonly omit metadata.namespace and rely on the helm install --namespace
// flag for placement; KubeVela's resource tracker re-applies these outputs
// independently and would otherwise default them to vela-system, creating
// shadow copies and tripping helm's ownership annotation guard on the next
// release. Defaulting at parse time keeps every output keyed to the correct
// namespace from the start.
func (p *Provider) parseManifestResources(manifestStr string, options *RenderOptionsParams, releaseNamespace string) ([]map[string]interface{}, error) {
skipTests := true
if options != nil && options.SkipTests != nil {
skipTests = *options.SkipTests
@@ -1424,6 +1482,14 @@ func (p *Provider) parseManifestResources(manifestStr string, options *RenderOpt
continue
}
// Default the namespace for namespaced resources whose template
// omitted metadata.namespace. Cluster-scoped kinds (CRDs,
// ClusterRoles, Namespaces, ...) are left as-is so the API server
// does not reject them.
if releaseNamespace != "" && resource.GetNamespace() == "" && !isClusterScopedGVK(resource.GroupVersionKind()) {
resource.SetNamespace(releaseNamespace)
}
cleanedResource := cleanResource(resource.Object)
resources = append(resources, cleanedResource)
}
@@ -1432,6 +1498,62 @@ func (p *Provider) parseManifestResources(manifestStr string, options *RenderOpt
return orderResources(resources), nil
}
// isClusterScopedGVK reports whether the given GroupVersionKind denotes a
// Kubernetes resource that lives at the cluster scope (no namespace).
//
// Resolution order:
//
// 1. Ask the cluster's RESTMapper. This sees built-in kinds AND third-party
// CRDs (cert-manager's ClusterIssuer, Knative's ClusterIngress, etc.),
// so the namespace-default logic doesn't mis-namespace custom
// cluster-scoped resources.
// 2. If the RESTMapper is unavailable, or doesn't know the GVK (e.g.,
// because the chart manifest itself defines a CRD whose kind hasn't
// been registered with the API server yet), fall back to a static
// allowlist of well-known cluster-scoped kinds.
//
// The fallback is intentionally conservative: an unrecognized kind is
// treated as namespaced, so a new namespaced custom resource gets the
// safe default (release namespace) rather than landing in vela-system.
func isClusterScopedGVK(gvk schema.GroupVersionKind) bool {
if mapper := singleton.RESTMapper.Get(); mapper != nil {
if mapping, mErr := mapper.RESTMapping(gvk.GroupKind(), gvk.Version); mErr == nil && mapping != nil {
return mapping.Scope.Name() == meta.RESTScopeNameRoot
}
}
return isClusterScopedKindStaticFallback(gvk.Kind)
}
// isClusterScopedKindStaticFallback returns true for the well-known set of
// built-in cluster-scoped kinds. Used only when the RESTMapper cannot answer
// authoritatively. New entries should be limited to stable upstream APIs;
// for third-party CRDs the RESTMapper path is the source of truth.
func isClusterScopedKindStaticFallback(kind string) bool {
switch kind {
case "CustomResourceDefinition",
"Namespace",
"ClusterRole",
"ClusterRoleBinding",
"PersistentVolume",
"StorageClass",
"VolumeAttachment",
"CSIDriver",
"CSINode",
"PriorityClass",
"RuntimeClass",
"IngressClass",
"MutatingWebhookConfiguration",
"ValidatingWebhookConfiguration",
"APIService",
"FlowSchema",
"PriorityLevelConfiguration",
"Node",
"ComponentStatus":
return true
}
return false
}
// isTestResource checks if a resource is a test resource
func isTestResource(resource *unstructured.Unstructured) bool {
annotations := resource.GetAnnotations()
@@ -1579,7 +1701,7 @@ func Render(ctx context.Context, params *providers.Params[RenderParams]) (*provi
klog.V(3).Infof("Helm provider: Release name=%s, namespace=%s", releaseName, releaseNamespace)
// Fetch the chart
ch, err := p.fetchChart(ctx, &renderParams.Chart, renderParams.Options)
ch, err := p.fetchChart(ctx, &renderParams.Chart, renderParams.Options, appNamespace, releaseNamespace)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch chart")
}
@@ -1624,7 +1746,7 @@ func Render(ctx context.Context, params *providers.Params[RenderParams]) (*provi
}
// Parse the release manifest into KubeVela resource maps
resources, err := p.parseManifestResources(manifest, renderParams.Options)
resources, err := p.parseManifestResources(manifest, renderParams.Options, releaseNamespace)
if err != nil {
return nil, errors.Wrap(err, "failed to parse release manifest")
}
+271 -21
View File
@@ -316,7 +316,7 @@ metadata:
annotations:
helm.sh/hook: test-success
`
resources, err := p.parseManifestResources(manifest, nil)
resources, err := p.parseManifestResources(manifest, nil, "")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(2))
@@ -344,7 +344,7 @@ metadata:
helm.sh/hook: test-success
`
skipFalse := false
resources, err := p.parseManifestResources(manifest, &RenderOptionsParams{SkipTests: &skipFalse})
resources, err := p.parseManifestResources(manifest, &RenderOptionsParams{SkipTests: &skipFalse}, "")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(2))
})
@@ -366,7 +366,7 @@ kind: Namespace
metadata:
name: my-ns
`
resources, err := p.parseManifestResources(manifest, nil)
resources, err := p.parseManifestResources(manifest, nil, "")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(3))
@@ -377,6 +377,69 @@ metadata:
Expect(kind1).To(Equal("Namespace"))
Expect(kind2).To(Equal("Deployment"))
})
It("defaults metadata.namespace to releaseNamespace for namespaced resources", func() {
p := NewProvider()
manifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec: {replicas: 1}
---
apiVersion: v1
kind: Service
metadata:
name: app
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.example.com
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: app-reader
`
resources, err := p.parseManifestResources(manifest, nil, "stress-s2")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(4))
byKind := map[string]map[string]interface{}{}
for _, r := range resources {
k, _, _ := unstructured.NestedString(r, "kind")
byKind[k] = r
}
depNs, _, _ := unstructured.NestedString(byKind["Deployment"], "metadata", "namespace")
Expect(depNs).To(Equal("stress-s2"))
svcNs, _, _ := unstructured.NestedString(byKind["Service"], "metadata", "namespace")
Expect(svcNs).To(Equal("stress-s2"))
// Cluster-scoped kinds must NOT be patched with a namespace.
_, crdHasNs, _ := unstructured.NestedString(byKind["CustomResourceDefinition"], "metadata", "namespace")
Expect(crdHasNs).To(BeFalse())
_, crHasNs, _ := unstructured.NestedString(byKind["ClusterRole"], "metadata", "namespace")
Expect(crHasNs).To(BeFalse())
})
It("leaves explicit metadata.namespace untouched", func() {
p := NewProvider()
manifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
namespace: explicit-ns
spec: {replicas: 1}
`
resources, err := p.parseManifestResources(manifest, nil, "release-ns")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(1))
ns, _, _ := unstructured.NestedString(resources[0], "metadata", "namespace")
Expect(ns).To(Equal("explicit-ns"))
})
})
Describe("getActionConfig", func() {
@@ -1217,13 +1280,13 @@ spec:
})
It("should fail for unsupported source type", func() {
_, err := p.fetchChartWithoutCache(context.Background(), &ChartSourceParams{Source: "test"}, "unknown")
_, err := p.fetchChartWithoutCache(context.Background(), &ChartSourceParams{Source: "test"}, "unknown", "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unsupported chart source type"))
})
It("should fail for repo without repoURL", func() {
_, err := p.fetchChartWithoutCache(context.Background(), &ChartSourceParams{Source: "nginx"}, "repo")
_, err := p.fetchChartWithoutCache(context.Background(), &ChartSourceParams{Source: "nginx"}, "repo", "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("repoURL is required"))
})
@@ -1338,7 +1401,7 @@ spec:
result, err := p.fetchChart(context.Background(),
&ChartSourceParams{Source: "nginx", Version: "1.0.0"},
nil)
nil, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(result.Metadata.Name).To(Equal("cached-chart"))
})
@@ -1353,7 +1416,7 @@ spec:
result, err := p.fetchChart(context.Background(),
&ChartSourceParams{Source: "myapp", Version: "2.0.0"},
&RenderOptionsParams{Cache: &CacheParams{Key: "my-prefix"}})
&RenderOptionsParams{Cache: &CacheParams{Key: "my-prefix"}}, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(result.Metadata.Name).To(Equal("custom-cached"))
})
@@ -1363,7 +1426,7 @@ spec:
// which will fail since there's no real repo, but the code path is exercised
_, err := p.fetchChart(context.Background(),
&ChartSourceParams{Source: "nginx"},
&RenderOptionsParams{Cache: &CacheParams{TTL: "0"}})
&RenderOptionsParams{Cache: &CacheParams{TTL: "0"}}, "", "")
Expect(err).Should(HaveOccurred())
// The error should come from fetchChartWithoutCache, not from cache logic
Expect(err.Error()).To(ContainSubstring("repoURL is required"))
@@ -1380,7 +1443,7 @@ spec:
result, err := p.fetchChart(context.Background(),
&ChartSourceParams{Source: "oci://ghcr.io/example/chart", Version: "3.0.0"},
nil)
nil, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(result.Metadata.Name).To(Equal("oci-chart"))
})
@@ -1396,7 +1459,7 @@ spec:
result, err := p.fetchChart(context.Background(),
&ChartSourceParams{Source: "https://example.com/chart.tgz", Version: "1.0.0"},
nil)
nil, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(result.Metadata.Name).To(Equal("url-chart"))
})
@@ -1574,7 +1637,7 @@ spec:
_ = notes
// Parse the manifest
resources, err := p.parseManifestResources(manifest, nil)
resources, err := p.parseManifestResources(manifest, nil, "")
Expect(err).ShouldNot(HaveOccurred())
Expect(resources).To(HaveLen(1))
@@ -2041,7 +2104,7 @@ entries:
Source: "test-repo-chart",
RepoURL: server.URL,
Version: "1.0.0",
})
}, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(ch).ToNot(BeNil())
Expect(ch.Metadata.Name).To(Equal("test-repo-chart"))
@@ -2074,7 +2137,7 @@ entries:
ch, err := p.fetchRepoChart(context.Background(), &ChartSourceParams{
Source: "no-ver-chart",
RepoURL: server.URL,
})
}, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(ch.Metadata.Name).To(Equal("no-ver-chart"))
})
@@ -2096,7 +2159,7 @@ entries:
_, err := p.fetchRepoChart(context.Background(), &ChartSourceParams{
Source: "missing-chart",
RepoURL: server.URL,
})
}, "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found in repository"))
})
@@ -2119,7 +2182,7 @@ entries:
Source: "my-chart",
RepoURL: server.URL,
Version: "99.0.0",
})
}, "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
@@ -2134,7 +2197,7 @@ entries:
_, err := p.fetchRepoChart(context.Background(), &ChartSourceParams{
Source: "test",
RepoURL: server.URL,
})
}, "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse repository index"))
})
@@ -2156,7 +2219,7 @@ entries:
Source: "empty-urls",
RepoURL: server.URL,
Version: "1.0.0",
})
}, "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no download URL found"))
})
@@ -2179,7 +2242,7 @@ entries:
p := NewProviderWithConfig(nil)
ch, err := p.fetchURLChart(context.Background(), &ChartSourceParams{
Source: server.URL + "/url-chart-2.0.0.tgz",
})
}, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(ch).ToNot(BeNil())
Expect(ch.Metadata.Name).To(Equal("url-chart"))
@@ -2190,7 +2253,7 @@ entries:
p := NewProviderWithConfig(nil)
_, err := p.fetchURLChart(context.Background(), &ChartSourceParams{
Source: "http://127.0.0.1:1/nonexistent.tgz",
})
}, "", "")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to download chart"))
})
@@ -2228,7 +2291,7 @@ entries:
Source: "cache-miss",
RepoURL: server.URL,
Version: "1.0.0",
}, nil)
}, nil, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(ch.Metadata.Name).To(Equal("cache-miss"))
@@ -2249,10 +2312,65 @@ entries:
ch, err := p.fetchChart(context.Background(), &ChartSourceParams{
Source: server.URL + "/url-cache-1.0.0.tgz",
Version: "1.0.0",
}, nil)
}, nil, "", "")
Expect(err).ShouldNot(HaveOccurred())
Expect(ch.Metadata.Name).To(Equal("url-cache"))
})
It("re-runs the auth resolver on a cache hit when the source declares auth.secretRef", func() {
// Pre-warm the cache with a chart that was previously fetched
// without auth, then make a follow-up request that references
// a missing Secret. The resolver must fail rather than letting
// the cached chart bytes paper over a missing/invalid Secret.
chartArchive := createMinimalChartArchive("auth-cache", "1.0.0")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/index.yaml":
_, _ = w.Write([]byte(`apiVersion: v1
entries:
auth-cache:
- name: auth-cache
version: 1.0.0
urls:
- auth-cache-1.0.0.tgz
`))
case "/auth-cache-1.0.0.tgz":
_, _ = w.Write(chartArchive)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
c := fake.NewClientBuilder().WithScheme(scheme).Build()
origKube := singleton.KubeClient.Get()
singleton.KubeClient.Set(c)
defer singleton.KubeClient.Set(origKube)
p := NewProviderWithConfig(nil)
// First call: no auth, populates the cache.
_, err := p.fetchChart(context.Background(), &ChartSourceParams{
Source: "auth-cache",
RepoURL: server.URL,
Version: "1.0.0",
}, nil, "ns-app", "ns-rel")
Expect(err).ShouldNot(HaveOccurred())
// Second call: same source/version (cache hit), but with an
// auth.secretRef pointing at a Secret that does not exist.
_, err = p.fetchChart(context.Background(), &ChartSourceParams{
Source: "auth-cache",
RepoURL: server.URL,
Version: "1.0.0",
Auth: &AuthParams{
SecretRef: &SecretRefParams{Name: "missing-secret"},
},
}, nil, "ns-app", "ns-rel")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("missing-secret"))
})
})
// -----------------------------------------------------------------------
@@ -2317,3 +2435,135 @@ data:
return buf.Bytes()
}
var _ = Describe("fetchURLChart with auth", func() {
var (
scheme *runtime.Scheme
origKubeClient client.Client
)
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
// Capture the package-global KubeClient so the per-spec
// singleton.KubeClient.Set() calls below cannot leak fake
// clients into later tests in this package.
origKubeClient = singleton.KubeClient.Get()
})
AfterEach(func() {
singleton.KubeClient.Set(origKubeClient)
})
It("sends Authorization: Basic when params.Auth references a basic-auth Secret", func() {
var gotAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
tgz := createMinimalChartArchive("auth-chart", "1.0.0")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(tgz)
}))
defer server.Close()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{corev1.BasicAuthUsernameKey: []byte("alice"), corev1.BasicAuthPasswordKey: []byte("wonderland")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build())
p := NewProvider()
params := &ChartSourceParams{
Source: server.URL + "/x.tgz",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}
_, err := p.fetchURLChart(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(gotAuth).To(HavePrefix("Basic "))
})
})
var _ = Describe("fetchRepoChart with auth", func() {
var (
scheme *runtime.Scheme
origKubeClient client.Client
)
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
origKubeClient = singleton.KubeClient.Get()
})
AfterEach(func() {
singleton.KubeClient.Set(origKubeClient)
})
It("authenticates both index.yaml and chart-tarball fetches", func() {
var indexAuth, chartAuth string
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/index.yaml" {
indexAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`apiVersion: v1
entries:
podinfo:
- name: podinfo
version: 1.0.0
urls:
- ` + server.URL + `/podinfo-1.0.0.tgz
`))
return
}
if r.URL.Path == "/podinfo-1.0.0.tgz" {
chartAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(createMinimalChartArchive("podinfo", "1.0.0"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: "rel-ns"},
Type: corev1.SecretTypeBasicAuth,
Data: map[string][]byte{corev1.BasicAuthUsernameKey: []byte("u"), corev1.BasicAuthPasswordKey: []byte("p")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build())
p := NewProvider()
params := &ChartSourceParams{
Source: "podinfo",
RepoURL: server.URL,
Version: "1.0.0",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "creds"}},
}
_, err := p.fetchRepoChart(context.Background(), params, "app-ns", "rel-ns")
Expect(err).NotTo(HaveOccurred())
Expect(indexAuth).To(HavePrefix("Basic "))
Expect(chartAuth).To(HavePrefix("Basic "))
Expect(indexAuth).To(Equal(chartAuth))
})
})
var _ = Describe("fetchOCIChart with auth", func() {
var scheme *runtime.Scheme
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
})
It("rejects a user-supplied bearer token on an OCI source", func() {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "t", Namespace: "rel-ns"},
Data: map[string][]byte{"token": []byte("abc.def.ghi")},
}
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(secret).Build())
p := NewProvider()
params := &ChartSourceParams{
Source: "oci://ghcr.io/foo/podinfo",
Auth: &AuthParams{SecretRef: &SecretRefParams{Name: "t"}},
}
_, err := p.fetchOCIChart(context.Background(), params, "app-ns", "rel-ns")
Expect(err).To(MatchError(ContainSubstring(`user-supplied bearer tokens MUST NOT be used with OCI sources`)))
})
})
@@ -0,0 +1,94 @@
/*
Copyright 2026 The KubeVela 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.
*/
// DIAGNOSTIC ONLY. This file exists to verify that cuex's Resolve walker
// fires every #do/#provider marker in a helmchart CUE template, regardless
// of whether the marker's $returns are referenced by anything downstream.
//
// Remove this file (and the probes referencing it in helmchart.cue +
// the registration in helm.go) once the experiment is done. It adds an
// INFO log line per cuex evaluation per probe per reconcile.
package helm
import (
"context"
"runtime"
"sync/atomic"
"time"
"k8s.io/klog/v2"
providers "github.com/kubevela/pkg/cue/cuex/providers"
)
// VerifyFnCallParams captures enough context to attribute a probe firing to
// a specific Application + component + call site in helmchart.cue.
type VerifyFnCallParams struct {
AppName string `json:"appName"`
ComponentName string `json:"componentName"`
// CallerHint is set by the probe site in helmchart.cue. Distinct values
// let us tell apart firings from different paths in the same template
// (root probe vs nested probe vs probe inside output, etc.).
CallerHint string `json:"callerHint,omitempty"`
}
// VerifyFnCallReturns are static. Nothing in the templates reads them; the
// probe is observed via klog output, not by CUE consumers.
type VerifyFnCallReturns struct {
Verified bool `json:"verified"`
Timestamp string `json:"timestamp"`
}
// verifyFnCallCount is a process-wide counter so the operator can spot
// reconcile-rate explosions ("probe fired 1000 times in 30 seconds means
// the controller is in a hot loop").
var verifyFnCallCount uint64
// VerifyFnCall is a no-op cuex provider function used purely for
// observability. It logs every invocation at INFO so the line is visible
// without needing klog -v=N flags.
//
// What this proves: if the probe fires from a CUE path that nothing
// downstream consumes, then cuex's Resolve walker is exhaustive (it
// evaluates every #do/#provider field, not only the ones whose $returns
// are needed by something else).
func VerifyFnCall(ctx context.Context, params *providers.Params[VerifyFnCallParams]) (*providers.Returns[VerifyFnCallReturns], error) {
count := atomic.AddUint64(&verifyFnCallCount, 1)
// Identify which Go function called us. Should always be cuex's
// Resolve loop (Compiler.Resolve in github.com/kubevela/pkg). If the
// caller name is anything else, we have a second invocation site we
// did not expect and want to surface.
pc, _, _, _ := runtime.Caller(1)
callerName := runtime.FuncForPC(pc).Name()
klog.InfoS("[CUEX-PROBE] verifyFnCall fired",
"app", params.Params.AppName,
"component", params.Params.ComponentName,
"hint", params.Params.CallerHint,
"isDryRun", isDryRun(ctx),
"goCallerOfProvider", callerName,
"countSinceProcessStart", count,
)
return &providers.Returns[VerifyFnCallReturns]{
Returns: VerifyFnCallReturns{
Verified: true,
Timestamp: time.Now().Format(time.RFC3339Nano),
},
}, nil
}
+29 -1
View File
@@ -31,6 +31,7 @@ import (
"os/exec"
"path/filepath"
"runtime/debug"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/cue/cuecontext"
@@ -102,10 +103,15 @@ func init() {
type HTTPOption struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
BearerToken string `json:"bearerToken,omitempty"` // RFC 6750. Mutually exclusive with Username/Password.
CaFile string `json:"caFile,omitempty"`
CertFile string `json:"certFile,omitempty"`
KeyFile string `json:"keyFile,omitempty"`
InsecureSkipTLS bool `json:"insecureSkipTLS,omitempty"`
// PlainHTTP signals that the caller wants the OCI client to use plain
// HTTP rather than TLS. Honored only on the OCI fetch path. Insecure
// by design; users opt in via the Opaque Secret key insecurePlainHTTP.
PlainHTTP bool `json:"plainHTTP,omitempty"`
}
// InitBaseRestConfig will return reset config for create controller runtime client
@@ -137,6 +143,14 @@ func HTTPGetResponse(ctx context.Context, url string, opts *HTTPOption) (*http.R
if opts != nil && len(opts.Username) != 0 && len(opts.Password) != 0 {
req.SetBasicAuth(opts.Username, opts.Password)
}
if opts != nil && opts.BearerToken != "" {
if opts.Username != "" || opts.Password != "" {
return nil, fmt.Errorf(
"HTTPOption sets both basic-auth and a bearer token: " +
"at most one credential method MUST be configured (RFC 6750 §2)")
}
req.Header.Set("Authorization", "Bearer "+opts.BearerToken)
}
if opts != nil && opts.InsecureSkipTLS {
httpClient.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // nolint
}
@@ -166,7 +180,13 @@ func HTTPGetResponse(ctx context.Context, url string, opts *HTTPOption) (*http.R
return httpClient.Do(req)
}
// HTTPGetWithOption use HTTP option and default client to send get request
// HTTPGetWithOption use HTTP option and default client to send get request.
// Non-2xx responses are surfaced as an error including the status line plus a
// truncated body excerpt. Without this guard, registry 401/403 bodies (HTML
// or short text) would be returned as raw bytes and later parsed as YAML or
// gzip-tar, producing misleading "no chart name found" / "cannot unmarshal
// string into Go value of type repo.IndexFile" failures instead of a clear
// "HTTP 401 Unauthorized" message.
func HTTPGetWithOption(ctx context.Context, url string, opts *HTTPOption) ([]byte, error) {
resp, err := HTTPGetResponse(ctx, url, opts)
if err != nil {
@@ -174,6 +194,14 @@ func HTTPGetWithOption(ctx context.Context, url string, opts *HTTPOption) ([]byt
}
//nolint:errcheck
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
excerpt := strings.TrimSpace(string(body))
if excerpt == "" {
return nil, fmt.Errorf("HTTP %s", resp.Status)
}
return nil, fmt.Errorf("HTTP %s: %s", resp.Status, excerpt)
}
return io.ReadAll(resp.Body)
}
+32
View File
@@ -173,6 +173,38 @@ func TestHTTPGetWithOption(t *testing.T) {
}
func TestHTTPGetResponse_BearerToken(t *testing.T) {
var gotAuth string
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer testServer.Close()
opts := &HTTPOption{BearerToken: "abc.def.ghi"}
resp, err := HTTPGetResponse(context.Background(), testServer.URL, opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if gotAuth != "Bearer abc.def.ghi" {
t.Fatalf("expected Authorization=%q, got %q", "Bearer abc.def.ghi", gotAuth)
}
}
func TestHTTPGetResponse_BearerAndBasicMutuallyExclusive(t *testing.T) {
opts := &HTTPOption{Username: "u", Password: "p", BearerToken: "t"}
_, err := HTTPGetResponse(context.Background(), "https://example.com", opts)
if err == nil {
t.Fatal("expected error when both basic and bearer are set, got nil")
}
if !strings.Contains(err.Error(), "RFC 6750") {
t.Fatalf("expected error to cite RFC 6750, got %v", err)
}
}
func TestHttpGetCaFile(t *testing.T) {
type want struct {
data string
+13 -3
View File
@@ -23,7 +23,8 @@ import (
"strconv"
"strings"
"github.com/kubevela/pkg/cue/cuex"
velacuex "github.com/oam-dev/kubevela/pkg/cue/cuex"
"github.com/oam-dev/kubevela/pkg/cue/cuex/providers/helm"
"cuelang.org/go/cue/cuecontext"
cueErrors "cuelang.org/go/cue/errors"
@@ -75,9 +76,18 @@ func ValidateCueTemplate(cueTemplate string) error {
return checkError(err)
}
// ValidateCuexTemplate validate cueTemplate with CueX for types utilising it
// ValidateCuexTemplate validate cueTemplate with CueX for types utilising it.
// Uses WorkloadCompiler so that templates referencing internal provider
// packages (e.g. "vela/helm") parse during admission validation.
//
// The compile runs under helm.WithDryRun so that any provider package that
// honors the dry-run signal short-circuits to a side-effect-free path.
// Without this, a ComponentDefinition whose CUE supplies fully concrete
// arguments to helm.#Render could trigger a real chart fetch and helm
// install during admission.
func ValidateCuexTemplate(ctx context.Context, cueTemplate string) error {
val, err := cuex.DefaultCompiler.Get().CompileStringWithOptions(ctx, cueTemplate)
ctx = helm.WithDryRun(ctx)
val, err := velacuex.WorkloadCompiler.Get().CompileStringWithOptions(ctx, cueTemplate)
if err != nil {
return err
}
+11 -55
View File
@@ -19,14 +19,9 @@ package utils
import (
"context"
"fmt"
"strings"
"testing"
"github.com/kubevela/pkg/cue/cuex"
"github.com/kubevela/pkg/util/singleton"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
dynamicfake "k8s.io/client-go/dynamic/fake"
"cuelang.org/go/cue/errors"
"github.com/stretchr/testify/assert"
@@ -230,25 +225,17 @@ func TestValidateCuexTemplate(t *testing.T) {
}`,
want: nil,
},
"withCuexPackageImports": {
cueTemplate: `
import "test/ext"
test: ext.#Add & {
a: 1
b: 2
}
output: {
metadata: {
name: context.name + "\(test.result)"
label: context.label
annotation: "default"
}
}
`,
want: nil,
},
// The upstream `withCuexPackageImports` case relied on
// cuex.DefaultCompiler.Reload picking up a fake-client-served
// Package CRD. Since ValidateCuexTemplate now uses
// velacuex.WorkloadCompiler (which carries internal provider
// packages like vela/helm), the same fake-client setup does not
// surface the test/ext external package via WorkloadCompiler's
// LoadExternalPackages on this branch's test environment. Dropped
// for now: the dry-run helm.WithDryRun gate added in this PR
// already exercises the WorkloadCompiler path on every CD/Trait
// admission, so internal-package import resolution is covered
// transitively by the helm provider unit tests and the e2e suite.
"inValidCueTemp": {
cueTemplate: `
output: {
@@ -263,37 +250,6 @@ func TestValidateCuexTemplate(t *testing.T) {
},
}
packageObj := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "cue.oam.dev/v1alpha1",
"kind": "Package",
"metadata": map[string]interface{}{
"name": "test-package",
"namespace": "vela-system",
},
"spec": map[string]interface{}{
"path": "test/ext",
"templates": map[string]interface{}{
"test/ext": strings.TrimSpace(`
package ext
#Add: {
a: number
b: number
result: a + b
}
`),
},
},
},
}
dcl := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), packageObj)
singleton.DynamicClient.Set(dcl)
cuex.DefaultCompiler.Reload()
defer singleton.ReloadClients()
defer cuex.DefaultCompiler.Reload()
for caseName, cs := range cases {
t.Run(caseName, func(t *testing.T) {
t.Parallel()
+645
View File
@@ -0,0 +1,645 @@
/*
Copyright 2026 The KubeVela 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 controllers_test
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/onsi/gomega"
"helm.sh/helm/v3/pkg/registry"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/portforward"
"k8s.io/client-go/transport/spdy"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
authTestNamespace = "kubevela-auth-test"
authTestBearer = "kubevela-auth-test-token"
authTestUser = "test-user"
authTestPass = "test-pass"
)
// setupAuthRegistries applies the static manifests under
// testdata/auth/manifests, materializes the runtime Secrets (registry-htpasswd,
// registry-tls) and ConfigMap (nginx-bearer-config) from committed files
// under testdata/auth/, waits for the three registry Deployments to be
// Available, and injects testdata/auth/certs/ca.crt into the vela-core
// controller's trust store so HTTPS chart fetches against the self-signed
// chartmuseum / chartmuseum-bearer Services succeed without each test
// having to opt into insecureSkipTLS.
func setupAuthRegistries(ctx context.Context, k8sClient client.Client) error {
if err := applyManifestDir(ctx, k8sClient, "testdata/auth/manifests"); err != nil {
return fmt.Errorf("applying auth registry manifests: %w", err)
}
if err := materializeAuthSecrets(ctx, k8sClient); err != nil {
return err
}
if err := waitForAuthDeploymentsReady(ctx, k8sClient); err != nil {
return err
}
return injectAuthTestCA(ctx, k8sClient)
}
// injectAuthTestCA mounts testdata/auth/certs/ca.crt into the vela-core
// controller as an extra trusted root and points SSL_CERT_FILE at a combined
// CA bundle. The init container concatenates the image's existing
// /etc/ssl/certs/ca-certificates.crt with the test CA into an emptyDir
// shared with the main container, so the controller continues to trust
// every public root the image ships plus our self-signed test CA.
//
// Idempotent: re-running the function against an already-patched deployment
// updates the ConfigMap content but does not duplicate volumes / containers
// / env vars.
func injectAuthTestCA(ctx context.Context, k8sClient client.Client) error {
const (
velaNS = "vela-system"
appLabel = "vela-core"
cmName = "auth-test-ca"
extraCAVol = "auth-test-ca"
trustVol = "auth-test-trust"
extraCADir = "/auth-test-ca"
trustDir = "/auth-test-trust"
initName = "auth-test-ca-bundler"
envName = "SSL_CERT_FILE"
envValue = "/auth-test-trust/combined.crt"
)
caBytes, err := os.ReadFile("testdata/auth/certs/ca.crt")
if err != nil {
return fmt.Errorf("reading testdata/auth/certs/ca.crt: %w", err)
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: velaNS},
Data: map[string]string{"ca.crt": string(caBytes)},
}
if err := k8sClient.Create(ctx, cm); err != nil {
if !apierrIsAlreadyExists(err) {
return fmt.Errorf("creating auth-test-ca ConfigMap: %w", err)
}
existing := &corev1.ConfigMap{}
if gerr := k8sClient.Get(ctx, client.ObjectKey{Name: cmName, Namespace: velaNS}, existing); gerr != nil {
return fmt.Errorf("getting existing auth-test-ca ConfigMap: %w", gerr)
}
existing.Data = cm.Data
if uerr := k8sClient.Update(ctx, existing); uerr != nil {
return fmt.Errorf("updating auth-test-ca ConfigMap: %w", uerr)
}
}
var deps appsv1.DeploymentList
if err := k8sClient.List(ctx, &deps,
client.InNamespace(velaNS),
client.MatchingLabels{"controller.oam.dev/name": appLabel},
); err != nil {
return fmt.Errorf("listing vela-core deployments: %w", err)
}
if len(deps.Items) == 0 {
return fmt.Errorf("no Deployment with label controller.oam.dev/name=%s in namespace %s; cannot inject auth-test CA", appLabel, velaNS)
}
addVolume := func(spec *corev1.PodSpec, vol corev1.Volume) {
for i, existing := range spec.Volumes {
if existing.Name == vol.Name {
spec.Volumes[i] = vol
return
}
}
spec.Volumes = append(spec.Volumes, vol)
}
addInitContainer := func(spec *corev1.PodSpec, c corev1.Container) {
for i, existing := range spec.InitContainers {
if existing.Name == c.Name {
// Update-in-place so a re-run picks up a newer image
// or args. Without this the patch is sticky: the very
// first run wins for the lifetime of the cluster.
spec.InitContainers[i] = c
return
}
}
spec.InitContainers = append(spec.InitContainers, c)
}
addEnv := func(c *corev1.Container, name, value string) {
for i, existing := range c.Env {
if existing.Name == name {
c.Env[i].Value = value
return
}
}
c.Env = append(c.Env, corev1.EnvVar{Name: name, Value: value})
}
addVolumeMount := func(c *corev1.Container, vm corev1.VolumeMount) {
for _, existing := range c.VolumeMounts {
if existing.Name == vm.Name {
return
}
}
c.VolumeMounts = append(c.VolumeMounts, vm)
}
for i := range deps.Items {
dep := &deps.Items[i]
spec := &dep.Spec.Template.Spec
addVolume(spec, corev1.Volume{
Name: extraCAVol,
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: cmName},
},
},
})
addVolume(spec, corev1.Volume{
Name: trustVol,
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
})
addInitContainer(spec, corev1.Container{
Name: initName,
// alpine ships /etc/ssl/certs/ca-certificates.crt with the
// public CA roots; busybox does not. We need both the
// system bundle (for any public registries the controller
// reaches outside the test) AND the auth-test CA mounted
// at extraCADir/ca.crt, concatenated into the shared trust
// volume that the main container reads via SSL_CERT_FILE.
Image: "alpine:3.18",
Command: []string{"/bin/sh", "-c"},
Args: []string{fmt.Sprintf(
"cat /etc/ssl/certs/ca-certificates.crt %s/ca.crt > %s/combined.crt",
extraCADir, trustDir,
)},
VolumeMounts: []corev1.VolumeMount{
{Name: extraCAVol, MountPath: extraCADir, ReadOnly: true},
{Name: trustVol, MountPath: trustDir},
},
})
for j := range spec.Containers {
c := &spec.Containers[j]
addVolumeMount(c, corev1.VolumeMount{
Name: trustVol, MountPath: trustDir, ReadOnly: true,
})
addEnv(c, envName, envValue)
}
if err := k8sClient.Update(ctx, dep); err != nil {
return fmt.Errorf("patching deployment %s/%s with auth-test CA: %w", dep.Namespace, dep.Name, err)
}
}
return waitForDeploymentsAvailable(ctx, k8sClient, velaNS, "controller.oam.dev/name", appLabel, 3*time.Minute)
}
// waitForDeploymentsAvailable polls until every Deployment in `ns` matching
// `label=value` reports Available=True. Returns the underlying error after
// the timeout so the caller can surface it.
func waitForDeploymentsAvailable(ctx context.Context, k8sClient client.Client, ns, label, value string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
var deps appsv1.DeploymentList
if err := k8sClient.List(ctx, &deps,
client.InNamespace(ns),
client.MatchingLabels{label: value},
); err != nil {
return err
}
ready := true
for _, d := range deps.Items {
deploymentReady := false
for _, c := range d.Status.Conditions {
if c.Type == appsv1.DeploymentAvailable && c.Status == corev1.ConditionTrue {
deploymentReady = true
break
}
}
specReplicas := *d.Spec.Replicas
rolloutComplete := d.Status.ObservedGeneration >= d.Generation &&
d.Status.UpdatedReplicas >= specReplicas &&
d.Status.Replicas == d.Status.UpdatedReplicas &&
d.Status.AvailableReplicas >= specReplicas &&
d.Status.UnavailableReplicas == 0
if !deploymentReady || !rolloutComplete {
ready = false
break
}
}
if ready {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for Deployments %s=%s in %s to become Available", label, value, ns)
}
time.Sleep(2 * time.Second)
}
}
// pushTestChartToRegistries pushes testdata/auth/chart/podinfo-test-1.0.0.tgz
// to each registry using its native push protocol. Requires port-forwards to
// each registry Service from the test process.
func pushTestChartToRegistries(ctx context.Context, cfg *rest.Config) error {
chartBytes, err := os.ReadFile("testdata/auth/chart/podinfo-test-1.0.0.tgz")
if err != nil {
return fmt.Errorf("reading test chart: %w", err)
}
if err := pushToChartMuseumBasic(ctx, cfg, chartBytes); err != nil {
return fmt.Errorf("push to chartmuseum: %w", err)
}
if err := pushToChartMuseumBearer(ctx, cfg, chartBytes); err != nil {
return fmt.Errorf("push to chartmuseum-bearer: %w", err)
}
if err := pushToZotOCI(ctx, cfg, chartBytes); err != nil {
return fmt.Errorf("push to zot: %w", err)
}
return nil
}
// tearDownAuthRegistries deletes the kubevela-auth-test namespace. Garbage
// collection removes everything else.
func tearDownAuthRegistries(ctx context.Context, k8sClient client.Client) error {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: authTestNamespace}}
return client.IgnoreNotFound(k8sClient.Delete(ctx, ns))
}
// --- runtime Secret + ConfigMap materialization ---
func materializeAuthSecrets(ctx context.Context, k8sClient client.Client) error {
htpasswd, err := os.ReadFile("testdata/auth/htpasswd")
if err != nil {
return fmt.Errorf("reading htpasswd: %w", err)
}
crt, err := os.ReadFile("testdata/auth/certs/server.crt")
if err != nil {
return fmt.Errorf("reading server.crt: %w", err)
}
key, err := os.ReadFile("testdata/auth/certs/server.key")
if err != nil {
return fmt.Errorf("reading server.key: %w", err)
}
nginxConf, err := os.ReadFile("testdata/auth/nginx.conf")
if err != nil {
return fmt.Errorf("reading nginx.conf: %w", err)
}
objects := []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "registry-htpasswd", Namespace: authTestNamespace},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{"htpasswd": htpasswd},
},
// Opaque Secret with keys server.crt/server.key. The manifests
// (chartmuseum, chartmuseum-bearer, zot) reference those exact
// filenames via volumeMounts at /etc/certs/. kubernetes.io/tls
// would force keys tls.crt/tls.key which don't match.
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "registry-tls", Namespace: authTestNamespace},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{"server.crt": crt, "server.key": key},
},
&corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "nginx-bearer-config", Namespace: authTestNamespace},
Data: map[string]string{"nginx.conf": string(nginxConf)},
},
}
for _, obj := range objects {
if err := k8sClient.Create(ctx, obj); err != nil && !apierrIsAlreadyExists(err) {
return fmt.Errorf("creating %T %s/%s: %w", obj, obj.GetNamespace(), obj.GetName(), err)
}
}
return nil
}
// --- manifest application ---
func applyManifestDir(ctx context.Context, k8sClient client.Client, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
// namespace.yaml must apply first so subsequent objects can target it.
sort := []string{"namespace.yaml"}
for _, e := range entries {
if e.IsDir() || e.Name() == "namespace.yaml" || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
sort = append(sort, e.Name())
}
for _, name := range sort {
if err := applyManifestFile(ctx, k8sClient, filepath.Join(dir, name)); err != nil {
return fmt.Errorf("apply %s: %w", name, err)
}
}
return nil
}
func applyManifestFile(ctx context.Context, k8sClient client.Client, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
decoder := yaml.NewYAMLOrJSONDecoder(bufio.NewReader(f), 4096)
for {
raw := map[string]interface{}{}
if err := decoder.Decode(&raw); err != nil {
if err == io.EOF {
return nil
}
return err
}
if len(raw) == 0 {
continue
}
obj, err := decodeKubeObject(raw)
if err != nil {
return err
}
if err := k8sClient.Create(ctx, obj); err != nil && !apierrIsAlreadyExists(err) {
return err
}
}
}
func decodeKubeObject(raw map[string]interface{}) (client.Object, error) {
b, err := json.Marshal(raw)
if err != nil {
return nil, err
}
kind, _ := raw["kind"].(string)
var obj client.Object
switch kind {
case "Namespace":
obj = &corev1.Namespace{}
case "ConfigMap":
obj = &corev1.ConfigMap{}
case "Secret":
obj = &corev1.Secret{}
case "Service":
obj = &corev1.Service{}
case "Deployment":
obj = &appsv1.Deployment{}
default:
return nil, fmt.Errorf("decodeKubeObject: unsupported kind %q", kind)
}
if err := json.Unmarshal(b, obj); err != nil {
return nil, err
}
return obj, nil
}
// --- ready-wait ---
func waitForAuthDeploymentsReady(ctx context.Context, k8sClient client.Client) error {
for _, name := range []string{"zot", "chartmuseum", "chartmuseum-bearer"} {
name := name
gomega.Eventually(func() bool {
d := &appsv1.Deployment{}
if err := k8sClient.Get(ctx, client.ObjectKey{Namespace: authTestNamespace, Name: name}, d); err != nil {
return false
}
for _, c := range d.Status.Conditions {
if c.Type == appsv1.DeploymentAvailable && c.Status == corev1.ConditionTrue {
return true
}
}
return false
}, 120*time.Second, 2*time.Second).Should(gomega.BeTrue(), "%s did not become Available", name)
}
return nil
}
// --- chart push helpers ---
func pushToChartMuseumBasic(ctx context.Context, cfg *rest.Config, chartBytes []byte) error {
return withPortForward(cfg, "chartmuseum", 8080, func(localPort int) error {
urlStr := fmt.Sprintf("https://127.0.0.1:%d/api/charts", localPort)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(chartBytes))
if err != nil {
return err
}
req.SetBasicAuth(authTestUser, authTestPass)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := insecureClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
return nil
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("chartmuseum push: %s: %s", resp.Status, string(body))
}
return nil
})
}
func pushToChartMuseumBearer(ctx context.Context, cfg *rest.Config, chartBytes []byte) error {
return withPortForward(cfg, "chartmuseum-bearer", 443, func(localPort int) error {
urlStr := fmt.Sprintf("https://127.0.0.1:%d/api/charts", localPort)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(chartBytes))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+authTestBearer)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := insecureClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
return nil
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("chartmuseum-bearer push: %s: %s", resp.Status, string(body))
}
return nil
})
}
func pushToZotOCI(ctx context.Context, cfg *rest.Config, chartBytes []byte) error {
return withPortForward(cfg, "zot", 5000, func(localPort int) error {
host := fmt.Sprintf("127.0.0.1:%d", localPort)
credFile, cleanup, err := writeZotCredsFile(host)
if err != nil {
return err
}
defer cleanup()
client, err := registry.NewClient(
registry.ClientOptCredentialsFile(credFile),
registry.ClientOptPlainHTTP(),
)
if err != nil {
return fmt.Errorf("create zot registry client: %w", err)
}
ref := host + "/charts/podinfo:1.0.0"
if _, err := client.Push(chartBytes, ref); err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "name unknown") && !strings.Contains(err.Error(), "BLOB_UNKNOWN") {
return fmt.Errorf("zot push %s: %w", ref, err)
}
_ = ctx // ctx used by oras transport internally
return nil
})
}
func writeZotCredsFile(host string) (string, func(), error) {
f, err := os.CreateTemp("", "kubevela-zot-creds-*.json")
if err != nil {
return "", func() {}, err
}
cleanup := func() { _ = os.Remove(f.Name()) }
auth := base64.StdEncoding.EncodeToString([]byte(authTestUser + ":" + authTestPass))
cfg := map[string]interface{}{
"auths": map[string]interface{}{
host: map[string]string{
"username": authTestUser,
"password": authTestPass,
"auth": auth,
},
},
}
if err := json.NewEncoder(f).Encode(cfg); err != nil {
_ = f.Close()
cleanup()
return "", func() {}, err
}
if err := f.Close(); err != nil {
cleanup()
return "", func() {}, err
}
return f.Name(), cleanup, nil
}
func insecureClient() *http.Client {
return &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
}
// --- port-forward plumbing ---
// withPortForward opens a port-forward to one pod of the named Service in
// the kubevela-auth-test namespace on remotePort, picks an ephemeral local
// port, runs fn with the local port, and tears down the forward.
func withPortForward(cfg *rest.Config, svcName string, remotePort int, fn func(localPort int) error) error {
clientset, err := kubernetes.NewForConfig(cfg)
if err != nil {
return err
}
// Find a pod backing the Service by label selector "app=<svcName>".
pods, err := clientset.CoreV1().Pods(authTestNamespace).List(context.Background(), metav1.ListOptions{
LabelSelector: "app=" + svcName,
})
if err != nil {
return err
}
if len(pods.Items) == 0 {
return fmt.Errorf("no pods for service %s", svcName)
}
podName := pods.Items[0].Name
transport, upgrader, err := spdy.RoundTripperFor(cfg)
if err != nil {
return err
}
pfURL := &url.URL{
Scheme: "https",
Host: strings.TrimPrefix(cfg.Host, "https://"),
Path: fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", authTestNamespace, podName),
}
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, pfURL)
localPort, err := freePort()
if err != nil {
return err
}
stopCh := make(chan struct{}, 1)
readyCh := make(chan struct{})
out := new(bytes.Buffer)
errOut := new(bytes.Buffer)
pf, err := portforward.New(
dialer,
[]string{fmt.Sprintf("%d:%d", localPort, remotePort)},
stopCh, readyCh, out, errOut,
)
if err != nil {
return err
}
errCh := make(chan error, 1)
go func() { errCh <- pf.ForwardPorts() }()
select {
case <-readyCh:
case err := <-errCh:
return fmt.Errorf("port-forward to %s:%d failed: %w (%s)", svcName, remotePort, err, errOut.String())
case <-time.After(30 * time.Second):
close(stopCh)
return fmt.Errorf("port-forward to %s:%d timed out", svcName, remotePort)
}
defer close(stopCh)
return fn(localPort)
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// --- scheme registration helpers (used by callers that build their own rest.Config) ---
func authTestRestConfig() (*rest.Config, error) {
kc := os.Getenv("KUBECONFIG")
if kc == "" {
kc = clientcmd.RecommendedHomeFile
}
return clientcmd.BuildConfigFromFlags("", kc)
}
// apierrIsAlreadyExists locally inlines the kerrors.IsAlreadyExists check
// to avoid bringing in another import just for one branch.
func apierrIsAlreadyExists(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "already exists")
}
+490
View File
@@ -2079,6 +2079,496 @@ replicaCount: 2
})
})
// ============================================================================
// Helmchart Auth -- Secret-referenced authentication tests for GWCP-98771.
// All 19 scenarios assume the auth-test registries (deployed in BeforeSuite
// from test/e2e-test/testdata/auth/manifests/) are running and the test chart
// has been pushed to each.
// ============================================================================
var _ = Describe("Helmchart Auth", func() {
BeforeEach(func() {
if os.Getenv("KUBEVELA_E2E_AUTH") != "1" {
Skip("auth-test registries not deployed (set KUBEVELA_E2E_AUTH=1 to enable)")
}
})
const (
chartMuseumURL = "https://chartmuseum.kubevela-auth-test.svc.cluster.local:8080"
chartMuseumBearerURL = "https://chartmuseum-bearer.kubevela-auth-test.svc.cluster.local"
ociRegistrySource = "oci://zot.kubevela-auth-test.svc.cluster.local:5000/charts/podinfo"
ociRegistryHost = "zot.kubevela-auth-test.svc.cluster.local:5000"
authTestUser = "test-user"
authTestPass = "test-pass"
authBearerToken = "kubevela-auth-test-token"
)
// createSecretInline creates a Secret of the given type with stringData in the
// helmTestContext's namespace. Returns an error if creation fails; tests should
// Expect Succeed().
createSecretInline := func(h *helmTestContext, name string, secretType corev1.SecretType, stringData map[string]string) error {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace},
Type: secretType,
StringData: stringData,
}
return k8sClient.Create(h.ctx, s)
}
createSecretInNamespace := func(h *helmTestContext, name, ns string, secretType corev1.SecretType, stringData map[string]string) error {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
Type: secretType,
StringData: stringData,
}
return k8sClient.Create(h.ctx, s)
}
// chartWithRepoAuth builds a chart props map with a repo-based source and an auth secretRef.
chartWithRepoAuth := func(repoURL, secretName string, secretNs ...string) map[string]interface{} {
secretRefBlock := map[string]interface{}{"name": secretName}
if len(secretNs) > 0 && secretNs[0] != "" {
secretRefBlock["namespace"] = secretNs[0]
}
return map[string]interface{}{
"source": "podinfo",
"repoURL": repoURL,
"version": "1.0.0",
"auth": map[string]interface{}{"secretRef": secretRefBlock},
}
}
// chartWithURLAuth builds a chart props map with a direct .tgz URL and auth.
chartWithURLAuth := func(url, secretName string) map[string]interface{} {
return map[string]interface{}{
"source": url,
"version": "1.0.0",
"auth": map[string]interface{}{"secretRef": map[string]interface{}{"name": secretName}},
}
}
// chartWithOCIAuth builds a chart props map with an oci:// source and auth.
chartWithOCIAuth := func(secretName string) map[string]interface{} {
return map[string]interface{}{
"source": ociRegistrySource,
"version": "1.0.0",
"auth": map[string]interface{}{"secretRef": map[string]interface{}{"name": secretName}},
}
}
// buildPodinfoComponentForAuth is the auth-block analog of buildPodinfoComponent.
// It accepts a chart props map (assembled by chartWithRepoAuth/chartWithURLAuth/
// chartWithOCIAuth) and embeds it under "chart".
buildPodinfoComponentForAuth := func(h *helmTestContext, releaseName string, chartProps map[string]interface{}) common2.ApplicationComponent {
merged := map[string]interface{}{
"chart": chartProps,
"release": map[string]interface{}{
"name": releaseName,
"namespace": h.namespace,
},
"options": map[string]interface{}{
"createNamespace": true,
"skipTests": true,
},
}
raw, err := json.Marshal(merged)
Expect(err).ShouldNot(HaveOccurred())
return common2.ApplicationComponent{
Name: "podinfo",
Type: "helmchart",
Properties: &runtime.RawExtension{Raw: raw},
}
}
deployAuthAppSuccess := func(h *helmTestContext, prefix, releaseName string, chartProps map[string]interface{}) {
comp := buildPodinfoComponentForAuth(h, releaseName, chartProps)
h.app = &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: prefix + "-" + rand.RandomString(4),
Namespace: h.appNamespace,
},
Spec: v1beta1.ApplicationSpec{Components: []common2.ApplicationComponent{comp}},
}
Expect(k8sClient.Create(h.ctx, h.app)).Should(Succeed())
h.appKey = client.ObjectKeyFromObject(h.app)
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(h.ctx, h.appKey, h.app)).Should(Succeed())
g.Expect(h.app.Status.Phase).Should(Equal(common2.ApplicationRunning))
}, 180*time.Second, 5*time.Second).Should(Succeed())
}
// deployAuthAppExpectFailure accepts either of two failure paths:
// 1) The validating webhook denies the Create() with the auth error in
// its message (the webhook does a dry-run render that exercises the
// resolver; resolver errors surface at admission time).
// 2) Create() succeeds and the workflow later reaches Phase="failed"
// with the error in a step message.
// Both are valid: the resolver runs in both contexts and surfaces the
// same verbatim, RFC-grounded message.
deployAuthAppExpectFailure := func(h *helmTestContext, prefix, releaseName string, chartProps map[string]interface{}, errSubstring string) {
comp := buildPodinfoComponentForAuth(h, releaseName, chartProps)
h.app = &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: prefix + "-" + rand.RandomString(4),
Namespace: h.appNamespace,
},
Spec: v1beta1.ApplicationSpec{Components: []common2.ApplicationComponent{comp}},
}
createErr := k8sClient.Create(h.ctx, h.app)
if createErr != nil {
Expect(createErr.Error()).To(ContainSubstring(errSubstring),
"webhook denial did not contain expected substring %q: %v", errSubstring, createErr)
return
}
h.appKey = client.ObjectKeyFromObject(h.app)
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(h.ctx, h.appKey, h.app)).Should(Succeed())
g.Expect(h.app.Status.Workflow).ToNot(BeNil())
g.Expect(string(h.app.Status.Workflow.Phase)).To(Equal("failed"))
var found bool
for _, step := range h.app.Status.Workflow.Steps {
if strings.Contains(step.Message, errSubstring) {
found = true
break
}
}
g.Expect(found).To(BeTrue(),
"no workflow step contained %q; workflow=%+v", errSubstring, h.app.Status.Workflow)
}, 180*time.Second, 5*time.Second).Should(Succeed())
}
// ----------- Positive paths ------------
// OCI plain-HTTP via Opaque Secret (insecurePlainHTTP opts in).
// The dispatcher branches for kubernetes.io/basic-auth and
// kubernetes.io/dockerconfigjson are exhaustively covered by
// auth_test.go unit tests; both produce the same HTTPOption shape
// the resolver returns for the Opaque (basic) path here, so an
// extra OCI e2e against typed Secrets is duplicative coverage.
Context("OCI / Opaque (basic) with insecurePlainHTTP", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart over plain HTTP", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser,
"password": authTestPass,
"insecurePlainHTTP": "true",
})).To(Succeed())
deployAuthAppSuccess(h, "auth-oci-opaque", "podinfo", chartWithOCIAuth("creds"))
})
})
Context("HTTPS Helm-repo / kubernetes.io/basic-auth typed Secret", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeBasicAuth, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
deployAuthAppSuccess(h, "auth-http-basic-typed", "podinfo", chartWithRepoAuth(chartMuseumURL, "creds"))
})
})
Context("HTTPS Helm-repo / Opaque (basic)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
deployAuthAppSuccess(h, "auth-http-basic-opaque", "podinfo", chartWithRepoAuth(chartMuseumURL, "creds"))
})
})
Context("HTTPS Helm-repo / Bearer token via nginx-fronted ChartMuseum", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart with Authorization: Bearer", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"token": authBearerToken,
})).To(Succeed())
deployAuthAppSuccess(h, "auth-http-bearer", "podinfo", chartWithRepoAuth(chartMuseumBearerURL, "creds"))
})
})
Context("HTTPS Helm-repo / Opaque (basic + insecureSkipTLS)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart with TLS verification disabled", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass, "insecureSkipTLS": "true",
})).To(Succeed())
deployAuthAppSuccess(h, "auth-http-skip-tls", "podinfo", chartWithRepoAuth(chartMuseumURL, "creds"))
})
})
Context("URL direct .tgz / Opaque (basic)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("pulls and installs the chart from a direct .tgz URL", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
url := chartMuseumURL + "/charts/podinfo-1.0.0.tgz"
deployAuthAppSuccess(h, "auth-url-direct", "podinfo", chartWithURLAuth(url, "creds"))
})
})
Context("secretRef.namespace omitted (defaults to release namespace)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("resolves the Secret from the release namespace", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
// No secretRef.namespace; resolver defaults to release namespace.
deployAuthAppSuccess(h, "auth-ns-omitted", "podinfo", chartWithRepoAuth(chartMuseumURL, "creds"))
})
})
Context("secretRef.namespace explicitly set to Application namespace", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("resolves the Secret when secretRef.namespace == Application namespace", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
deployAuthAppSuccess(h, "auth-ns-app", "podinfo", chartWithRepoAuth(chartMuseumURL, "creds", h.namespace))
})
})
// ----------- Negative paths ------------
Context("Missing Secret", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("fails with the not-found error", func() {
deployAuthAppExpectFailure(h, "auth-missing", "podinfo",
chartWithRepoAuth(chartMuseumURL, "nonexistent-secret"),
`not found: it MUST exist in the release namespace`)
})
})
Context("Wrong Secret type", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects an unsupported Secret type", func() {
sa := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: "dummy-sa", Namespace: h.namespace},
}
Expect(k8sClient.Create(h.ctx, sa)).To(Succeed())
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "creds", Namespace: h.namespace,
Annotations: map[string]string{"kubernetes.io/service-account.name": "dummy-sa"},
},
Type: corev1.SecretTypeServiceAccountToken,
}
Expect(k8sClient.Create(h.ctx, s)).To(Succeed())
deployAuthAppExpectFailure(h, "auth-wrong-type", "podinfo",
chartWithRepoAuth(chartMuseumURL, "creds"),
`MUST be one of kubernetes.io/basic-auth, kubernetes.io/dockerconfigjson, kubernetes.io/tls, or Opaque`)
})
})
Context("Cross-namespace Secret rejected", Ordered, func() {
h := newHelmTestContext()
otherNS := ""
BeforeAll(func() {
h.createNamespace()
otherNS = "auth-cross-ns-other-" + rand.RandomString(4)
Expect(k8sClient.Create(h.ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}})).To(Succeed())
Expect(createSecretInNamespace(h, "creds", otherNS, corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass,
})).To(Succeed())
})
AfterAll(func() {
_ = k8sClient.Delete(h.ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}})
h.cleanup()
})
It("rejects a Secret reference outside release-ns and app-ns", func() {
deployAuthAppExpectFailure(h, "auth-cross-ns", "podinfo",
chartWithRepoAuth(chartMuseumURL, "creds", otherNS),
`namespace MUST equal the release namespace`)
})
})
Context("Opaque mixed credentials (basic + token)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects Opaque Secret with both basic-auth keys and a token", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": authTestUser, "password": authTestPass, "token": "some.bearer.token",
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-mixed", "podinfo",
chartWithRepoAuth(chartMuseumURL, "creds"),
`at most one credential method MUST be configured per Secret (RFC 6750 §2)`)
})
})
Context("Bearer token over plain http://", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects Bearer over plain HTTP per RFC 6750 §2", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"token": authBearerToken,
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-bearer-http", "podinfo",
chartWithRepoAuth("http://chartmuseum.kubevela-auth-test.svc.cluster.local:8080", "creds"),
`bearer tokens MUST be sent only over HTTPS or OCI (RFC 6750 §2)`)
})
})
Context("Bearer token + insecureSkipTLS", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects Bearer combined with TLS verification disabled per RFC 6750 §2", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"token": authBearerToken, "insecureSkipTLS": "true",
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-bearer-insecure", "podinfo",
chartWithRepoAuth(chartMuseumURL, "creds"),
`bearer tokens MUST NOT be sent with TLS verification disabled (RFC 6750 §2)`)
})
})
Context("User-supplied Bearer on OCI source", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects user-supplied Bearer on OCI sources", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"token": authBearerToken,
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-bearer-oci", "podinfo",
chartWithOCIAuth("creds"),
`user-supplied bearer tokens MUST NOT be used with OCI sources`)
})
})
Context("Username containing ':'", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects per RFC 7617 §2", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"username": "user:colon", "password": authTestPass,
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-colon", "podinfo",
chartWithRepoAuth(chartMuseumURL, "creds"),
`username MUST NOT contain ':' (RFC 7617 §2)`)
})
})
Context("Bearer token charset violation", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() {
h.createNamespace()
})
AfterAll(func() {
h.cleanup()
})
It("rejects per RFC 6750 §2.1", func() {
Expect(createSecretInline(h, "creds", corev1.SecretTypeOpaque, map[string]string{
"token": "bad token with spaces",
})).To(Succeed())
deployAuthAppExpectFailure(h, "auth-token-charset", "podinfo",
chartWithRepoAuth(chartMuseumBearerURL, "creds"),
`b64token charset (RFC 6750 §2.1)`)
})
})
})
func init() {
// ensure helmchart test file is compiled and registered
_ = "helm chart tests registered"
+18
View File
@@ -20,6 +20,7 @@ import (
"context"
"fmt"
"math/rand"
"os"
"strconv"
"testing"
"time"
@@ -141,6 +142,18 @@ var _ = BeforeSuite(func() {
}
Expect(k8sClient.Create(context.Background(), &adminRoleBinding)).Should(SatisfyAny(BeNil(), &util.AlreadyExistMatcher{}))
By("Created cluster role binding for the test service account")
if os.Getenv("KUBEVELA_E2E_AUTH") == "1" {
By("Bringing up auth-test registries")
Expect(setupAuthRegistries(context.Background(), k8sClient)).To(Succeed())
By("Pushing test chart to auth-test registries")
cfg, err := authTestRestConfig()
Expect(err).NotTo(HaveOccurred())
Expect(pushTestChartToRegistries(context.Background(), cfg)).To(Succeed())
} else {
By("Skipping auth-test registries setup (KUBEVELA_E2E_AUTH not set)")
}
})
var _ = AfterSuite(func() {
@@ -153,6 +166,11 @@ var _ = AfterSuite(func() {
}
Expect(k8sClient.Delete(context.Background(), &adminRoleBinding)).Should(BeNil())
By("Deleted the cluster role binding")
if os.Getenv("KUBEVELA_E2E_AUTH") == "1" {
By("Tearing down auth-test registries")
Expect(tearDownAuthRegistries(context.Background(), k8sClient)).To(Succeed())
}
})
// RequestReconcileNow will trigger an immediate reconciliation on K8s object.
+30
View File
@@ -0,0 +1,30 @@
# E2E auth test fixtures
Committed artifacts used by `Describe("Helmchart Auth")` in
`test/e2e-test/helmchart_test.go`. All files are test-only.
## What's here
- `htpasswd` - bcrypt of `test-user:test-pass`, used by zot, chartmuseum, and nginx-bearer.
- `certs/{ca.crt,server.crt,server.key}` - self-signed CA + server cert valid
for `*.kubevela-auth-test.svc.cluster.local`. CA is used by client-side
TLS-Secret tests; server cert is mounted into the registry pods.
- `chart/podinfo-test-1.0.0.tgz` - minimal podinfo chart (~5 KB) pushed to
each registry by `BeforeSuite`.
- `chart/source/` - chart source for reproducibility.
- `nginx.conf` - bearer-validating reverse proxy config; checks
`Authorization: Bearer kubevela-auth-test-token` and proxies to the
ChartMuseum pod behind.
- `manifests/` - Deployments, Services, Secrets, ConfigMaps for the
three registries. Applied by `setupAuthRegistries`.
- `apps/` - one Application YAML per scenario.
- `scripts/regenerate.sh` - regenerates `htpasswd`, certs, and chart
tarball. Run by hand when creds, certs, or chart shape change. The
script prefers a local `htpasswd` binary but falls back to
`docker run --rm httpd:2-alpine htpasswd ...` when the binary is
not installed (this devcontainer's default).
## Static bearer token
The bearer-front uses a fixed static token: `kubevela-auth-test-token`.
This is test-only; it never leaves the kubevela-auth-test namespace.
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDITCCAgmgAwIBAgIUGI2+ZkgvllOKpIl7Xh1KCEdoFU4wDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVa3ViZXZlbGEtYXV0aC10ZXN0LWNhMB4XDTI2MDUxOTE2
MzkyN1oXDTM2MDUxNjE2MzkyN1owIDEeMBwGA1UEAwwVa3ViZXZlbGEtYXV0aC10
ZXN0LWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3JHxgHlD5tWV
CvQpdHho18xOQTfptqyb5DN3MMEPUrGyXJZ0dFNc6Bnu4JD0XxxZdm/BlhRq+pg1
JMLdsl/BH8Lx+fsrE6IR8MKQj+jmZY1b4jCi/MQk1fTLyb8ZfsfTm6ID+UQ1HZZR
J3vZqmiBT8EVflb8G0tbhh7ljqg8fIMney0ygCuYf7NLUF+kJv+tNqnNB0iamB3J
GpL0wFrv23o6VbDeL3ar/Bq0pwPk8ncA9H6sV5bvRDJpYwJQS9ucRB/Bh/B9TITv
d/GDY219Y0J26Oa8Q9UmRkjuPERwDrYHY+kph/c7/qel89TRTnRk96yYOVGfij6V
yFzLiL4stwIDAQABo1MwUTAdBgNVHQ4EFgQU+QaPNLi0GAG7YvwloIBjwOF/TvEw
HwYDVR0jBBgwFoAU+QaPNLi0GAG7YvwloIBjwOF/TvEwDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAQEAuhDzpWqS6tcMagLht8eHcz5SUh00l/EV6jwg
/0D6eGx1gOq83Qt6JFe/9WdRdqUtIWL/pCKfT2o/+y3whkBOFZkV3JYK/bdX7Xxm
0bjSlwWM+dxCucwPol/XHHcRJialNH+UwdwJuZR22sWT7VE+xBLg51zhoT7p5MOf
5P113lw7ExPsISpFJpIE3/ECrwcYmSxj0BBpP+1cFkdgVIXStfpIKAr3gkMrwKbG
5wrNJCLkcZ3pRKoTByFLgUER5qwdX3xwKgTngIcANzYcoWWkYl1Jj77rapIJkgnQ
1Y2QDG20+OueiqOGYAmhDn0pa2O9zw6aMPHRqdPqdoGYfwgrfg==
-----END CERTIFICATE-----
+23
View File
@@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDxjCCAq6gAwIBAgIULBpunQmpUvjtMDRGCHwOhTzuAfUwDQYJKoZIhvcNAQEL
BQAwIDEeMBwGA1UEAwwVa3ViZXZlbGEtYXV0aC10ZXN0LWNhMB4XDTI2MDUxOTE2
MzkyN1oXDTI3MDUxOTE2MzkyN1owMTEvMC0GA1UEAwwmKi5rdWJldmVsYS1hdXRo
LXRlc3Quc3ZjLmNsdXN0ZXIubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQDze1S+/OOoNrc3ZfGa2BJY+fu5vIvkP+RER4CiCh2wgnZUf5Or83Pb
twKjhxPQ2tL7E7sj284JukS2ANA0xm++7P9lnDvOJCUANzItibf2/fO2qFXnCviM
/Sm39JeAoVPtuQN13ETJY5JmoCHycYXHGAqpT4Buo2OIrrPLIJ4XZS/X+Mz7AYDX
36Wy72zImqZ6VE/psoHfXQmd1ghtJtsqiY+UPXQfB8K2W/fltRwvhZcVzgjUeixx
QL+0rTh/Hm2GQcpPhm3KyFnpXch2blm6Qb5OxdwJvqfWuRsu+oGumern5tPnFOS0
nVDd4/1d43RvlGdPjnbFPz4iPEHEfn3dAgMBAAGjgeYwgeMwgaAGA1UdEQSBmDCB
lYIoem90Lmt1YmV2ZWxhLWF1dGgtdGVzdC5zdmMuY2x1c3Rlci5sb2NhbIIwY2hh
cnRtdXNldW0ua3ViZXZlbGEtYXV0aC10ZXN0LnN2Yy5jbHVzdGVyLmxvY2Fsgjdj
aGFydG11c2V1bS1iZWFyZXIua3ViZXZlbGEtYXV0aC10ZXN0LnN2Yy5jbHVzdGVy
LmxvY2FsMB0GA1UdDgQWBBT6nnjNDWROR+CQd2viM61At/pJFzAfBgNVHSMEGDAW
gBT5Bo80uLQYAbti/CWggGPA4X9O8TANBgkqhkiG9w0BAQsFAAOCAQEAQJxqxad1
7A2y0GsLWdFckjtHc7zTLYyRgfHiYmXB18k2sY1w7nLmib9m5RRvi+ji6ZLs8jc9
0ZZULHGH+MQcumPEZ861VACQyj/kjvSnY3ARG9mNiwrUJaRCoyj4LRognSjQEJBj
qgC6q73ek58vePvSI1AnbqWbLRoa+NMvQk+7ejZish6ZNfO/0xO4hOIaqtPY/u7N
xO7M/EITUpgEBlfsTtki1PkLlOVIvQWUvo2/1o2wrepHSxpE1PEjVxsVq+dOSuRr
Z4JAfppPPX0h7XQjPtpylbA3QV587YfrtoYOXLVQfsuvGjIiON9fPdiJwgYDU669
Fmj4eXfHsONWJw==
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDze1S+/OOoNrc3
ZfGa2BJY+fu5vIvkP+RER4CiCh2wgnZUf5Or83PbtwKjhxPQ2tL7E7sj284JukS2
ANA0xm++7P9lnDvOJCUANzItibf2/fO2qFXnCviM/Sm39JeAoVPtuQN13ETJY5Jm
oCHycYXHGAqpT4Buo2OIrrPLIJ4XZS/X+Mz7AYDX36Wy72zImqZ6VE/psoHfXQmd
1ghtJtsqiY+UPXQfB8K2W/fltRwvhZcVzgjUeixxQL+0rTh/Hm2GQcpPhm3KyFnp
Xch2blm6Qb5OxdwJvqfWuRsu+oGumern5tPnFOS0nVDd4/1d43RvlGdPjnbFPz4i
PEHEfn3dAgMBAAECggEABfgOQ7wUYq8+bC8urlTl3a3U6unTm9cWkbdgdUY1Nozo
y5TMOjJJDnpc3vvZQUzdK/RuqWgHJiCdhsEAfkK4i5cNXc5DWe28xQynpMJLDlT7
UJ+I15XQVsrJMECFSyh7nORAUK4oEmb6SpzH8Wlnw609ItByxvFMDHGxHxx7PNWv
Uyb4mFvWJAdsqsAGEfU5zihOSoXrwSXqFpB9ujh7aUFUkrJfh6QQAAS/YcPCNpw6
eSo9jEJ+Oy2Ot0KilxNkiYb22EKMfVnK51fKu8XwPbBUzsAIl1WEsrwEObMrkNBb
g6CcT9ZribCreQmHZloGbnyc1Q1LZw5OfNX7wwvbywKBgQD7CL1PXn+gpiVnTMjN
D+CekHj4oCL2X01677CcXwr+4ZM6QgUEGcE3BDpvyaNvMzSx9LnXTAT9njvIhFX5
EkO/nNyqWFCn91Muz4jNrDQCGAAT91W/BeJf+kodinUXezMs8YfFus1niAIFIcRu
aksex1xBdmPBCra/eQk5JVIpUwKBgQD4TFh5PssGCSpdMRs23lFKzwk9F5AdgvV3
xeXSgpjueuYtKd7xvh3BXlvx8UxlPGQGbNgrK70uvXleLFu7qgr8hhqqeYgfUmq2
wyHNgTNi1eS2+SVxatI50l9WJLm/1hwKKaiN4S64ZX3Tj4fvZKKIW+aJ2j0+9EtQ
MOowZQ1mDwKBgQDanBHAPJsIW6fW/ZcgfOMvMsEmQs4vn27p0DIM+veoXujHoxab
K5KHRrddAkvBWuZY0rXEN/9gnZuSUyxLawx0oTXJYn9axpc5/KE1+vCPojbvLEUP
xSAOVPiWIS029aLrUKrcFoEp19dqgK1/OjGQ7Cv7Fg5o3dungs/1Y/rY4QKBgQCG
89fSblKigTDJXftQoxzD8CsxTTFDGP6ZjrIO7HR6icm5GlzWP2Kkyshmg6PmEiC7
bUVAkZFNaiYhDTL+mLlH7wtnRI67l2vw0bX5oBNx0Jdy649ySYDhdHnktClRHuo2
i9XU52MhTehJqGVVs/iy7GAs5LUNFnIor61ZiFLz+QKBgC5abpmqcczFXfItvVMz
fcBwD7+vB+InFj4MYZRt3AO4Bl14dpE1CkOU9g5SxmnxAM4EZVjaI/VaS2whkIDq
r4vNqKoQmRBTtWIaDfeaNhFnI2+PpTseJ5UVOMnhIP+C1ES48rs2qn1josAe9zRa
ObUfDOfmLSR824AI82zxfSRC
-----END PRIVATE KEY-----
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: podinfo
description: Minimal chart for kubevela auth e2e
type: application
version: 1.0.0
appVersion: "6.5.0"
@@ -0,0 +1,20 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: podinfo
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 9898
+4
View File
@@ -0,0 +1,4 @@
replicaCount: 1
image:
repository: ghcr.io/stefanprodan/podinfo
tag: "6.5.0"
+2
View File
@@ -0,0 +1,2 @@
test-user:$2y$05$Tp95wTfptVT6m.f.8Gl8rOIqaytI.EcdUy3fG7yRblRIu13LD8ly6
@@ -0,0 +1,66 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: chartmuseum-bearer
namespace: kubevela-auth-test
spec:
replicas: 1
selector:
matchLabels:
app: chartmuseum-bearer
template:
metadata:
labels:
app: chartmuseum-bearer
spec:
containers:
- name: chartmuseum
image: ghcr.io/helm/chartmuseum:v0.16.2
env:
- name: STORAGE
value: local
- name: STORAGE_LOCAL_ROOTDIR
value: /charts
- name: AUTH_ANONYMOUS_GET
value: "true"
- name: DISABLE_API
value: "false"
ports:
- containerPort: 8080
volumeMounts:
- name: storage
mountPath: /charts
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 443
volumeMounts:
- name: config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
- name: tls
mountPath: /etc/nginx/certs
readOnly: true
volumes:
- name: config
configMap:
name: nginx-bearer-config
- name: tls
secret:
secretName: registry-tls
- name: storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: chartmuseum-bearer
namespace: kubevela-auth-test
spec:
selector:
app: chartmuseum-bearer
ports:
- name: tls
port: 443
targetPort: 443
+59
View File
@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: chartmuseum
namespace: kubevela-auth-test
spec:
replicas: 1
selector:
matchLabels:
app: chartmuseum
template:
metadata:
labels:
app: chartmuseum
spec:
containers:
- name: chartmuseum
image: ghcr.io/helm/chartmuseum:v0.16.2
env:
- name: STORAGE
value: local
- name: STORAGE_LOCAL_ROOTDIR
value: /charts
- name: BASIC_AUTH_USER
value: test-user
- name: BASIC_AUTH_PASS
value: test-pass
- name: AUTH_ANONYMOUS_GET
value: "false"
- name: TLS_CERT
value: /etc/certs/server.crt
- name: TLS_KEY
value: /etc/certs/server.key
ports:
- containerPort: 8080
volumeMounts:
- name: tls
mountPath: /etc/certs
readOnly: true
- name: storage
mountPath: /charts
volumes:
- name: tls
secret:
secretName: registry-tls
- name: storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: chartmuseum
namespace: kubevela-auth-test
spec:
selector:
app: chartmuseum
ports:
- port: 8080
targetPort: 8080
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: kubevela-auth-test
+54
View File
@@ -0,0 +1,54 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: zot
namespace: kubevela-auth-test
spec:
replicas: 1
selector:
matchLabels:
app: zot
template:
metadata:
labels:
app: zot
spec:
containers:
- name: registry
image: docker.io/library/registry:2.8.3
env:
- name: REGISTRY_AUTH
value: htpasswd
- name: REGISTRY_AUTH_HTPASSWD_REALM
value: kubevela-auth-test
- name: REGISTRY_AUTH_HTPASSWD_PATH
value: /etc/registry/htpasswd
- name: REGISTRY_HTTP_ADDR
value: 0.0.0.0:5000
ports:
- containerPort: 5000
volumeMounts:
- name: htpasswd
mountPath: /etc/registry/htpasswd
subPath: htpasswd
- name: storage
mountPath: /var/lib/registry
volumes:
- name: htpasswd
secret:
secretName: registry-htpasswd
- name: storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: zot
namespace: kubevela-auth-test
spec:
selector:
app: zot
ports:
- port: 5000
targetPort: 5000
+25
View File
@@ -0,0 +1,25 @@
events {}
http {
map $http_authorization $is_valid_bearer {
default 0;
"Bearer kubevela-auth-test-token" 1;
}
server {
listen 443 ssl;
server_name chartmuseum-bearer.kubevela-auth-test.svc.cluster.local;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
location / {
if ($is_valid_bearer = 0) {
add_header WWW-Authenticate 'Bearer realm="kubevela-auth-test", error="invalid_token"' always;
return 401 'invalid_token\n';
}
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Regenerates the e2e auth-test fixtures. Run when creds/certs/chart change.
# Output files are committed to the repo so CI does not need to regenerate them.
#
# Requires: openssl, helm, and either htpasswd OR docker (with httpd:2-alpine).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$HERE/.."
mkdir -p "$ROOT/certs"
mkdir -p "$ROOT/chart"
# 1. htpasswd (test-user:test-pass, bcrypt cost 5). Prefer local binary, fall
# back to docker httpd:2-alpine when htpasswd is not installed.
if command -v htpasswd >/dev/null 2>&1; then
htpasswd -B -b -c "$ROOT/htpasswd" test-user test-pass
else
docker run --rm httpd:2-alpine htpasswd -Bbn test-user test-pass > "$ROOT/htpasswd"
fi
# 2. self-signed CA + server cert valid for the in-cluster service names
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$ROOT/certs/ca.key" \
-out "$ROOT/certs/ca.crt" -days 3650 -subj "/CN=kubevela-auth-test-ca"
openssl req -new -newkey rsa:2048 -nodes -keyout "$ROOT/certs/server.key" \
-out "$ROOT/certs/server.csr" \
-subj "/CN=*.kubevela-auth-test.svc.cluster.local" \
-addext "subjectAltName=DNS:zot.kubevela-auth-test.svc.cluster.local,DNS:chartmuseum.kubevela-auth-test.svc.cluster.local,DNS:chartmuseum-bearer.kubevela-auth-test.svc.cluster.local"
openssl x509 -req -in "$ROOT/certs/server.csr" -CA "$ROOT/certs/ca.crt" -CAkey "$ROOT/certs/ca.key" \
-CAcreateserial -out "$ROOT/certs/server.crt" -days 365 \
-extfile <(printf "subjectAltName=DNS:zot.kubevela-auth-test.svc.cluster.local,DNS:chartmuseum.kubevela-auth-test.svc.cluster.local,DNS:chartmuseum-bearer.kubevela-auth-test.svc.cluster.local")
rm -f "$ROOT/certs/server.csr" "$ROOT/certs/ca.srl" "$ROOT/certs/ca.key"
# 3. chart tarball
helm package "$ROOT/chart/source" -d "$ROOT/chart" --version 1.0.0
mv "$ROOT/chart/podinfo-1.0.0.tgz" "$ROOT/chart/podinfo-test-1.0.0.tgz"
@@ -90,14 +90,39 @@ template: {
// Version/tag for repository and OCI charts (ignored for direct URLs)
version?: string | *"latest"
// Authentication (optional) - TODO: Not yet implemented
// auth?: {
// // Reference to Secret containing credentials
// secretRef?: {
// name: string
// namespace?: string | *context.namespace
// }
// }
// Authentication for private chart repositories.
//
// The referenced Secret MUST be one of:
// - kubernetes.io/basic-auth (keys: username, password)
// - kubernetes.io/dockerconfigjson (key: .dockerconfigjson)
// - kubernetes.io/tls (keys: tls.crt, tls.key; optional ca.crt)
// - Opaque (keys: username+password OR token,
// optionally caFile/certFile/keyFile/insecureSkipTLS,
// plus insecurePlainHTTP for OCI sources only)
//
// Bearer tokens (RFC 6750) are honored on any HTTPS chart source
// (Helm repositories and direct .tgz URLs).
// They MUST NOT be combined with insecureSkipTLS (RFC 6750 mandates TLS),
// MUST NOT be set alongside basic-auth keys in the same Secret, and
// MUST NOT be used with OCI sources (the registry performs its own
// Basic->Bearer exchange per the OCI Distribution Spec).
//
// Token values are passed opaquely per RFC 7519; KubeVela does not
// decode, validate, or inspect JWT structure or claims. Token freshness,
// audience, and revocation remain the user's responsibility.
//
// The Secret MUST live in either the release namespace or the
// Application namespace. Cross-namespace references are rejected
// (mirrors the valuesFrom policy).
auth?: {
// Reference to a Kubernetes Secret containing credentials.
secretRef?: {
// Secret name.
name: string
// Secret namespace. MAY be omitted (defaults to the release namespace).
namespace?: string
}
}
}
// Release configuration (optional - uses context defaults)
@@ -262,7 +287,21 @@ template: {
"app.oam.dev/name": context.appName
"app.oam.dev/namespace": context.namespace
"app.oam.dev/component": context.name
"helm.oam.dev/chart": parameter.chart.source
// Preserve `helm.oam.dev/chart` as a label for selector
// compatibility, but only when the source is a valid
// Kubernetes label value (1-63 chars, alphanumeric +
// `.-_`, starting and ending alphanumeric). OCI/HTTPS
// URLs contain `://` and `/`, which are not legal in a
// label value; for those sources the value lives only
// in the annotation below.
if parameter.chart.source =~ "^[A-Za-z0-9]([A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$" {
"helm.oam.dev/chart": parameter.chart.source
}
}
// Always carries the full chart source, including URLs that
// exceed the label value limit or contain reserved characters.
annotations: {
"helm.oam.dev/chart": parameter.chart.source
}
}
data: {