mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-18 12:06:38 +00:00
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:
co-authored by
Vishal Kumar
parent
5664912a4c
commit
bcf6af9747
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/stefanprodan/podinfo
|
||||
tag: "6.5.0"
|
||||
Vendored
+2
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kubevela-auth-test
|
||||
+54
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user