Feat: implement valuesFrom support for helmchart component and update… (#7099)

* feat: implement valuesFrom support for helmchart component and update documentation examples

Signed-off-by: Anaswara Suresh <anaswarasuresh2212@gmail.com>

* fix: address cubic review feedback on valuesFrom

Three issues raised by cubic AI review on kubevela#7099:

1. docs/examples/helmchart-valuesfrom/secret-and-inline.yaml —
   Expected-result comments used incorrect paths (resources.cpu /
   resources.mem) and values (500m) that did not match the actual CM
   data. Rewrote the narrative to use the real paths
   (resources.limits.cpu / resources.limits.memory) and bundled the
   Secret inline in the manifest so the example is self-contained and
   the expected output is deterministic.

2. docs/examples/helmchart-valuesfrom/secret-and-inline.yaml —
   The Secret was marked optional: true while the narrative required
   it for the merged output to match. Bundled the Secret inline and
   dropped the optional flag, removing the order/timing ambiguity.
   README.md updated to drop the now-redundant "create the Secret
   first" instruction.

3. pkg/cue/cuex/providers/helm/helm.go:Render —
   After removing the unconditional "default" fallback for
   releaseNamespace, Helm could run with an empty namespace when both
   Context.AppNamespace and Release.Namespace were unset (non-normal
   code paths — direct callers, tests, CLI tooling). Restored the
   "default" fallback at the end of the namespace resolution while
   keeping the Application-namespace plumbing for tenant-scoped
   cross-namespace rejection.

Co-authored-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Signed-off-by: Anaswara Suresh <anaswarasuresh2212@gmail.com>

* feat: add valuesFrom fingerprinting for helmchart components to trigger workflow restarts on ConfigMap/Secret changes

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

* feat: add valuesFrom fingerprinting for helmchart components to trigger workflow restarts on ConfigMap/Secret changes

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

* feat: enhance valuesFrom support for helmchart components with fingerprinting and error handling

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

* docs: clarify cross-namespace restrictions for valuesFrom in helmchart examples

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

* ci: retrigger checks

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

* feat: add publishVersion support to Helm provider for stable release management

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

* feat: improve error handling for application retrieval in Helm provider

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

* ci: retrigger checks

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

* feat: add application publishVersion lookup defense in Helm provider tests

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

---------

Signed-off-by: Anaswara Suresh <anaswarasuresh2212@gmail.com>
Signed-off-by: Ayush Kumar <ayushshyamkumar888@gmail.com>
Co-authored-by: Anaswara Suresh <anaswarasuresh2212@gmail.com>
This commit is contained in:
Ayush Kumar
2026-04-28 20:02:58 -07:00
committed by GitHub
co-authored by Ayush Kumar Anaswara Suresh
parent 2268d95c1c
commit 28892ccc7e
12 changed files with 2858 additions and 128 deletions
@@ -51,19 +51,22 @@ spec:
namespace?: string | *context.namespace
}
// Inline values (highest priority)
// +usage=Inline values merged with the highest priority; override everything in valuesFrom.
values?: {...}
// Value sources (merged in order) - TODO: Not yet implemented
// valuesFrom?: [...{
// kind: "Secret" | "ConfigMap" | "OCIRepository"
// name: string
// namespace?: string
// key?: string // Specific key in ConfigMap/Secret
// url?: string // For OCIRepository
// tag?: string // For OCIRepository
// optional?: bool | *false // Don't fail if source doesn't exist
// }]
// +usage=Additional values sources merged in array order. Later entries override earlier ones on conflict, and inline `values` override everything in valuesFrom. Deep-merges map keys; arrays are replaced (not concatenated); null is preserved. On every reconcile the controller computes a content fingerprint over all referenced sources and folds it into the workflow revision; an external edit to a referenced ConfigMap/Secret triggers a `helm upgrade` on the next reconcile (default resync ~5 min). Sources are read from the control-plane cluster regardless of where the chart is deployed. Suppressed when the `app.oam.dev/publishVersion` annotation is set so explicit pins remain hard.
valuesFrom?: [...{
// +usage=Source kind. Only Secret and ConfigMap are supported; OCIRepository is reserved for a future release.
kind: "Secret" | "ConfigMap"
// +usage=Name of the Secret or ConfigMap.
name: string
// +usage=Namespace of the Secret or ConfigMap. Defaults to the chart release namespace, which itself defaults to the Application's own namespace when release.namespace is unset. Cross-namespace references are rejected to prevent cross-tenant reads via the controller's cluster-wide RBAC.
namespace?: string
// +usage=Key inside .data whose value is parsed as YAML. Defaults to "values.yaml" (FluxCD / Helm convention). Only ConfigMap.data and Secret.data are read; ConfigMap.binaryData is rejected.
key?: string
// +usage=If true, a missing Secret/ConfigMap or missing key is skipped silently. Parse errors and permission errors still fail the render.
optional?: bool | *false
}]
// Health status criteria - defines when the Helm deployment is considered healthy
healthStatus?: [...{
@@ -177,10 +180,9 @@ spec:
values: parameter.values
}
// TODO: valuesFrom not yet implemented
// if parameter.valuesFrom != _|_ {
// valuesFrom: parameter.valuesFrom
// }
if parameter.valuesFrom != _|_ {
valuesFrom: parameter.valuesFrom
}
options: _options
// Pass KubeVela ownership context so the provider can inject labels
@@ -0,0 +1,127 @@
# helmchart valuesFrom examples
The native `helmchart` component can merge values from `ConfigMap` and `Secret`
resources before rendering the chart. Useful for pulling per-environment config,
database DSNs, or any sensitive value out of the Application spec itself.
## Quick reference
```yaml
components:
- type: helmchart
properties:
chart:
source: podinfo
repoURL: https://stefanprodan.github.io/podinfo
version: "6.11.1"
release:
name: podinfo
namespace: myapp
values:
replicaCount: 3 # inline — wins over anything below
valuesFrom:
- kind: ConfigMap
name: podinfo-base # read first
- kind: Secret
name: podinfo-overlay # read second, overrides earlier on conflict
# key: values.yaml # optional, defaults to "values.yaml"
# optional: true # skip silently on not-found
```
## Merge order
Highest priority wins:
```
inline `values` > valuesFrom[N] > valuesFrom[N-1] > ... > valuesFrom[0] > chart defaults
```
- **Deep-merge** for map keys (e.g. `resources.limits.memory`)
- **Replace** for arrays (e.g. `extraArgs: [...]` from a later source replaces earlier)
- `null` values are preserved (not treated as a delete marker)
## Semantics you should know
| Field | Default | Notes |
|--------------|--------------------------------|------------------------------------------------------------|
| `kind` | — | Only `Secret` and `ConfigMap` are supported. |
| `name` | — | Required. |
| `namespace` | Chart release namespace (which itself defaults to the Application's own namespace when `release.namespace` is unset) | Cross-namespace references are **rejected** by design — the explicit value must equal either the release namespace or the Application's own namespace. |
| `key` | `values.yaml` | Which key inside `.data` holds the YAML blob. |
| `optional` | `false` | When `true`, missing resource/key is skipped silently. Parse errors and permission errors still fail. |
### Cross-namespace is disallowed
Because the KubeVela controller has cluster-wide read on ConfigMaps and Secrets,
allowing an arbitrary user-supplied `namespace` would let any tenant read
arbitrary Secrets. An explicit `valuesFrom.namespace` is therefore accepted
only if it matches one of:
- the **chart release namespace** (`release.namespace`, the default), or
- the **Application's own namespace** (`metadata.namespace` on the
`Application` object).
When `release.namespace` is unset it falls back to the Application's namespace,
so the two are equal in the common single-namespace case. They diverge when a
user explicitly puts the chart in a different namespace from the App; both
options remain valid for the source.
Anything else fails with:
```
cross-namespace valuesFrom sources are not permitted
```
If you need shared config across unrelated namespaces, copy the ConfigMap into
each Application namespace (e.g. via a replicator) rather than referencing
across.
### External CM/Secret edits propagate on the next reconcile
An edit to a referenced `ConfigMap` or `Secret` does not directly trigger a
reconcile, but on the next reconcile (periodic resync, default ~5 minutes) the
controller computes a content fingerprint of every referenced source and folds
it into `desiredRev` as a `-vf-…` suffix on `status.workflow.appRevision`. When
the content moves, the suffix moves, the workflow gate fails, the workflow
restarts, and the chart re-renders with the new values.
To roll out immediately rather than waiting for the next resync, touch the
`app.oam.dev/requestreconcile` annotation on the Application:
```bash
kubectl -n myapp annotate app foo app.oam.dev/requestreconcile=$(date +%s) --overwrite
```
Caveats:
- **Cosmetic-only edits do not roll out.** YAML→JSON canonicalisation (matching
Helm's own value-comparison contract) drops whitespace, comments, and key
order, so an edit that adds a comment or reformats produces the same digest.
- **A rollback to identical earlier content does not roll out**, for the same
reason.
- **`publishVersion` annotation pin**: when `app.oam.dev/publishVersion` is set,
the suffix is **not** appended. The pin is hard — CM/Secret edits are
deferred until the user bumps the pin.
- **Multi-cluster**: `valuesFrom` sources are always read from the
control-plane cluster, regardless of where the chart is deployed (e.g. via a
topology policy). A CM/Secret living only on a target member cluster will not
be found.
- **Optional missing sources** contribute a stable `<missing>` sentinel to the
fingerprint, so they do not cause spurious upgrades while absent and
deterministically move the digest the moment they appear.
## Files in this folder
- [`configmap-backed.yaml`](./configmap-backed.yaml) — a minimal example with
one ConfigMap backing `replicaCount`.
- [`secret-and-inline.yaml`](./secret-and-inline.yaml) — three-layer merge:
chart defaults → ConfigMap → Secret → inline.
Each file is self-contained (the ConfigMap/Secret is bundled alongside the
Application). Apply with:
```bash
kubectl apply -f configmap-backed.yaml
# or
kubectl apply -f secret-and-inline.yaml
```
@@ -0,0 +1,33 @@
# Minimal helmchart + valuesFrom example.
# Apply this manifest — the ConfigMap sets replicaCount, the Application
# references it, and podinfo deploys with 3 replicas.
apiVersion: v1
kind: ConfigMap
metadata:
name: podinfo-values
data:
values.yaml: |
replicaCount: 3
---
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: podinfo-cm-backed
spec:
components:
- name: podinfo
type: helmchart
properties:
chart:
source: podinfo
repoURL: https://stefanprodan.github.io/podinfo
version: "6.11.1"
release:
name: podinfo
namespace: default
valuesFrom:
- kind: ConfigMap
name: podinfo-values
options:
createNamespace: true
skipTests: true
@@ -0,0 +1,62 @@
# Three-layer merge example: ConfigMap (base) + Secret (overlay) + inline.
#
# Expected rendered values:
# - replicaCount: 2 (from inline — wins over everything)
# - resources.limits.cpu: 500m (from Secret — wins over CM on conflict)
# - resources.limits.memory: 256Mi (from CM — Secret didn't set it, preserved)
# - ui.color: "#34577c" (from CM — no other source sets it)
#
# Single self-contained manifest: both the ConfigMap and Secret are bundled.
# Apply with:
# kubectl apply -f secret-and-inline.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: podinfo-base
data:
values.yaml: |
replicaCount: 1
resources:
limits:
cpu: 100m
memory: 256Mi
ui:
color: "#34577c"
---
apiVersion: v1
kind: Secret
metadata:
name: podinfo-overlay
type: Opaque
stringData:
values.yaml: |
resources:
limits:
cpu: 500m
---
apiVersion: core.oam.dev/v1beta1
kind: Application
metadata:
name: podinfo-layered
spec:
components:
- name: podinfo
type: helmchart
properties:
chart:
source: podinfo
repoURL: https://stefanprodan.github.io/podinfo
version: "6.11.1"
release:
name: podinfo-layered
namespace: default
values:
replicaCount: 2
valuesFrom:
- kind: ConfigMap
name: podinfo-base
- kind: Secret
name: podinfo-overlay
options:
createNamespace: true
skipTests: true
@@ -0,0 +1,283 @@
/*
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 application
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
)
// helmchartComponentType is the component-definition type string the helm
// provider handles. The fingerprint helper only walks components of this type.
// Defined in vela-templates/definitions/internal/component/helmchart.cue.
const helmchartComponentType = "helmchart"
// defaultValuesFromKey matches the helm provider's defaultValuesKey at
// pkg/cue/cuex/providers/helm/helm.go (see the const there). Falls back to
// "values.yaml" when the user does not specify a key, matching FluxCD and
// Helm CLI conventions. The two constants must stay in sync; if helm.go
// changes its default, update this one too.
const defaultValuesFromKey = "values.yaml"
// Suffix shape for the workflow-revision token that workflow.go appends to
// desiredRev when the fingerprint helper returns a non-empty digest:
// "<base><valuesFromSuffixSeparator><valuesFromSuffixHexLen-char hex>".
//
// The hex length encodes 128 bits of SHA-256, which is far above any feasible
// collision-search budget. Both constants are referenced from workflow.go so a
// future change in length or separator updates both append and prefix-match
// sites in lock-step.
const (
valuesFromSuffixSeparator = "-vf-"
valuesFromSuffixHexLen = 32
)
// valuesFromRef captures the subset of the helmchart valuesFrom entry the
// fingerprint helper needs.
type valuesFromRef struct {
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Key string `json:"key,omitempty"`
Optional bool `json:"optional,omitempty"`
}
// helmchartProperties is the minimal subset of helmchart component properties
// the fingerprint helper needs. Other fields are ignored to keep the helper
// resilient to future schema additions.
type helmchartProperties struct {
Release struct {
Namespace string `json:"namespace,omitempty"`
} `json:"release,omitempty"`
ValuesFrom []valuesFromRef `json:"valuesFrom,omitempty"`
}
// resolvedRef is a valuesFromRef plus the namespace context (Application's
// namespace and the release namespace, derived from the helmchart properties)
// needed to resolve the source's effective namespace later.
type resolvedRef struct {
ref valuesFromRef
appNamespace string
releaseNamespace string
}
// computeValuesFromContentFingerprint walks every helmchart component in the
// Application, reads its referenced ConfigMaps/Secrets, and returns a stable
// sha256 hex digest covering all referenced content. Returns "" when the App
// has no helmchart-with-valuesFrom components, so the workflow gate is left
// unchanged for apps that do not opt in.
//
// The digest is intended to be appended to desiredRev as a suffix so external
// CM/Secret edits move the gate without changing the AppRevision hash.
func computeValuesFromContentFingerprint(ctx context.Context, app *v1beta1.Application) (string, error) {
if app == nil || len(app.Spec.Components) == 0 {
return "", nil
}
var refs []resolvedRef
for _, comp := range app.Spec.Components {
if err := ctx.Err(); err != nil {
return "", err
}
if comp.Type != helmchartComponentType {
continue
}
if comp.Properties == nil || len(comp.Properties.Raw) == 0 {
continue
}
var props helmchartProperties
if err := json.Unmarshal(comp.Properties.Raw, &props); err != nil {
// Properties not parseable as our minimal shape — let the render
// layer surface the schema error. Skip fingerprinting this
// component so we do not gate on a parser disagreement.
continue
}
if len(props.ValuesFrom) == 0 {
continue
}
releaseNamespace := props.Release.Namespace
if releaseNamespace == "" {
releaseNamespace = app.Namespace
}
for _, ref := range props.ValuesFrom {
refs = append(refs, resolvedRef{
ref: ref,
appNamespace: app.Namespace,
releaseNamespace: releaseNamespace,
})
}
}
if len(refs) == 0 {
return "", nil
}
lines := make([]string, 0, len(refs))
for _, r := range refs {
line, err := hashOneSource(ctx, r.ref, r.appNamespace, r.releaseNamespace)
if err != nil {
return "", err
}
lines = append(lines, line)
}
sort.Strings(lines)
h := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return hex.EncodeToString(h[:]), nil
}
// hashOneSource resolves one valuesFrom reference and returns a per-source
// "kind:ns/name/key=hex" line for the aggregate digest. The hex is sha256 of
// canonical JSON of the parsed YAML content, matching the helm provider's
// computeReleaseFingerprint contract so cosmetic-only YAML edits do not move
// the digest.
//
// singleton.KubeClient.Get() returns an UNCACHED client (see helm.go's
// loadConfigMapValues for the rationale): switching to a cached reader would
// register a cluster-wide ConfigMap/Secret informer and bloat controller
// memory. Direct API reads per source per reconcile are the intended
// trade-off.
func hashOneSource(ctx context.Context, ref valuesFromRef, appNamespace, releaseNamespace string) (string, error) {
ns, err := resolveNamespace(ref, appNamespace, releaseNamespace)
if err != nil {
return "", err
}
key := ref.Key
if key == "" {
key = defaultValuesFromKey
}
k8s := singleton.KubeClient.Get()
switch ref.Kind {
case "ConfigMap":
cm := &corev1.ConfigMap{}
if err := k8s.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: ns}, cm); err != nil {
if apierrors.IsNotFound(err) {
if ref.Optional {
return missingLine(ref.Kind, ns, ref.Name, key), nil
}
return "", fmt.Errorf("configmap %s/%s not found", ns, ref.Name)
}
return "", fmt.Errorf("failed to read ConfigMap %s/%s: %w", ns, ref.Name, err)
}
raw, ok := cm.Data[key]
if !ok {
// If the key lives in binaryData (kubectl create cm --from-file with
// non-UTF-8 content), surface a specific error rather than the
// generic "not found" so the operator can diagnose the mismatch.
// Helm values files are textual, so binaryData is rejected by design.
if _, isBinary := cm.BinaryData[key]; isBinary {
return "", fmt.Errorf("configmap %s/%s key %q is in binaryData; valuesFrom requires a textual YAML value in .data",
ns, ref.Name, key)
}
if ref.Optional {
return missingLine(ref.Kind, ns, ref.Name, key), nil
}
return "", fmt.Errorf("configmap %s/%s key %q not found", ns, ref.Name, key)
}
return canonicalLine(ref.Kind, ns, ref.Name, key, []byte(raw))
case "Secret":
sec := &corev1.Secret{}
if err := k8s.Get(ctx, client.ObjectKey{Name: ref.Name, Namespace: ns}, sec); err != nil {
if apierrors.IsNotFound(err) {
if ref.Optional {
return missingLine(ref.Kind, ns, ref.Name, key), nil
}
return "", fmt.Errorf("secret %s/%s not found", ns, ref.Name)
}
return "", fmt.Errorf("failed to read Secret %s/%s: %w", ns, ref.Name, err)
}
raw, ok := sec.Data[key]
if !ok {
if ref.Optional {
return missingLine(ref.Kind, ns, ref.Name, key), nil
}
return "", fmt.Errorf("secret %s/%s key %q not found", ns, ref.Name, key)
}
// Kubernetes already base64-decodes Secret.Data on read, so the bytes
// are consumed as-is. Error messages intentionally never include raw
// secret content (matches the helm provider's convention).
return canonicalLine(ref.Kind, ns, ref.Name, key, raw)
default:
// OCIRepository is deferred and remains unsupported
// here. The helm provider's loadValuesFromSource is the source of
// truth for kind validation.
return "", fmt.Errorf("unsupported valuesFrom kind %q: only ConfigMap and Secret are currently supported by the fingerprint helper", ref.Kind)
}
}
// canonicalLine parses raw as YAML, re-marshals as JSON (Go's encoding/json
// sorts map keys deterministically when marshalling map[string]interface{}),
// sha256s the canonical bytes, and returns a "kind:ns/name/key=hex" line.
//
// The parse-then-canonicalise round trip is intentional: it makes the digest
// invariant under whitespace and comment edits in the source YAML, matching
// the helm provider's computeReleaseFingerprint contract (which hashes the
// JSON of the parsed values map).
func canonicalLine(kind, ns, name, key string, raw []byte) (string, error) {
var parsed map[string]interface{}
if err := yaml.Unmarshal(raw, &parsed); err != nil {
return "", fmt.Errorf("%s %s/%s key %q: invalid YAML: %w", kind, ns, name, key, err)
}
if parsed == nil {
parsed = map[string]interface{}{}
}
canonical, err := json.Marshal(parsed)
if err != nil {
return "", fmt.Errorf("%s %s/%s key %q: canonicalise failed: %w", kind, ns, name, key, err)
}
h := sha256.Sum256(canonical)
return fmt.Sprintf("%s:%s/%s/%s=%s", kind, ns, name, key, hex.EncodeToString(h[:])), nil
}
// missingLine is the per-source line emitted when an optional valuesFrom
// source is absent (resource missing or key missing). The literal "<missing>"
// keeps the aggregate fingerprint stable across reconciles while the source
// stays absent, and moves the digest the moment the source appears.
func missingLine(kind, ns, name, key string) string {
return fmt.Sprintf("%s:%s/%s/%s=<missing>", kind, ns, name, key)
}
// resolveNamespace mirrors the helm provider's resolveValuesFromNamespace.
// An empty Namespace defaults to releaseNamespace. An explicit Namespace is
// accepted only if it equals releaseNamespace or appNamespace; any other
// value is rejected to block cross-tenant Secret reads via the controller's
// cluster-wide RBAC.
func resolveNamespace(ref valuesFromRef, appNamespace, releaseNamespace string) (string, error) {
if ref.Namespace == "" {
return releaseNamespace, nil
}
if ref.Namespace == releaseNamespace || ref.Namespace == appNamespace {
return ref.Namespace, nil
}
return "", fmt.Errorf("cross-namespace valuesFrom sources are not permitted: %s %q requested namespace %q but Application is in %q and release is in %q",
ref.Kind, ref.Name, ref.Namespace, appNamespace, releaseNamespace)
}
@@ -0,0 +1,33 @@
/*
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 application
import "testing"
// TestDefaultValuesFromKeyMatchesHelmProvider locks defaultValuesFromKey to
// the literal value the helm provider's defaultValuesKey uses at
// pkg/cue/cuex/providers/helm/helm.go. The two packages cannot import each
// other (circular), and the helm provider's constant is unexported, so the
// fingerprint helper has to duplicate the literal. If the helm provider
// changes its default, this test catches the drift at package-test time
// instead of leaving a silent fingerprint bug in production.
func TestDefaultValuesFromKeyMatchesHelmProvider(t *testing.T) {
const helmProviderDefault = "values.yaml"
if defaultValuesFromKey != helmProviderDefault {
t.Fatalf("defaultValuesFromKey %q diverged from helm provider default %q — keep them in sync (see pkg/cue/cuex/providers/helm/helm.go)", defaultValuesFromKey, helmProviderDefault)
}
}
@@ -0,0 +1,681 @@
/*
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 application
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
)
func TestComputeValuesFromContentFingerprint_NilApp(t *testing.T) {
got, err := computeValuesFromContentFingerprint(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint for nil app, got %q", got)
}
}
func TestComputeValuesFromContentFingerprint_EmptyComponents(t *testing.T) {
app := &v1beta1.Application{}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint for app with no components, got %q", got)
}
}
func TestComputeValuesFromContentFingerprint_NoHelmchartComponents(t *testing.T) {
app := &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "web", Type: "webservice"},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint, got %q", got)
}
}
func TestComputeValuesFromContentFingerprint_HelmchartNoValuesFrom(t *testing.T) {
app := &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "x",
Type: "helmchart",
Properties: &runtime.RawExtension{
Raw: []byte(`{"chart":{"source":"foo"}}`),
},
},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint when valuesFrom is absent, got %q", got)
}
}
func TestComputeValuesFromContentFingerprint_HelmchartNilProperties(t *testing.T) {
app := &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: nil},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint, got %q", got)
}
}
// newAppWithCMValuesFrom builds an Application with one helmchart component
// referencing a single ConfigMap valuesFrom source.
func newAppWithCMValuesFrom(t *testing.T, appName, ns, cmName, key string) *v1beta1.Application {
t.Helper()
entry := map[string]interface{}{"kind": "ConfigMap", "name": cmName}
if key != "" {
entry["key"] = key
}
props := map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": ns},
"valuesFrom": []map[string]interface{}{entry},
}
raw, err := json.Marshal(props)
if err != nil {
t.Fatalf("marshal props: %v", err)
}
return &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: ns},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: raw}},
},
},
}
}
// setupFakeClient installs a fresh fake controller-runtime client into the
// kubevela singleton.KubeClient slot, seeded with the given runtime objects.
// Tests that need a different object set call this again to swap the client.
//
// Cleanup deliberately re-Sets the singleton to nil rather than capturing a
// "previous" value with Get() because the singleton's loader chain calls
// config.GetConfigOrDie(), which os.Exits when run outside an envtest context
// (i.e. `go test -run TestComputeValuesFrom...` directly, without the package's
// Ginkgo suite). Calling Get() in setupFakeClient would therefore hard-exit
// the test binary in standalone runs.
//
// Why this is safe in the package binary: Go runs Test* functions in
// alphabetical order, so `TestAPIs` (the Ginkgo entry point) runs FIRST and
// has fully completed BeforeSuite/AfterSuite by the time the standalone
// `TestComputeValuesFromContentFingerprint_*` functions begin. None of the
// Ginkgo specs in this package use setupFakeClient, and after the standalone
// Test* functions finish, no further code in the binary calls
// singleton.KubeClient.Get(). The cleanup leaves the singleton at nil at exit,
// which is harmless. If a future Ginkgo spec ever needs a fake client at
// suite-time, it must restore the envtest client itself rather than rely on
// this helper.
func setupFakeClient(t *testing.T, objs ...runtime.Object) {
t.Helper()
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
t.Fatalf("add scheme: %v", err)
}
c := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build()
t.Cleanup(func() { singleton.KubeClient.Set(nil) })
singleton.KubeClient.Set(c)
}
func TestComputeValuesFromContentFingerprint_SingleConfigMap(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 3\n"},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Fatalf("expected non-empty fingerprint, got empty")
}
if len(got) != 64 {
t.Fatalf("expected 64-char sha256 hex, got %d chars: %q", len(got), got)
}
}
func TestComputeValuesFromContentFingerprint_MalformedProperties(t *testing.T) {
// Backstop the silent-continue at the json.Unmarshal step — malformed
// helmchart properties must NOT propagate as an error. The render layer
// is the source of truth for schema validation; gating the workflow on
// a parser disagreement here would block reconciles unnecessarily.
app := &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "x",
Type: "helmchart",
Properties: &runtime.RawExtension{Raw: []byte(`{not valid json`)},
},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("malformed properties must not return error, got: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint for malformed properties, got %q", got)
}
}
func TestComputeValuesFromContentFingerprint_Deterministic(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 3\nimage:\n tag: v1\n"},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
first, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("first call: %v", err)
}
for i := 0; i < 5; i++ {
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("iteration %d: %v", i, err)
}
if got != first {
t.Fatalf("iteration %d: fingerprint moved: %q vs %q", i, got, first)
}
}
}
func TestComputeValuesFromContentFingerprint_ContentChangeMovesFingerprint(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 3\n"},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
before, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("before: %v", err)
}
// Edit the CM content and re-seed the fake client. setupFakeClient builds
// a fresh client from the supplied objects, so this is a faithful
// "operator edited the CM and the next reconcile re-reads it" simulation.
cm.Data["values.yaml"] = "replicas: 5\n"
setupFakeClient(t, cm)
after, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("after: %v", err)
}
if before == after {
t.Fatalf("expected fingerprint to move on content change, both = %q", before)
}
}
func TestComputeValuesFromContentFingerprint_WhitespaceOnlyChangeStable(t *testing.T) {
cmA := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{
"values.yaml": "replicas: 3\nimage:\n tag: v1\n",
},
}
cmB := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{
// Same parsed shape, different formatting — leading comment and
// extra blank line. canonicalLine parses YAML and re-marshals as
// JSON, so cosmetic edits like these must NOT move the digest.
"values.yaml": "# leading comment\nreplicas: 3\n\nimage:\n tag: v1\n",
},
}
setupFakeClient(t, cmA)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
first, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("first: %v", err)
}
setupFakeClient(t, cmB)
second, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("second: %v", err)
}
if first != second {
t.Fatalf("expected whitespace-only YAML change to leave fingerprint stable, got %q vs %q", first, second)
}
}
// newAppWithSecretValuesFrom builds an Application with one helmchart
// component referencing a single Secret valuesFrom source.
func newAppWithSecretValuesFrom(t *testing.T, appName, ns, secretName, key string) *v1beta1.Application {
t.Helper()
entry := map[string]interface{}{"kind": "Secret", "name": secretName}
if key != "" {
entry["key"] = key
}
props := map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": ns},
"valuesFrom": []map[string]interface{}{entry},
}
raw, err := json.Marshal(props)
if err != nil {
t.Fatalf("marshal props: %v", err)
}
return &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: ns},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: raw}},
},
},
}
}
func TestComputeValuesFromContentFingerprint_SecretSource(t *testing.T) {
sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "sec", Namespace: "ns1"},
Data: map[string][]byte{"values.yaml": []byte("replicas: 2\n")},
}
setupFakeClient(t, sec)
app := newAppWithSecretValuesFrom(t, "app1", "ns1", "sec", "")
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == "" {
t.Fatalf("expected non-empty fingerprint for Secret source")
}
if len(got) != 64 {
t.Fatalf("expected 64-char hex, got %d", len(got))
}
}
func TestComputeValuesFromContentFingerprint_SecretAndConfigMapDistinct(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 2\n"},
}
sec := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "ns1"},
Data: map[string][]byte{"values.yaml": []byte("replicas: 2\n")},
}
setupFakeClient(t, cm, sec)
cmApp := newAppWithCMValuesFrom(t, "a", "ns1", "shared", "")
secApp := newAppWithSecretValuesFrom(t, "a", "ns1", "shared", "")
cmFp, err := computeValuesFromContentFingerprint(context.Background(), cmApp)
if err != nil {
t.Fatalf("CM fingerprint: %v", err)
}
secFp, err := computeValuesFromContentFingerprint(context.Background(), secApp)
if err != nil {
t.Fatalf("Secret fingerprint: %v", err)
}
if cmFp == secFp {
t.Fatalf("ConfigMap and Secret with identical content + name should produce DIFFERENT fingerprints (kind is part of the per-source line) — got %q for both", cmFp)
}
}
// newAppWithOptionalCMValuesFrom builds an Application with one helmchart
// component referencing a single ConfigMap valuesFrom source marked optional.
func newAppWithOptionalCMValuesFrom(t *testing.T, appName, ns, cmName string) *v1beta1.Application {
t.Helper()
raw, err := json.Marshal(map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": ns},
"valuesFrom": []map[string]interface{}{
{"kind": "ConfigMap", "name": cmName, "optional": true},
},
})
if err != nil {
t.Fatalf("marshal props: %v", err)
}
return &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: appName, Namespace: ns},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: raw}},
},
},
}
}
func TestComputeValuesFromContentFingerprint_OptionalMissing_Stable(t *testing.T) {
setupFakeClient(t) // no CM at all
app := newAppWithOptionalCMValuesFrom(t, "app1", "ns1", "ghost")
first, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("first: %v", err)
}
second, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("second: %v", err)
}
if first == "" {
t.Fatalf("expected a non-empty fingerprint covering the <missing> sentinel, got empty")
}
if first != second {
t.Fatalf("fingerprint must be stable while optional source stays missing, got %q vs %q", first, second)
}
}
func TestComputeValuesFromContentFingerprint_OptionalAppearMovesFingerprint(t *testing.T) {
setupFakeClient(t) // missing
app := newAppWithOptionalCMValuesFrom(t, "app1", "ns1", "ghost")
missingFp, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("missing: %v", err)
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "ghost", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 4\n"},
}
setupFakeClient(t, cm)
presentFp, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("present: %v", err)
}
if missingFp == presentFp {
t.Fatalf("expected fingerprint to move when optional source appears")
}
}
func TestComputeValuesFromContentFingerprint_RequiredMissingErrors(t *testing.T) {
setupFakeClient(t) // no CM
app := newAppWithCMValuesFrom(t, "app1", "ns1", "ghost", "")
_, err := computeValuesFromContentFingerprint(context.Background(), app)
if err == nil {
t.Fatalf("expected error for required missing ConfigMap, got nil")
}
if !strings.Contains(err.Error(), "configmap ns1/ghost not found") {
t.Fatalf("error wording missing source identity: %v", err)
}
}
func TestComputeValuesFromContentFingerprint_InvalidYAMLErrors(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{
// Unterminated flow mapping — invalid YAML.
"values.yaml": "replicas: [3, 4",
},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
_, err := computeValuesFromContentFingerprint(context.Background(), app)
if err == nil {
t.Fatalf("expected YAML parse error, got nil")
}
if !strings.Contains(err.Error(), "invalid YAML") {
t.Fatalf("error wording missing 'invalid YAML': %v", err)
}
}
func TestComputeValuesFromContentFingerprint_InvalidYAMLFailsEvenIfOptional(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "key: [unterminated"},
}
setupFakeClient(t, cm)
app := newAppWithOptionalCMValuesFrom(t, "app1", "ns1", "cfg")
_, err := computeValuesFromContentFingerprint(context.Background(), app)
if err == nil {
t.Fatalf("expected parse error to surface even with optional=true (matches helm provider semantics)")
}
}
func TestComputeValuesFromContentFingerprint_CrossNamespaceRejected(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "leaked", Namespace: "ns-a"},
Data: map[string]string{"values.yaml": "x: 1\n"},
}
setupFakeClient(t, cm)
props, _ := json.Marshal(map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": "ns-b"},
"valuesFrom": []map[string]interface{}{
{"kind": "ConfigMap", "name": "leaked", "namespace": "ns-a"},
},
})
app := &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: "tenant-b", Namespace: "ns-b"},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: props}},
},
},
}
_, err := computeValuesFromContentFingerprint(context.Background(), app)
if err == nil {
t.Fatalf("expected cross-namespace rejection, got nil")
}
if !strings.Contains(err.Error(), "cross-namespace valuesFrom") {
t.Fatalf("error wording missing 'cross-namespace valuesFrom': %v", err)
}
}
func TestComputeValuesFromContentFingerprint_ExplicitSameNamespaceAllowed(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "x: 1\n"},
}
setupFakeClient(t, cm)
props, _ := json.Marshal(map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": "ns1"},
"valuesFrom": []map[string]interface{}{
{"kind": "ConfigMap", "name": "cfg", "namespace": "ns1"},
},
})
app := &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: "app1", Namespace: "ns1"},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: props}},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("expected explicit same-namespace to be accepted, got error: %v", err)
}
if got == "" {
t.Fatalf("expected non-empty fingerprint")
}
}
func TestComputeValuesFromContentFingerprint_MultipleSourcesSorted(t *testing.T) {
a := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "x: 1\n"},
}
b := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "y: 2\n"},
}
setupFakeClient(t, a, b)
mkApp := func(refs []map[string]interface{}) *v1beta1.Application {
props, _ := json.Marshal(map[string]interface{}{
"chart": map[string]interface{}{"source": "foo"},
"release": map[string]interface{}{"namespace": "ns1"},
"valuesFrom": refs,
})
return &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ns1"},
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{Name: "x", Type: "helmchart", Properties: &runtime.RawExtension{Raw: props}},
},
},
}
}
abApp := mkApp([]map[string]interface{}{
{"kind": "ConfigMap", "name": "a"},
{"kind": "ConfigMap", "name": "b"},
})
baApp := mkApp([]map[string]interface{}{
{"kind": "ConfigMap", "name": "b"},
{"kind": "ConfigMap", "name": "a"},
})
abFp, _ := computeValuesFromContentFingerprint(context.Background(), abApp)
baFp, _ := computeValuesFromContentFingerprint(context.Background(), baApp)
if abFp == "" || abFp != baFp {
t.Fatalf("expected order-independent aggregation, got %q vs %q", abFp, baFp)
}
}
func TestComputeValuesFromContentFingerprint_CustomKey(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{
"dev.yaml": "replicas: 1\n",
"prod.yaml": "replicas: 5\n",
},
}
setupFakeClient(t, cm)
devApp := newAppWithCMValuesFrom(t, "a", "ns1", "cfg", "dev.yaml")
prodApp := newAppWithCMValuesFrom(t, "a", "ns1", "cfg", "prod.yaml")
devFp, _ := computeValuesFromContentFingerprint(context.Background(), devApp)
prodFp, _ := computeValuesFromContentFingerprint(context.Background(), prodApp)
if devFp == prodFp {
t.Fatalf("expected different keys to produce different fingerprints, both = %q", devFp)
}
}
func TestComputeValuesFromContentFingerprint_BinaryDataRejected(t *testing.T) {
// kubectl create cm --from-file with non-UTF-8 content writes the key into
// .binaryData, not .data. valuesFrom requires textual YAML; the loader must
// reject explicitly with a clear message rather than fall through to the
// generic "key not found" error.
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
BinaryData: map[string][]byte{"values.yaml": {0xff, 0xfe, 0x00}},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
_, err := computeValuesFromContentFingerprint(context.Background(), app)
if err == nil {
t.Fatalf("expected an explicit binaryData rejection error, got nil")
}
if !strings.Contains(err.Error(), "binaryData") {
t.Fatalf("error should mention binaryData; got: %v", err)
}
}
func TestComputeValuesFromContentFingerprint_RespectsContextCancellation(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "ns1"},
Data: map[string]string{"values.yaml": "replicas: 3\n"},
}
setupFakeClient(t, cm)
app := newAppWithCMValuesFrom(t, "app1", "ns1", "cfg", "")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := computeValuesFromContentFingerprint(ctx, app)
if err == nil {
t.Fatalf("expected context.Canceled, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got: %v", err)
}
}
func TestComputeValuesFromContentFingerprint_NoValuesFromHelmchartIsBackwardsCompat(t *testing.T) {
// Backwards-compat regression guard: a helmchart Application that doesn't
// declare valuesFrom must produce an empty fingerprint so workflow.go's
// gate behaves identically to before this feature.
app := &v1beta1.Application{
Spec: v1beta1.ApplicationSpec{
Components: []common.ApplicationComponent{
{
Name: "x",
Type: "helmchart",
Properties: &runtime.RawExtension{
Raw: []byte(`{"chart":{"source":"foo"},"release":{"namespace":"ns1"},"values":{"replicaCount":3}}`),
},
},
},
},
}
got, err := computeValuesFromContentFingerprint(context.Background(), app)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Fatalf("expected empty fingerprint for helmchart without valuesFrom, got %q", got)
}
}
@@ -163,7 +163,8 @@ func (r *Reconciler) checkWorkflowRestart(ctx monitorContext.Context, app *v1bet
if app.Status.Workflow != nil {
currentRev = app.Status.Workflow.AppRevision
}
if metav1.HasAnnotation(app.ObjectMeta, oam.AnnotationPublishVersion) {
publishVersionPinned := metav1.HasAnnotation(app.ObjectMeta, oam.AnnotationPublishVersion)
if publishVersionPinned {
desiredRev = app.GetAnnotations()[oam.AnnotationPublishVersion]
} else { // nolint
// backward compatibility
@@ -172,6 +173,36 @@ func (r *Reconciler) checkWorkflowRestart(ctx monitorContext.Context, app *v1bet
currentRev = currentRev[:idx]
}
}
// Append a content fingerprint of any helmchart valuesFrom sources so
// external ConfigMap/Secret edits move desiredRev and trigger a workflow
// restart on the next reconcile. The suffix only appears for Applications
// that declare valuesFrom on a helmchart component; all other Applications
// observe identical behaviour to before this change.
//
// Suppressed when publishVersion is set: an explicit pin from the user is
// hard, so a CM/Secret edit must not move the revision until the user bumps
// the pin. This preserves GitOps semantics for users who rely on the
// publishVersion annotation as a stable rollout token.
//
// On a transient fingerprint computation error (e.g. API hiccup), if the
// previous reconcile already persisted a "<base>-vf-..." revision in
// status.workflow.appRevision, reuse it so the gate still matches and the
// workflow does not flap. The error is logged at V(2) rather than Error so
// a persistent failure (e.g. RBAC change deleting CM read) does not produce
// alert-storms across the fleet — the persistent failure surfaces during
// the next Render() with a clearer error than could be produced here.
if !publishVersionPinned {
if vfFp, err := computeValuesFromContentFingerprint(ctx, app); err != nil {
klog.V(2).InfoS("failed to compute valuesFrom fingerprint; falling back to spec-only workflow gate",
"err", err, "appName", app.Name, "namespace", app.Namespace)
if currentRev != "" && strings.HasPrefix(currentRev, desiredRev+valuesFromSuffixSeparator) {
desiredRev = currentRev
}
} else if vfFp != "" {
desiredRev = desiredRev + valuesFromSuffixSeparator + vfFp[:valuesFromSuffixHexLen]
}
}
if currentRev != "" && desiredRev == currentRev {
return
}
+325 -49
View File
@@ -1,5 +1,5 @@
/*
revertCopyright 2026 The KubeVela Authors.
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.
@@ -39,17 +39,23 @@ import (
"helm.sh/helm/v3/pkg/registry"
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/repo"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
kyaml "k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
"github.com/kubevela/pkg/cue/cuex/providers"
cuexruntime "github.com/kubevela/pkg/cue/cuex/runtime"
"github.com/kubevela/pkg/util/runtime"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/oam"
"github.com/oam-dev/kubevela/pkg/utils"
"github.com/oam-dev/kubevela/pkg/utils/common"
)
@@ -100,14 +106,12 @@ type ReleaseParams struct {
Namespace string `json:"namespace,omitempty"`
}
// ValuesFromParams represents a values source
// ValuesFromParams represents a values source.
type ValuesFromParams struct {
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace,omitempty"`
Key string `json:"key,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
Optional bool `json:"optional,omitempty"`
}
@@ -165,6 +169,12 @@ type ContextParams struct {
AppNamespace string `json:"appNamespace"`
Name string `json:"name"` // component name
Namespace string `json:"namespace"` // component namespace
// PublishVersion is the value of the Application's app.oam.dev/publishVersion
// annotation, if any. When set, the provider records it as a label on the
// helm release so subsequent reconciles can short-circuit when the pin is
// stable. Populated by Render() via an Application lookup; not part of the
// CUE-passed context shape.
PublishVersion string `json:"-"`
}
// velaContextStr returns a human-readable prefix like "app=myapp/default component=web"
@@ -538,53 +548,211 @@ func (p *Provider) fetchRepoChart(ctx context.Context, params *ChartSourceParams
return ch, nil
}
// mergeValues merges values from multiple sources
func (p *Provider) mergeValues(ctx context.Context, baseValues interface{}, valuesFrom []ValuesFromParams) (map[string]interface{}, error) {
// Start with base values
result := make(map[string]interface{})
// defaultValuesKey is the key looked up in a ConfigMap/Secret when the user
// does not specify one explicitly. Matches the FluxCD and Helm CLI convention.
const defaultValuesKey = "values.yaml"
if baseValues != nil {
if m, ok := baseValues.(map[string]interface{}); ok {
result = m
}
}
// Merge values from each source
for _, source := range valuesFrom {
values, err := p.loadValuesFromSource(ctx, source)
if err != nil {
if source.Optional {
klog.V(4).Infof("Skipping optional values source %s/%s: %v", source.Kind, source.Name, err)
continue
}
return nil, errors.Wrapf(err, "failed to load values from %s/%s", source.Kind, source.Name)
}
// Merge values
result = chartutil.CoalesceTables(result, values)
}
return result, nil
// valueSourceMissingError is returned by loaders when a ConfigMap/Secret or the
// requested key inside it does not exist. mergeValues uses this sentinel type to
// decide whether source.Optional allows the source to be skipped. Parse errors
// and other failures produce different error types, so Optional never swallows
// them — a common source of silent misconfiguration bugs.
type valueSourceMissingError struct {
kind, name, namespace, key string
cause error
}
// loadValuesFromSource loads values from a specific source
// nolint:unparam // result is always nil until ConfigMap/Secret/OCI loading is implemented
func (p *Provider) loadValuesFromSource(_ context.Context, source ValuesFromParams) (map[string]interface{}, error) {
func (e *valueSourceMissingError) Error() string {
if e.key != "" {
return fmt.Sprintf("%s %s/%s key %q not found: %v", e.kind, e.namespace, e.name, e.key, e.cause)
}
return fmt.Sprintf("%s %s/%s not found: %v", e.kind, e.namespace, e.name, e.cause)
}
func (e *valueSourceMissingError) Unwrap() error { return e.cause }
func isValueSourceMissing(err error) bool {
var target *valueSourceMissingError
return stderrors.As(err, &target)
}
// errCrossNamespaceValuesFrom is returned when a valuesFrom source references a
// namespace other than the Application's own namespace. The controller has
// cluster-scoped read on ConfigMaps/Secrets, so without this guard a tenant could
// read Secrets from any namespace by submitting a crafted Application.
var errCrossNamespaceValuesFrom = stderrors.New("cross-namespace valuesFrom sources are not permitted")
// mergeValues merges inline `values` and any `valuesFrom` sources into a single
// map. Priority (highest wins): inline > valuesFrom[N] > valuesFrom[N-1] > ... >
// valuesFrom[0]. Later entries override earlier ones. The merge is a deep-merge
// of map keys via chartutil.CoalesceTables; arrays are replaced wholesale (not
// concatenated), and `null` values are preserved (not treated as delete), so
// semantics diverge slightly from `helm CLI --values a.yaml --values b.yaml`
// which uses chartutil.CoalesceValues.
//
// A valuesFrom entry that omits `namespace` resolves to releaseNamespace (the
// natural co-location with the chart's deployed resources). An entry that sets
// an explicit Namespace is only allowed if it matches either releaseNamespace
// or appNamespace; any other namespace is rejected to block cross-tenant reads
// via the controller's cluster-wide RBAC.
func (p *Provider) mergeValues(ctx context.Context, baseValues interface{}, valuesFrom []ValuesFromParams, appNamespace, releaseNamespace string) (map[string]interface{}, error) {
accumulated := map[string]interface{}{}
for _, source := range valuesFrom {
if err := ctx.Err(); err != nil {
return nil, err
}
values, err := p.loadValuesFromSource(ctx, source, appNamespace, releaseNamespace)
if err != nil {
if source.Optional && isValueSourceMissing(err) {
klog.V(2).Infof("Helm provider: skipping optional values source %s %q: %v", source.Kind, source.Name, err)
continue
}
return nil, errors.Wrapf(err, "failed to load values from %s %q", source.Kind, source.Name)
}
// CoalesceTables(dst, src) treats dst as authoritative. `values` is the
// newer source, so it's passed as dst to override `accumulated` (older).
accumulated = chartutil.CoalesceTables(values, accumulated)
}
// Inline values override everything from valuesFrom. Clone before merging
// because CoalesceTables mutates dst in place, and dst here is the caller's
// map (renderParams.Values).
if inline, ok := baseValues.(map[string]interface{}); ok {
clone := make(map[string]interface{}, len(inline))
for k, v := range inline {
clone[k] = v
}
accumulated = chartutil.CoalesceTables(clone, accumulated)
}
return accumulated, nil
}
// loadValuesFromSource dispatches to the appropriate loader based on source.Kind.
func (p *Provider) loadValuesFromSource(ctx context.Context, source ValuesFromParams, appNamespace, releaseNamespace string) (map[string]interface{}, error) {
switch source.Kind {
case "ConfigMap":
// TODO: Implement ConfigMap loading
return nil, fmt.Errorf("configmap values source not yet implemented")
return p.loadConfigMapValues(ctx, source, appNamespace, releaseNamespace)
case "Secret":
// TODO: Implement Secret loading
return nil, fmt.Errorf("secret values source not yet implemented")
case "OCIRepository":
// TODO: Implement OCI repository loading
return nil, fmt.Errorf("ocirepository values source not yet implemented")
return p.loadSecretValues(ctx, source, appNamespace, releaseNamespace)
default:
return nil, fmt.Errorf("unsupported values source kind: %s", source.Kind)
}
}
// resolveValuesFromNamespace returns the effective namespace for a valuesFrom
// entry. The default (empty) resolves to releaseNamespace — the natural place
// to co-locate chart values with the chart's resources. An explicit namespace
// is accepted only if it matches releaseNamespace or appNamespace; any other
// value is rejected so a tenant cannot coerce the controller's cluster-wide
// RBAC into reading Secrets from unrelated namespaces.
func resolveValuesFromNamespace(source ValuesFromParams, appNamespace, releaseNamespace string) (string, error) {
if source.Namespace == "" {
return releaseNamespace, nil
}
if source.Namespace == releaseNamespace || source.Namespace == appNamespace {
return source.Namespace, nil
}
return "", fmt.Errorf("%w: %s %q requested namespace %q but Application is in %q and release is in %q",
errCrossNamespaceValuesFrom, source.Kind, source.Name, source.Namespace, appNamespace, releaseNamespace)
}
// loadConfigMapValues reads a ConfigMap in the Application namespace and parses
// the requested key as YAML. When source.Key is empty it falls back to
// "values.yaml" (Helm/FluxCD convention). Not-found errors (missing ConfigMap
// or missing key) are returned as valueSourceMissingError so optional sources
// can skip them; parse errors are surfaced as-is and are never swallowed by
// optional.
//
// singleton.KubeClient.Get() here is the kubevela-pkg default client built via
// controller-runtime's client.New — this is an UNCACHED client that reads
// directly from the API server. Do NOT switch this to manager.GetClient() or
// a cached reader: that would register a cluster-wide ConfigMap/Secret
// informer on first use and load every CM/Secret cluster-wide into the
// controller's memory. Direct API reads per valuesFrom entry are the intended
// trade-off.
func (p *Provider) loadConfigMapValues(ctx context.Context, source ValuesFromParams, appNamespace, releaseNamespace string) (map[string]interface{}, error) {
ns, err := resolveValuesFromNamespace(source, appNamespace, releaseNamespace)
if err != nil {
return nil, err
}
key := source.Key
if key == "" {
key = defaultValuesKey
}
k8s := singleton.KubeClient.Get()
cm := &corev1.ConfigMap{}
if err := k8s.Get(ctx, client.ObjectKey{Name: source.Name, Namespace: ns}, cm); err != nil {
if apierrors.IsNotFound(err) {
return nil, &valueSourceMissingError{kind: "ConfigMap", name: source.Name, namespace: ns, cause: err}
}
return nil, errors.Wrapf(err, "failed to read ConfigMap %s/%s", ns, source.Name)
}
raw, ok := cm.Data[key]
if !ok {
// If the key lives in binaryData (kubectl create cm --from-file of
// non-UTF-8 content), reject explicitly. Helm values are textual; a
// binary blob is unparseable. The clear message saves operators from
// chasing a mismatch when `kubectl get cm` shows the key under
// binaryData and the loader reports "not found".
if _, isBinary := cm.BinaryData[key]; isBinary {
return nil, errors.Errorf("ConfigMap %s/%s key %q is in binaryData; valuesFrom requires a textual YAML value in .data",
ns, source.Name, key)
}
return nil, &valueSourceMissingError{
kind: "ConfigMap", name: source.Name, namespace: ns, key: key,
cause: fmt.Errorf("key not found in .data"),
}
}
var values map[string]interface{}
if err := yaml.Unmarshal([]byte(raw), &values); err != nil {
return nil, errors.Wrapf(err, "ConfigMap %s/%s key %q: invalid YAML", ns, source.Name, key)
}
return values, nil
}
// loadSecretValues reads a Secret in the Application namespace and parses the
// requested key as YAML. Kubernetes already base64-decodes Secret.Data on read,
// so the bytes are consumed as-is. Error messages intentionally never include
// raw secret bytes.
func (p *Provider) loadSecretValues(ctx context.Context, source ValuesFromParams, appNamespace, releaseNamespace string) (map[string]interface{}, error) {
ns, err := resolveValuesFromNamespace(source, appNamespace, releaseNamespace)
if err != nil {
return nil, err
}
key := source.Key
if key == "" {
key = defaultValuesKey
}
k8s := singleton.KubeClient.Get()
secret := &corev1.Secret{}
if err := k8s.Get(ctx, client.ObjectKey{Name: source.Name, Namespace: ns}, secret); err != nil {
if apierrors.IsNotFound(err) {
return nil, &valueSourceMissingError{kind: "Secret", name: source.Name, namespace: ns, cause: err}
}
return nil, errors.Wrapf(err, "failed to read Secret %s/%s", ns, source.Name)
}
raw, ok := secret.Data[key]
if !ok {
return nil, &valueSourceMissingError{
kind: "Secret", name: source.Name, namespace: ns, key: key,
cause: fmt.Errorf("key not found in .data"),
}
}
var values map[string]interface{}
if err := yaml.Unmarshal(raw, &values); err != nil {
return nil, errors.Wrapf(err, "Secret %s/%s key %q: invalid YAML", ns, source.Name, key)
}
return values, nil
}
// getActionConfig initializes a Helm action.Configuration with a real Kubernetes
// REST client and a secrets-based storage driver so that releases persist in-cluster.
func (p *Provider) getActionConfig(namespace string) (*action.Configuration, error) {
@@ -684,11 +852,18 @@ func velaOwnerLabels(velaCtx *ContextParams) map[string]string {
if velaCtx == nil {
return nil
}
return map[string]string{
labels := map[string]string{
"app.oam.dev/name": velaCtx.AppName,
"app.oam.dev/namespace": velaCtx.AppNamespace,
"app.oam.dev/component": velaCtx.Name,
}
// Embed the publishVersion pin in the release labels so subsequent
// reconciles can short-circuit when the App is at a stable pin and the
// release was already installed at that pin.
if velaCtx.PublishVersion != "" {
labels["app.oam.dev/publishVersion"] = velaCtx.PublishVersion
}
return labels
}
// isOwnedByVela checks whether a Helm release was installed/managed by KubeVela
@@ -708,11 +883,22 @@ func isOwnedByVela(rel *release.Release, velaCtx *ContextParams) bool {
// computeReleaseFingerprint builds a deterministic string from chart version and a
// SHA-256 hash of the values so repeated reconciles with no real changes can be
// detected cheaply without calling the Kubernetes API.
//
// Empty-values inputs are normalised to an empty map before hashing. Helm
// stores release.Config as nil when no values were supplied, but mergeValues
// returns map[string]interface{}{} for the same logical input — without this
// guard the two would hash to sha256("null") and sha256("{}") respectively,
// causing the dedup check below to mis-fire and trigger spurious helm upgrades
// on every reconcile for any release that was installed with empty/optional
// values.
func computeReleaseFingerprint(ch *chart.Chart, values map[string]interface{}) string {
version := ""
if ch != nil && ch.Metadata != nil {
version = ch.Metadata.Version
}
if values == nil {
values = map[string]interface{}{}
}
valuesJSON, _ := json.Marshal(values)
h := sha256.Sum256(valuesJSON)
return version + "|" + hex.EncodeToString(h[:])
@@ -785,6 +971,34 @@ func (p *Provider) installOrUpgradeChart(ctx context.Context, ch *chart.Chart, r
p.labelReleaseSecrets(releaseNamespace, releaseName, velaCtx)
}
// publishVersion pin short-circuit: when the App is at a stable
// publishVersion pin AND the deployed release was installed at the
// same pin AND the chart version is unchanged, return the deployed
// manifest unchanged regardless of any apparent values drift.
//
// Without this, a render path that bypasses the workflow gate
// (state-keep / drift detection / post-dispatch traits / periodic
// CUE evaluation) re-merges valuesFrom sources and the cluster-side
// fingerprint compare below would mis-fire whenever a referenced
// CM/Secret was edited. The user's explicit pin is the contract:
// nothing changes until they bump the pin.
//
// Initial install has no existingRelease so this branch is skipped,
// and the initial mergeValues runs normally — picking up the
// referenced CM/Secret content and stamping it into the release.
if !needsAdoption && velaCtx != nil && velaCtx.PublishVersion != "" &&
existingRelease.Info != nil && existingRelease.Info.Status == release.StatusDeployed &&
existingRelease.Chart != nil && existingRelease.Chart.Metadata != nil &&
existingRelease.Chart.Metadata.Version == ch.Metadata.Version &&
existingRelease.Labels["app.oam.dev/publishVersion"] == velaCtx.PublishVersion {
klog.V(2).Infof("Helm provider [%s]: Release %s held by publishVersion pin %q, skipping upgrade",
velaContextStr(velaCtx), releaseName, velaCtx.PublishVersion)
p.releaseFingerprints[cacheKey] = fingerprint
p.releaseManifests[cacheKey] = existingRelease.Manifest
p.releaseVersions[cacheKey] = existingRelease.Version
return existingRelease.Manifest, existingRelease.Info.Notes, existingRelease.Version, nil
}
// Release exists — check if it is already deployed with the same fingerprint
if !needsAdoption && existingRelease.Info.Status == release.StatusDeployed {
clusterFingerprint := computeReleaseFingerprint(existingRelease.Chart, existingRelease.Config)
@@ -1299,10 +1513,16 @@ func Render(ctx context.Context, params *providers.Params[RenderParams]) (*provi
klog.V(2).Infof("Helm provider [%s]: Starting render for chart %s from %s", velaContextStr(renderParams.Context), renderParams.Chart.Source, renderParams.Chart.RepoURL)
// Set default release name and namespace
releaseName := "release"
releaseNamespace := "default"
// Application namespace is the tenant boundary. When the Application has no
// explicit context, fall back to the release namespace below so the same
// Application can be rendered outside a ComponentDefinition path.
appNamespace := ""
if renderParams.Context != nil {
appNamespace = renderParams.Context.AppNamespace
}
releaseName := "release"
releaseNamespace := appNamespace
if renderParams.Release != nil {
if renderParams.Release.Name != "" {
releaseName = renderParams.Release.Name
@@ -1311,6 +1531,50 @@ func Render(ctx context.Context, params *providers.Params[RenderParams]) (*provi
releaseNamespace = renderParams.Release.Namespace
}
}
// Guarantee a non-empty release namespace. Under the normal KubeVela
// code path the controller always sets Context.AppNamespace before
// calling Render, but callers that invoke the provider directly (tests,
// CLI tooling) may leave both context and Release.Namespace empty.
// Falling back to "default" preserves the pre-refactor behavior and
// keeps Helm's namespace resolution from depending on the caller's
// kubeconfig default.
if releaseNamespace == "" {
releaseNamespace = "default"
}
if appNamespace == "" {
appNamespace = releaseNamespace
}
// Resolve the App's publishVersion annotation, if any. We pass it through
// ContextParams.PublishVersion so installOrUpgradeChart can short-circuit
// when the deployed release is already at the current pin and so
// velaOwnerLabels can stamp the pin onto the release at install time.
// Skipped in dry-run: admission validation must not depend on cluster
// state, and the user-visible behaviour (CUE shape OK / not OK) is
// independent of the pin.
//
// IsNotFound is treated as "App is being deleted" and falls through with
// an empty pin — the subsequent uninstall path handles cleanup. Any other
// error (RBAC change, transient API failure, network blip) is surfaced
// rather than silently swallowed: a swallowed error would leave the pin
// empty for this reconcile and bypass the pin short-circuit downstream,
// allowing an unintended helm upgrade to fire even though the user's
// publishVersion annotation is still in place.
if !isDryRun(ctx) && renderParams.Context != nil && renderParams.Context.AppName != "" && appNamespace != "" {
var app v1beta1.Application
switch getErr := singleton.KubeClient.Get().Get(ctx, client.ObjectKey{Name: renderParams.Context.AppName, Namespace: appNamespace}, &app); {
case getErr == nil:
if pin := app.GetAnnotations()[oam.AnnotationPublishVersion]; pin != "" {
renderParams.Context.PublishVersion = pin
}
case apierrors.IsNotFound(getErr):
// App is gone (deletion in flight). Proceed without a pin.
default:
return nil, errors.Wrapf(getErr,
"failed to read Application %s/%s for publishVersion lookup; refusing to proceed without pin context",
appNamespace, renderParams.Context.AppName)
}
}
klog.V(3).Infof("Helm provider: Release name=%s, namespace=%s", releaseName, releaseNamespace)
@@ -1321,12 +1585,24 @@ func Render(ctx context.Context, params *providers.Params[RenderParams]) (*provi
}
klog.V(2).Infof("Helm provider: Successfully fetched chart %s", ch.Name())
// Merge values from all sources
values, err := p.mergeValues(ctx, renderParams.Values, renderParams.ValuesFrom)
if err != nil {
return nil, errors.Wrap(err, "failed to merge values")
// Skip valuesFrom resolution in dry-run (webhook admission): the webhook
// validates CUE shape and renders the chart, not the final merged values,
// and running loadValuesFromSource during admission adds N cluster reads
// per Application create/update plus ordering hazards when the referenced
// CM/Secret is applied in the same kubectl batch.
var values map[string]interface{}
if isDryRun(ctx) {
if inline, ok := renderParams.Values.(map[string]interface{}); ok {
values = inline
} else {
values = map[string]interface{}{}
}
} else {
values, err = p.mergeValues(ctx, renderParams.Values, renderParams.ValuesFrom, appNamespace, releaseNamespace)
if err != nil {
return nil, errors.Wrapf(err, "%s: failed to merge values", velaContextStr(renderParams.Context))
}
}
klog.V(3).Infof("Helm provider: Merged values: %v", values)
// In dry-run mode (webhook validation), render client-side only — no cluster
// interaction, no real install, no hooks. This prevents the webhook from
+602 -22
View File
@@ -33,10 +33,20 @@ import (
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/release"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
kyaml "k8s.io/apimachinery/pkg/util/yaml"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"github.com/kubevela/pkg/cue/cuex/providers"
"github.com/kubevela/pkg/util/singleton"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/oam"
)
var _ = Describe("Helm Provider", func() {
@@ -129,6 +139,11 @@ var _ = Describe("Helm Provider", func() {
BeforeEach(func() {
p = NewProviderWithConfig(nil)
ctx = context.Background()
// Empty fake client so ConfigMap/Secret Gets return NotFound. Tests
// that need specific resources override the singleton themselves.
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
})
It("should return base values when no valuesFrom", func() {
@@ -138,34 +153,34 @@ var _ = Describe("Helm Provider", func() {
"key2": "value2",
},
}
result, err := p.mergeValues(ctx, baseValues, nil)
result, err := p.mergeValues(ctx, baseValues, nil, "default", "default")
Expect(err).ShouldNot(HaveOccurred())
Expect(result).To(Equal(baseValues))
})
It("should return empty map for nil base values", func() {
result, err := p.mergeValues(ctx, nil, nil)
result, err := p.mergeValues(ctx, nil, nil, "default", "default")
Expect(err).ShouldNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result).To(BeEmpty())
})
It("should skip optional source errors", func() {
It("should skip optional source when ConfigMap is missing", func() {
base := map[string]interface{}{"key": "value"}
valuesFrom := []ValuesFromParams{
{Kind: "ConfigMap", Name: "missing", Optional: true},
}
result, err := p.mergeValues(ctx, base, valuesFrom)
result, err := p.mergeValues(ctx, base, valuesFrom, "default", "default")
Expect(err).ShouldNot(HaveOccurred())
Expect(result["key"]).To(Equal("value"))
})
It("should propagate required source errors", func() {
It("should propagate missing-source errors when not optional", func() {
base := map[string]interface{}{"key": "value"}
valuesFrom := []ValuesFromParams{
{Kind: "ConfigMap", Name: "missing", Optional: false},
}
_, err := p.mergeValues(ctx, base, valuesFrom)
_, err := p.mergeValues(ctx, base, valuesFrom, "default", "default")
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to load values"))
})
@@ -410,6 +425,19 @@ metadata:
fp := computeReleaseFingerprint(nil, map[string]interface{}{"replicas": 2})
Expect(fp).ToNot(BeEmpty())
})
It("should treat nil values and empty map as equivalent", func() {
// Helm stores release.Config as nil when no values were supplied
// at install time, but mergeValues returns an empty map for the
// same logical input. Without normalising the two, the dedup
// check at the call site would mis-fire on every reconcile and
// trigger spurious helm upgrades for releases installed with
// empty/optional valuesFrom sources.
ch := &chart.Chart{Metadata: &chart.Metadata{Version: "1.2.3"}}
fpNil := computeReleaseFingerprint(ch, nil)
fpEmpty := computeReleaseFingerprint(ch, map[string]interface{}{})
Expect(fpNil).To(Equal(fpEmpty))
})
})
Describe("cache invalidation on missing release", func() {
@@ -766,24 +794,419 @@ spec:
})
})
Describe("loadValuesFromSource", func() {
Describe("loadValuesFromSource dispatcher", func() {
var p *Provider
BeforeEach(func() {
p = NewProviderWithConfig(nil)
})
DescribeTable("should return errors for unimplemented/unsupported source kinds",
func(kind, expectedErr string) {
_, err := p.loadValuesFromSource(context.Background(), ValuesFromParams{Kind: kind, Name: "test"})
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(expectedErr))
},
Entry("ConfigMap", "ConfigMap", "configmap values source not yet implemented"),
Entry("Secret", "Secret", "secret values source not yet implemented"),
Entry("OCIRepository", "OCIRepository", "ocirepository values source not yet implemented"),
Entry("Unknown", "Unknown", "unsupported values source kind: Unknown"),
It("returns an error for unsupported kinds (including the reserved OCIRepository)", func() {
for _, kind := range []string{"OCIRepository", "Unknown", "configmap", ""} {
_, err := p.loadValuesFromSource(context.Background(),
ValuesFromParams{Kind: kind, Name: "test"},
"default", "default")
Expect(err).Should(HaveOccurred(), "kind=%q must fail", kind)
Expect(err.Error()).To(ContainSubstring("unsupported values source kind"),
"kind=%q must surface as unsupported", kind)
}
})
})
Describe("cross-namespace valuesFrom rejection", func() {
var p *Provider
BeforeEach(func() {
p = NewProviderWithConfig(nil)
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).Build())
})
It("rejects a ConfigMap reference to a namespace other than the Application's", func() {
_, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "secrets-bearer", Namespace: "kube-system"},
"tenant-a", "tenant-a")
Expect(err).Should(HaveOccurred())
Expect(errors.Is(err, errCrossNamespaceValuesFrom)).To(BeTrue(),
"cross-ns error must be detectable via errors.Is")
Expect(err.Error()).To(ContainSubstring("kube-system"))
Expect(err.Error()).To(ContainSubstring("tenant-a"))
})
It("rejects a Secret reference to a namespace other than the Application's", func() {
_, err := p.loadSecretValues(context.Background(),
ValuesFromParams{Kind: "Secret", Name: "any", Namespace: "other-tenant"},
"tenant-a", "tenant-a")
Expect(err).Should(HaveOccurred())
Expect(errors.Is(err, errCrossNamespaceValuesFrom)).To(BeTrue())
})
It("allows an explicit Namespace equal to the Application's namespace", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "same", Namespace: "tenant-a"},
Data: map[string]string{"values.yaml": "k: v"},
}
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build())
values, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "same", Namespace: "tenant-a"},
"tenant-a", "tenant-a")
Expect(err).ShouldNot(HaveOccurred())
Expect(values["k"]).To(Equal("v"))
})
})
Describe("loadConfigMapValues", func() {
const releaseNS = "prod"
var p *Provider
BeforeEach(func() {
p = NewProviderWithConfig(nil)
})
buildClient := func(objs ...client.Object) {
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
builder := fake.NewClientBuilder().WithScheme(scheme)
for _, o := range objs {
builder = builder.WithObjects(o)
}
singleton.KubeClient.Set(builder.Build())
}
It("loads from the default values.yaml key when Key is empty", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicaCount: 3\nimage: nginx"},
}
buildClient(cm)
values, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "cfg"}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(values["replicaCount"]).To(BeEquivalentTo(3))
Expect(values["image"]).To(Equal("nginx"))
})
It("loads from an explicit Key", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: releaseNS},
Data: map[string]string{"prod.yaml": "replicaCount: 5"},
}
buildClient(cm)
values, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "cfg", Key: "prod.yaml"}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(values["replicaCount"]).To(BeEquivalentTo(5))
})
It("accepts an explicit Namespace that equals the Application's namespace", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicaCount: 7"},
}
buildClient(cm)
values, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "cfg", Namespace: releaseNS}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(values["replicaCount"]).To(BeEquivalentTo(7))
})
It("returns a missing-source error when the ConfigMap does not exist", func() {
buildClient()
_, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "absent"}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred())
Expect(isValueSourceMissing(err)).To(BeTrue(),
"missing ConfigMap should surface as valueSourceMissingError")
})
It("returns a missing-source error when the key is absent", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: releaseNS},
Data: map[string]string{"other.yaml": "foo: bar"},
}
buildClient(cm)
_, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "cfg"}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred())
Expect(isValueSourceMissing(err)).To(BeTrue())
})
It("surfaces YAML parse errors and never classifies them as missing", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicas: [unterminated"},
}
buildClient(cm)
_, err := p.loadConfigMapValues(context.Background(),
ValuesFromParams{Kind: "ConfigMap", Name: "cfg"}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid YAML"))
Expect(isValueSourceMissing(err)).To(BeFalse(),
"parse errors must NOT be swallowed by optional")
})
})
Describe("loadSecretValues", func() {
const releaseNS = "prod"
var p *Provider
BeforeEach(func() {
p = NewProviderWithConfig(nil)
})
buildClient := func(objs ...client.Object) {
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
builder := fake.NewClientBuilder().WithScheme(scheme)
for _, o := range objs {
builder = builder.WithObjects(o)
}
singleton.KubeClient.Set(builder.Build())
}
It("loads YAML from Secret.Data (already base64-decoded by the API)", func() {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: releaseNS},
Data: map[string][]byte{
"values.yaml": []byte("password: s3cret\nuser: admin"),
},
}
buildClient(secret)
values, err := p.loadSecretValues(context.Background(),
ValuesFromParams{Kind: "Secret", Name: "creds"}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(values["user"]).To(Equal("admin"))
Expect(values["password"]).To(Equal("s3cret"))
})
It("returns a missing-source error when the Secret does not exist", func() {
buildClient()
_, err := p.loadSecretValues(context.Background(),
ValuesFromParams{Kind: "Secret", Name: "absent"}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred())
Expect(isValueSourceMissing(err)).To(BeTrue())
})
It("surfaces YAML parse errors and does not leak raw secret bytes", func() {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: releaseNS},
Data: map[string][]byte{"values.yaml": []byte("super-secret: [unterminated")},
}
buildClient(secret)
_, err := p.loadSecretValues(context.Background(),
ValuesFromParams{Kind: "Secret", Name: "creds"}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred())
Expect(err.Error()).ToNot(ContainSubstring("super-secret"),
"Secret contents must never appear in error messages")
Expect(isValueSourceMissing(err)).To(BeFalse())
})
})
Describe("mergeValues priority", func() {
const releaseNS = "prod"
var (
p *Provider
ctx context.Context
)
BeforeEach(func() {
p = NewProviderWithConfig(nil)
ctx = context.Background()
})
It("gives inline values the highest priority over valuesFrom", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicaCount: 3\nimage: from-configmap"},
}
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(cm).Build())
base := map[string]interface{}{"image": "from-inline"}
result, err := p.mergeValues(ctx, base,
[]ValuesFromParams{{Kind: "ConfigMap", Name: "cm"}}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(result["image"]).To(Equal("from-inline"), "inline must win over ConfigMap")
Expect(result["replicaCount"]).To(BeEquivalentTo(3), "CM-only keys must remain")
})
It("makes later valuesFrom entries override earlier ones", func() {
cmA := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "tier: free\ncolour: blue"},
}
cmB := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "tier: paid"},
}
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
singleton.KubeClient.Set(fake.NewClientBuilder().WithScheme(scheme).WithObjects(cmA, cmB).Build())
result, err := p.mergeValues(ctx, nil,
[]ValuesFromParams{
{Kind: "ConfigMap", Name: "a"},
{Kind: "ConfigMap", Name: "b"},
}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(result["tier"]).To(Equal("paid"), "later source must win on conflict")
Expect(result["colour"]).To(Equal("blue"), "earlier source keeps non-overridden keys")
})
})
Describe("mergeValues edge cases", func() {
const releaseNS = "prod"
var (
p *Provider
ctx context.Context
)
BeforeEach(func() {
p = NewProviderWithConfig(nil)
ctx = context.Background()
})
buildClient := func(objs ...client.Object) {
scheme := runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
builder := fake.NewClientBuilder().WithScheme(scheme)
for _, o := range objs {
builder = builder.WithObjects(o)
}
singleton.KubeClient.Set(builder.Build())
}
It("treats empty valuesFrom slice equivalently to nil", func() {
buildClient()
base := map[string]interface{}{"key": "value"}
fromNil, errNil := p.mergeValues(ctx, base, nil, releaseNS, releaseNS)
fromEmpty, errEmpty := p.mergeValues(ctx, base, []ValuesFromParams{}, releaseNS, releaseNS)
Expect(errNil).ShouldNot(HaveOccurred())
Expect(errEmpty).ShouldNot(HaveOccurred())
Expect(fromEmpty).To(Equal(fromNil))
})
It("skips a missing optional source and continues with a following required source", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "real", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicaCount: 7"},
}
buildClient(cm)
result, err := p.mergeValues(ctx, nil, []ValuesFromParams{
{Kind: "ConfigMap", Name: "missing", Optional: true},
{Kind: "ConfigMap", Name: "real"},
}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(result["replicaCount"]).To(BeEquivalentTo(7))
})
It("preserves orthogonal nested keys while resolving conflicts at depth", func() {
cmA := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: releaseNS},
Data: map[string]string{"values.yaml": `resources:
limits:
cpu: 100m
memory: 256Mi
requests:
cpu: 50m`},
}
cmB := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: releaseNS},
Data: map[string]string{"values.yaml": `resources:
limits:
memory: 512Mi`},
}
buildClient(cmA, cmB)
result, err := p.mergeValues(ctx, nil, []ValuesFromParams{
{Kind: "ConfigMap", Name: "a"},
{Kind: "ConfigMap", Name: "b"},
}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
resources := result["resources"].(map[string]interface{})
limits := resources["limits"].(map[string]interface{})
requests := resources["requests"].(map[string]interface{})
Expect(limits["memory"]).To(Equal("512Mi"), "later source wins on conflict deep in the tree")
Expect(limits["cpu"]).To(Equal("100m"), "orthogonal sibling in the same sub-object preserved")
Expect(requests["cpu"]).To(Equal("50m"), "untouched sub-object preserved in full")
})
It("replaces array values instead of merging them (helm CoalesceTables semantics)", func() {
cmA := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "extraArgs:\n - --level=debug\n - --timeout=30"},
}
cmB := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "extraArgs:\n - --level=info"},
}
buildClient(cmA, cmB)
result, err := p.mergeValues(ctx, nil, []ValuesFromParams{
{Kind: "ConfigMap", Name: "a"},
{Kind: "ConfigMap", Name: "b"},
}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
args := result["extraArgs"].([]interface{})
Expect(args).To(HaveLen(1), "later array wholly replaces earlier array")
Expect(args[0]).To(Equal("--level=info"))
})
It("surfaces parse errors even when Optional is true", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "broken", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicas: [unterminated"},
}
buildClient(cm)
_, err := p.mergeValues(ctx, nil, []ValuesFromParams{
{Kind: "ConfigMap", Name: "broken", Optional: true},
}, releaseNS, releaseNS)
Expect(err).Should(HaveOccurred(),
"Optional must not mask parse errors — this is the critical contract")
Expect(err.Error()).To(ContainSubstring("invalid YAML"))
Expect(isValueSourceMissing(err)).To(BeFalse())
})
It("mixes a Secret and ConfigMap in the same valuesFrom list", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: releaseNS},
Data: map[string]string{"values.yaml": "replicaCount: 2\nimage: cm-image"},
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "creds", Namespace: releaseNS},
Data: map[string][]byte{"values.yaml": []byte("image: secret-image")},
}
buildClient(cm, secret)
result, err := p.mergeValues(ctx, nil, []ValuesFromParams{
{Kind: "ConfigMap", Name: "cm"},
{Kind: "Secret", Name: "creds"},
}, releaseNS, releaseNS)
Expect(err).ShouldNot(HaveOccurred())
Expect(result["image"]).To(Equal("secret-image"), "later Secret wins over earlier ConfigMap")
Expect(result["replicaCount"]).To(BeEquivalentTo(2), "orthogonal CM key preserved")
})
})
Describe("fetchChartWithoutCache", func() {
@@ -1429,6 +1852,161 @@ data:
})
})
// -----------------------------------------------------------------------
// Application publishVersion lookup defense
//
// Render reads the parent Application's publishVersion annotation so that
// installOrUpgradeChart can short-circuit when the deployed release is
// already at the user's pin. A non-NotFound error during that lookup
// previously left the pin empty, allowing an unintended upgrade to fire
// even though the user's pin was still in place. The defense surfaces
// that error instead of swallowing it.
// -----------------------------------------------------------------------
Describe("Render Application publishVersion lookup defense", func() {
var scheme *runtime.Scheme
BeforeEach(func() {
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
Expect(v1beta1.AddToScheme(scheme)).To(Succeed())
})
It("propagates a transient API error from the Application Get rather than bypassing the pin", func() {
injected := errors.New("etcdserver: leader changed")
c := fake.NewClientBuilder().
WithScheme(scheme).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, kc client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*v1beta1.Application); ok {
return injected
}
return kc.Get(ctx, key, obj, opts...)
},
}).
Build()
singleton.KubeClient.Set(c)
_, err := Render(context.Background(), &providers.Params[RenderParams]{
Params: RenderParams{
Chart: ChartSourceParams{Source: "render-defense-1", Version: "1.0.0"},
Release: &ReleaseParams{Name: "rel", Namespace: "tenant-a"},
Context: &ContextParams{
AppName: "my-app",
AppNamespace: "tenant-a",
Name: "my-comp",
Namespace: "tenant-a",
},
},
})
Expect(err).Should(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to read Application tenant-a/my-app for publishVersion lookup"),
"defense must surface the wrapped App-lookup error")
Expect(err.Error()).To(ContainSubstring("etcdserver: leader changed"),
"defense must preserve the underlying cause")
})
It("treats a NotFound on the Application as deletion-in-flight and proceeds past the lookup", func() {
// No App registered → NotFound from the typed fake client.
// Render must continue past the lookup; we prove it by leaving
// the chart un-cached so the next failure is from fetchChart,
// not the App lookup.
c := fake.NewClientBuilder().WithScheme(scheme).Build()
singleton.KubeClient.Set(c)
_, err := Render(context.Background(), &providers.Params[RenderParams]{
Params: RenderParams{
Chart: ChartSourceParams{Source: "render-defense-notfound-xyz", Version: "9.9.9"},
Release: &ReleaseParams{Name: "rel", Namespace: "tenant-a"},
Context: &ContextParams{
AppName: "deleted-app",
AppNamespace: "tenant-a",
Name: "my-comp",
Namespace: "tenant-a",
},
},
})
Expect(err).Should(HaveOccurred())
Expect(err.Error()).ToNot(ContainSubstring("failed to read Application"),
"NotFound on App must not be reported as a pin-lookup error")
Expect(err.Error()).To(ContainSubstring("failed to fetch chart"),
"Render must proceed past the App lookup to the chart fetch step")
})
It("succeeds the App lookup when the Application exists with a publishVersion annotation", func() {
app := &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "pinned-app",
Namespace: "tenant-a",
Annotations: map[string]string{oam.AnnotationPublishVersion: "v42"},
},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app).Build()
singleton.KubeClient.Set(c)
_, err := Render(context.Background(), &providers.Params[RenderParams]{
Params: RenderParams{
Chart: ChartSourceParams{Source: "render-defense-pinned-xyz", Version: "9.9.9"},
Release: &ReleaseParams{Name: "rel", Namespace: "tenant-a"},
Context: &ContextParams{
AppName: "pinned-app",
AppNamespace: "tenant-a",
Name: "my-comp",
Namespace: "tenant-a",
},
},
})
Expect(err).Should(HaveOccurred())
Expect(err.Error()).ToNot(ContainSubstring("failed to read Application"),
"a present App with a pin must not surface a lookup error")
Expect(err.Error()).To(ContainSubstring("failed to fetch chart"),
"Render must proceed past the App lookup to the chart fetch step")
})
It("skips the App lookup entirely in dry-run", func() {
// Dry-run is the webhook admission path; it must never depend on
// cluster state. Inject a Get error: if the lookup ran, Render
// would surface it. Instead, dry-run renders client-side.
c := fake.NewClientBuilder().
WithScheme(scheme).
WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, kc client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*v1beta1.Application); ok {
return errors.New("dry-run must not call Get on Application")
}
return kc.Get(ctx, key, obj, opts...)
},
}).
Build()
singleton.KubeClient.Set(c)
p := NewProvider()
p.cache.Put("repo/render-defense-dryrun/1.0.0", &chart.Chart{
Metadata: &chart.Metadata{Name: "render-defense-dryrun", Version: "1.0.0"},
Templates: []*chart.File{{
Name: "templates/cm.yaml",
Data: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: {{ .Release.Name }}-cm\n"),
}},
}, 1*time.Hour)
ctx := WithDryRun(context.Background())
_, err := Render(ctx, &providers.Params[RenderParams]{
Params: RenderParams{
Chart: ChartSourceParams{Source: "render-defense-dryrun", Version: "1.0.0"},
Release: &ReleaseParams{Name: "rel", Namespace: "tenant-a"},
Context: &ContextParams{
AppName: "any-app",
AppNamespace: "tenant-a",
Name: "my-comp",
Namespace: "tenant-a",
},
},
})
Expect(err).ShouldNot(HaveOccurred(),
"dry-run must not exercise the App lookup path")
})
})
// -----------------------------------------------------------------------
// Tier 1 Coverage: fetchRepoChart via httptest
// -----------------------------------------------------------------------
@@ -1682,7 +2260,12 @@ entries:
// -----------------------------------------------------------------------
Describe("Uninstall top-level function", func() {
It("should exercise the function entry and error path", func() {
It("should exercise the function entry and return without panicking", func() {
// Without a reachable cluster the call typically fails at
// getActionConfig or while contacting the API server; with a
// cluster it succeeds. The only invariant we can assert portably
// is that the function returns in a consistent shape — result is
// non-nil on success, or an error is returned on failure.
result, err := Uninstall(context.Background(), &providers.Params[UninstallParams]{
Params: UninstallParams{
Release: ReleaseParams{
@@ -1692,10 +2275,7 @@ entries:
KeepHistory: false,
},
})
// Without a cluster, this may fail at getActionConfig or at the actual uninstall
if err != nil {
Expect(err.Error()).To(Or(ContainSubstring("helm"), ContainSubstring("uninstall"), ContainSubstring("config")))
} else {
if err == nil {
Expect(result).ToNot(BeNil())
}
})
+646 -26
View File
@@ -215,7 +215,7 @@ func runCommandSucceed(name string, args ...string) string {
var _ = Describe("Helmchart Self-Healing", func() {
Context("Scenario 1: Delete a Single Managed Resource (Deployment)", Ordered, func() {
Context("Delete a Single Managed Resource (Deployment)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -257,7 +257,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 2: Helm Uninstall the Release", Ordered, func() {
Context("Helm Uninstall the Release", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -301,7 +301,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 3: Delete ONLY the Helm Release Secret", Ordered, func() {
Context("Delete ONLY the Helm Release Secret", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -358,7 +358,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 4: Mutate a Managed Resource (Scale Deployment)", Ordered, func() {
Context("Mutate a Managed Resource (Scale Deployment)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -393,7 +393,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 5: Add Extra Annotation/Label", Ordered, func() {
Context("Add Extra Annotation/Label", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -426,7 +426,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 6: Delete the Application CR", Ordered, func() {
Context("Delete the Application CR", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanupNamespaceOnly() })
@@ -483,7 +483,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 7: Delete a Non-Deployment Resource (Service)", Ordered, func() {
Context("Delete a Non-Deployment Resource (Service)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -523,7 +523,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 8: Delete the Namespace", Ordered, func() {
Context("Delete the Namespace", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -572,7 +572,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
})
})
Context("Scenario 9: Corrupt the Helm Release Secret", Ordered, func() {
Context("Corrupt the Helm Release Secret", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -632,7 +632,7 @@ var _ = Describe("Helmchart Self-Healing", func() {
var _ = Describe("Helmchart Adoption & Takeover", func() {
Context("Scenario 10: Adopt an Existing Vanilla Helm Release", Ordered, func() {
Context("Adopt an Existing Vanilla Helm Release", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -682,7 +682,7 @@ var _ = Describe("Helmchart Adoption & Takeover", func() {
})
})
Context("Scenario 11: Adopt Release with Different Values", Ordered, func() {
Context("Adopt Release with Different Values", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -734,7 +734,7 @@ var _ = Describe("Helmchart Adoption & Takeover", func() {
})
})
Context("Scenario 12: Re-adopt After Application Deletion", Ordered, func() {
Context("Re-adopt After Application Deletion", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanupNamespaceOnly() })
@@ -778,7 +778,7 @@ var _ = Describe("Helmchart Adoption & Takeover", func() {
var _ = Describe("Helmchart State Integrity", func() {
Context("Scenario 13: Upgrade History Preserved Across Multiple Changes", Ordered, func() {
Context("Upgrade History Preserved Across Multiple Changes", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -819,7 +819,7 @@ var _ = Describe("Helmchart State Integrity", func() {
var _ = Describe("Helmchart Destructive & Chaos", func() {
Context("Scenario 14: Two Applications Targeting Same Release Name", Ordered, func() {
Context("Two Applications Targeting Same Release Name", Ordered, func() {
h := newHelmTestContext()
var appB *v1beta1.Application
BeforeAll(func() { h.createNamespace() })
@@ -869,7 +869,7 @@ var _ = Describe("Helmchart Destructive & Chaos", func() {
var _ = Describe("Helmchart Resource Ordering", func() {
Context("Scenario 15: Chart with CRDs (crossplane)", Ordered, func() {
Context("Chart with CRDs (crossplane)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanupNamespaceOnly() })
@@ -951,7 +951,7 @@ spec:
})
})
Context("Scenario 16: Chart with Namespaces (createNamespace)", Ordered, func() {
Context("Chart with Namespaces (createNamespace)", Ordered, func() {
h := newHelmTestContext()
AfterAll(func() { h.cleanup() })
@@ -975,7 +975,7 @@ spec:
var _ = Describe("Helmchart Health Checks", func() {
Context("Scenario 17: Custom Health Check — Deployment Available", Ordered, func() {
Context("Custom Health Check — Deployment Available", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1007,7 +1007,7 @@ var _ = Describe("Helmchart Health Checks", func() {
})
})
Context("Scenario 18: Custom Health Check — Multiple Criteria (Two Components)", Ordered, func() {
Context("Custom Health Check — Multiple Criteria (Two Components)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1055,7 +1055,7 @@ var _ = Describe("Helmchart Health Checks", func() {
})
})
Context("Scenario 19: No Health Check Defined", Ordered, func() {
Context("No Health Check Defined", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1073,7 +1073,7 @@ var _ = Describe("Helmchart Health Checks", func() {
var _ = Describe("Helmchart Edge Cases", func() {
Context("Scenario 20: Empty Values", Ordered, func() {
Context("Empty Values", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1090,7 +1090,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 21: Namespace Does Not Exist and createNamespace=false", Ordered, func() {
Context("Namespace Does Not Exist and createNamespace=false", Ordered, func() {
h := &helmTestContext{
ctx: context.Background(),
namespace: "nonexistent-ns-" + rand.RandomString(4),
@@ -1126,7 +1126,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 22: Chart Not Found in Repository", Ordered, func() {
Context("Chart Not Found in Repository", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() {
@@ -1164,7 +1164,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 23: Invalid Chart Version", Ordered, func() {
Context("Invalid Chart Version", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1225,7 +1225,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 24: Two helmchart Components in Same Application", Ordered, func() {
Context("Two helmchart Components in Same Application", Ordered, func() {
h := newHelmTestContext()
nsA := "helm-multi-a-" + rand.RandomString(4)
nsB := "helm-multi-b-" + rand.RandomString(4)
@@ -1364,7 +1364,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 25: Helm Release Exists with Different Chart", Ordered, func() {
Context("Helm Release Exists with Different Chart", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1418,7 +1418,7 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
Context("Scenario 26: Apply Same Application Twice Without Changes", Ordered, func() {
Context("Apply Same Application Twice Without Changes", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
@@ -1459,6 +1459,626 @@ var _ = Describe("Helmchart Edge Cases", func() {
})
})
// ============================================================================
// valuesFrom Tests Scenarios
// ============================================================================
var _ = Describe("Helmchart valuesFrom", func() {
createCM := func(h *helmTestContext, name, key, valuesYAML string) {
if key == "" {
key = "values.yaml"
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace},
Data: map[string]string{key: valuesYAML},
}
Expect(k8sClient.Create(h.ctx, cm)).Should(Succeed())
}
createCMWithReplicas := func(h *helmTestContext, name string, replicaCount int) {
createCM(h, name, "", fmt.Sprintf("replicaCount: %d\n", replicaCount))
}
createCMInNamespace := func(h *helmTestContext, name, ns, valuesYAML string) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
Data: map[string]string{"values.yaml": valuesYAML},
}
Expect(k8sClient.Create(h.ctx, cm)).Should(Succeed())
}
createSecret := func(h *helmTestContext, name, valuesYAML string) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: h.namespace},
Data: map[string][]byte{"values.yaml": []byte(valuesYAML)},
}
Expect(k8sClient.Create(h.ctx, secret)).Should(Succeed())
}
createSecretWithReplicas := func(h *helmTestContext, name string, replicaCount int) {
createSecret(h, name, fmt.Sprintf("replicaCount: %d\n", replicaCount))
}
buildPodinfoComponent := func(h *helmTestContext, componentName, releaseName string, props map[string]interface{}) common2.ApplicationComponent {
merged := map[string]interface{}{
"chart": map[string]interface{}{
"source": "podinfo",
"repoURL": "https://stefanprodan.github.io/podinfo",
"version": "6.11.1",
},
"release": map[string]interface{}{
"name": releaseName,
"namespace": h.namespace,
},
"options": map[string]interface{}{
"createNamespace": true,
"skipTests": true,
},
}
for k, v := range props {
merged[k] = v
}
raw, err := json.Marshal(merged)
Expect(err).ShouldNot(HaveOccurred())
return common2.ApplicationComponent{
Name: componentName,
Type: "helmchart",
Properties: &runtime.RawExtension{Raw: raw},
}
}
deployAppWithComponents := func(h *helmTestContext, appNamePrefix string, comps []common2.ApplicationComponent) {
h.app = &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: appNamePrefix + "-" + rand.RandomString(4),
Namespace: h.appNamespace,
},
Spec: v1beta1.ApplicationSpec{Components: comps},
}
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))
}, 120*time.Second, 3*time.Second).Should(Succeed())
}
deployPodinfo := func(h *helmTestContext, appNamePrefix, releaseName string, props map[string]interface{}) {
comp := buildPodinfoComponent(h, "podinfo", releaseName, props)
deployAppWithComponents(h, appNamePrefix, []common2.ApplicationComponent{comp})
}
deployPodinfoExpectWorkflowFailure := func(h *helmTestContext, appNamePrefix, releaseName string, props map[string]interface{}, errSubstring string) {
comp := buildPodinfoComponent(h, "podinfo", releaseName, props)
h.app = &v1beta1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: appNamePrefix + "-" + 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.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; status=%+v", errSubstring, h.app.Status.Workflow)
}, 180*time.Second, 5*time.Second).Should(Succeed())
err := k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, &appsv1.Deployment{})
Expect(err).To(HaveOccurred(), "no Deployment should exist for a failed workflow")
}
waitForReplicas := func(h *helmTestContext, want int32) {
Eventually(func(g Gomega) {
deploy := &appsv1.Deployment{}
g.Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, deploy)).Should(Succeed())
g.Expect(deploy.Status.ReadyReplicas).Should(Equal(want))
}, 120*time.Second, 3*time.Second).Should(Succeed())
}
waitForNamedReplicas := func(h *helmTestContext, deployName string, want int32) {
Eventually(func(g Gomega) {
deploy := &appsv1.Deployment{}
g.Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: deployName}, deploy)).Should(Succeed())
g.Expect(deploy.Status.ReadyReplicas).Should(Equal(want))
}, 120*time.Second, 3*time.Second).Should(Succeed())
}
cmRef := func(name string, opts ...map[string]interface{}) map[string]interface{} {
entry := map[string]interface{}{"kind": "ConfigMap", "name": name}
for _, o := range opts {
for k, v := range o {
entry[k] = v
}
}
return entry
}
secretRef := func(name string, opts ...map[string]interface{}) map[string]interface{} {
entry := map[string]interface{}{"kind": "Secret", "name": name}
for _, o := range opts {
for k, v := range o {
entry[k] = v
}
}
return entry
}
Context("Values from ConfigMap", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should merge values from the referenced ConfigMap", func() {
createCMWithReplicas(h, "podinfo-values", 3)
deployPodinfo(h, "s27", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-values")},
})
waitForReplicas(h, 3)
})
})
Context("Values from Secret", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should merge values from the referenced Secret", func() {
createSecretWithReplicas(h, "podinfo-values", 2)
deployPodinfo(h, "s28", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{secretRef("podinfo-values")},
})
waitForReplicas(h, 2)
})
})
Context("Inline values override ConfigMap-supplied values", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should use inline replicaCount when it also appears in the ConfigMap", func() {
createCMWithReplicas(h, "podinfo-values", 2)
deployPodinfo(h, "s29", "podinfo", map[string]interface{}{
"values": map[string]interface{}{"replicaCount": 4},
"valuesFrom": []interface{}{cmRef("podinfo-values")},
})
waitForReplicas(h, 4)
})
})
Context("Optional missing valuesFrom source is skipped", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should deploy successfully even when the optional ConfigMap is missing", func() {
deployPodinfo(h, "s30", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("never-created", map[string]interface{}{"optional": true}),
},
})
waitForReplicas(h, 1) // chart default
})
})
Context("Required missing valuesFrom source fails the workflow with a clear error", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should fail the workflow and surface the missing-CM error", func() {
deployPodinfoExpectWorkflowFailure(h, "s31", "podinfo",
map[string]interface{}{
"valuesFrom": []interface{}{cmRef("never-created")},
},
`ConfigMap "never-created"`)
})
})
Context("Invalid YAML in a source is never swallowed by optional:true", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should surface the parse error even when the source is marked optional", func() {
createCM(h, "podinfo-bad-yaml", "", "replicaCount: [unterminated")
deployPodinfoExpectWorkflowFailure(h, "s32", "podinfo",
map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-bad-yaml", map[string]interface{}{"optional": true}),
},
},
"invalid YAML")
})
})
Context("Custom key selects the right values.yaml inside a multi-env ConfigMap", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should use the specified key and ignore other keys in the ConfigMap", func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "podinfo-multi-env-values", Namespace: h.namespace},
Data: map[string]string{
"dev.yaml": "replicaCount: 1\n",
"prod.yaml": "replicaCount: 5\n",
},
}
Expect(k8sClient.Create(h.ctx, cm)).Should(Succeed())
deployPodinfo(h, "s33", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-multi-env-values", map[string]interface{}{"key": "prod.yaml"}),
},
})
waitForReplicas(h, 5)
})
})
Context("Cross-namespace valuesFrom references are rejected", Ordered, func() {
h := newHelmTestContext()
otherNS := "helm-other-tenant-" + rand.RandomString(4)
BeforeAll(func() {
h.createNamespace()
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}}
Expect(k8sClient.Create(h.ctx, ns)).Should(Succeed())
// Put a real ConfigMap in the other namespace so the failure is
// due to the cross-namespace guard, not a NotFound.
createCMInNamespace(h, "podinfo-values", otherNS, "replicaCount: 3\n")
})
AfterAll(func() {
h.cleanup()
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}}
_ = k8sClient.Delete(h.ctx, ns, client.PropagationPolicy(metav1.DeletePropagationForeground))
})
It("should fail the workflow when valuesFrom.namespace != Application namespace", func() {
deployPodinfoExpectWorkflowFailure(h, "s34", "podinfo",
map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-values", map[string]interface{}{"namespace": otherNS}),
},
},
"cross-namespace valuesFrom")
})
})
Context("Two ConfigMaps in valuesFrom — later overrides earlier on conflict", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should resolve replicaCount to the later CM's value", func() {
createCMWithReplicas(h, "podinfo-base-values", 2)
createCMWithReplicas(h, "podinfo-overlay-values", 4)
deployPodinfo(h, "s35", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-base-values"),
cmRef("podinfo-overlay-values"),
},
})
waitForReplicas(h, 4)
})
})
Context("Deep merge preserves orthogonal nested keys across sources", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should keep base sibling keys when the overlay touches only one field in a nested map", func() {
createCM(h, "podinfo-base-values", "", `resources:
limits:
cpu: 100m
memory: 256Mi
requests:
cpu: 50m
replicaCount: 2
`)
createCM(h, "podinfo-overlay-values", "", `resources:
limits:
memory: 512Mi
`)
deployPodinfo(h, "s36", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-base-values"),
cmRef("podinfo-overlay-values"),
},
})
waitForReplicas(h, 2)
deploy := &appsv1.Deployment{}
Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, deploy)).Should(Succeed())
limits := deploy.Spec.Template.Spec.Containers[0].Resources.Limits
requests := deploy.Spec.Template.Spec.Containers[0].Resources.Requests
Expect(limits.Memory().String()).To(Equal("512Mi"),
"overlay memory should win on direct conflict")
Expect(limits.Cpu().String()).To(Equal("100m"),
"base cpu should survive because overlay only touched memory")
Expect(requests.Cpu().String()).To(Equal("50m"),
"untouched requests.cpu from base must survive")
})
})
Context("Mixed ConfigMap and Secret in the same valuesFrom list", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should merge values from a ConfigMap followed by a Secret", func() {
createCM(h, "podinfo-cm-values", "", "replicaCount: 2\nimage:\n tag: 6.11.0\n")
createSecret(h, "podinfo-secret-values", "replicaCount: 3\n")
deployPodinfo(h, "s37", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-cm-values"),
secretRef("podinfo-secret-values"),
},
})
waitForReplicas(h, 3)
})
})
Context("Two Secrets in valuesFrom — later overrides earlier", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should resolve conflicts between two Secrets with later-wins", func() {
createSecretWithReplicas(h, "podinfo-secret-a", 1)
createSecretWithReplicas(h, "podinfo-secret-b", 4)
deployPodinfo(h, "s38", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
secretRef("podinfo-secret-a"),
secretRef("podinfo-secret-b"),
},
})
waitForReplicas(h, 4)
})
})
Context("Application with only valuesFrom and no inline values", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should deploy using values sourced entirely from the ConfigMap", func() {
createCMWithReplicas(h, "podinfo-values", 2)
deployPodinfo(h, "s39", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-values")},
})
waitForReplicas(h, 2)
})
})
Context("Empty valuesFrom list behaves like no valuesFrom", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should deploy at chart defaults when valuesFrom is an empty list", func() {
deployPodinfo(h, "s40", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{},
})
waitForReplicas(h, 1) // chart default
})
})
Context("Optional missing source is skipped, subsequent required source is still applied", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should skip the missing optional CM and use the following required CM's values", func() {
createCMWithReplicas(h, "podinfo-real-values", 3)
deployPodinfo(h, "s41", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("never-created", map[string]interface{}{"optional": true}),
cmRef("podinfo-real-values"),
},
})
waitForReplicas(h, 3)
})
})
Context("Non-existent explicit namespace fails required lookup cleanly", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should fail with a not-found error referencing the missing namespace", func() {
deployPodinfoExpectWorkflowFailure(h, "s42", "podinfo",
map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("any-cm", map[string]interface{}{"namespace": "does-not-exist-ns"}),
},
},
"does-not-exist-ns")
})
})
Context("Array values are replaced wholesale by the later source (Helm semantics)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should drop base array entries when the overlay sets the same array", func() {
createCM(h, "podinfo-extraargs-base", "",
"extraArgs:\n - --level=debug\n - --timeout=30\n")
createCM(h, "podinfo-extraargs-overlay", "",
"extraArgs:\n - --level=info\n")
deployPodinfo(h, "s43", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{
cmRef("podinfo-extraargs-base"),
cmRef("podinfo-extraargs-overlay"),
},
})
waitForReplicas(h, 1)
deploy := &appsv1.Deployment{}
Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, deploy)).Should(Succeed())
// podinfo 6.11.1 renders extraArgs into the container's .command
// (appended to ["./podinfo", "--port=...", ...]). Check both command
// and args to stay robust against chart layout changes.
c := deploy.Spec.Template.Spec.Containers[0]
joined := strings.Join(c.Command, " ") + " " + strings.Join(c.Args, " ")
Expect(joined).To(ContainSubstring("--level=info"),
"overlay array value must appear in the container's command/args")
Expect(joined).ToNot(ContainSubstring("--level=debug"),
"base array value must NOT appear — arrays are replaced not merged")
Expect(joined).ToNot(ContainSubstring("--timeout=30"),
"base orthogonal array item must NOT appear — arrays are replaced wholesale")
})
})
Context("valuesFrom combined with healthStatus criteria", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should reach healthy state using CM-supplied replicaCount", func() {
createCMWithReplicas(h, "podinfo-values", 2)
deployPodinfo(h, "s44", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-values")},
"healthStatus": []interface{}{
map[string]interface{}{
"resource": map[string]interface{}{"kind": "Deployment", "name": "podinfo"},
"condition": map[string]interface{}{"type": "Available"},
},
},
})
waitForReplicas(h, 2)
Eventually(func(g Gomega) {
g.Expect(k8sClient.Get(h.ctx, h.appKey, h.app)).Should(Succeed())
g.Expect(h.app.Status.Services).ShouldNot(BeEmpty())
g.Expect(h.app.Status.Services[0].Healthy).Should(BeTrue())
}, 120*time.Second, 3*time.Second).Should(Succeed())
})
})
Context("Two helmchart components, each with its own valuesFrom source", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should render each component independently without cross-contamination", func() {
createCMWithReplicas(h, "podinfo-a-values", 2)
createSecretWithReplicas(h, "podinfo-b-values", 3)
compA := buildPodinfoComponent(h, "podinfo-a", "podinfo-a", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-a-values")},
})
compB := buildPodinfoComponent(h, "podinfo-b", "podinfo-b", map[string]interface{}{
"valuesFrom": []interface{}{secretRef("podinfo-b-values")},
})
deployAppWithComponents(h, "s45", []common2.ApplicationComponent{compA, compB})
waitForNamedReplicas(h, "podinfo-a", 2)
waitForNamedReplicas(h, "podinfo-b", 3)
})
})
Context("Self-healing restores a CM-backed Deployment after manual delete", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should initially deploy with CM-sourced replicaCount", func() {
createCMWithReplicas(h, "podinfo-values", 3)
deployPodinfo(h, "s46", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-values")},
})
waitForReplicas(h, 3)
})
It("should recreate the Deployment with the same CM values after it is deleted", func() {
runCommandSucceed("kubectl", "delete", "deployment", "podinfo", "-n", h.namespace)
Eventually(func() bool {
err := k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, &appsv1.Deployment{})
return err != nil
}, 10*time.Second, time.Second).Should(BeTrue())
RequestReconcileNow(h.ctx, h.app)
waitForReplicas(h, 3)
})
})
Context("Adoption of an existing vanilla Helm release with valuesFrom", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("should adopt the pre-existing release and merge CM values on the adoption upgrade", func() {
By("Installing podinfo via vanilla helm at replicaCount=1")
runCommandSucceed("helm", "install", "podinfo",
"--repo", "https://stefanprodan.github.io/podinfo", "podinfo",
"--version", "6.11.1", "--set", "replicaCount=1", "-n", h.namespace)
Eventually(func(g Gomega) {
d := &appsv1.Deployment{}
g.Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, d)).Should(Succeed())
g.Expect(d.Status.ReadyReplicas).Should(Equal(int32(1)))
}, 60*time.Second, 3*time.Second).Should(Succeed())
By("Creating CM with replicaCount=3 and applying the Application (adoption path)")
createCMWithReplicas(h, "podinfo-adopt-values", 3)
deployPodinfo(h, "s47", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("podinfo-adopt-values")},
})
By("Verifying adoption applied CM values (replicas scaled 1→3) and injected KubeVela labels")
waitForReplicas(h, 3)
deploy := &appsv1.Deployment{}
Expect(k8sClient.Get(h.ctx, types.NamespacedName{Namespace: h.namespace, Name: "podinfo"}, deploy)).Should(Succeed())
Expect(deploy.GetLabels()).To(HaveKey("app.oam.dev/name"),
"adoption must inject KubeVela ownership labels on the Deployment")
})
})
Context("Auto-reconcile on ConfigMap content change (without spec edit)", Ordered, func() {
h := newHelmTestContext()
BeforeAll(func() { h.createNamespace() })
AfterAll(func() { h.cleanup() })
It("rolls out a new Helm revision when only the referenced ConfigMap changes", func() {
By("creating the backing ConfigMap with replicaCount=2 and deploying the Application")
createCMWithReplicas(h, "vf-autorec-values", 2)
deployPodinfo(h, "s48", "podinfo", map[string]interface{}{
"valuesFrom": []interface{}{cmRef("vf-autorec-values")},
})
waitForReplicas(h, 2)
By("recording the current Helm release secret count for later comparison")
initialCount := len(h.getHelmSecrets().Items)
By("editing the ConfigMap content (replicaCount: 2 -> 4) WITHOUT touching the Application spec")
cm := &corev1.ConfigMap{}
Expect(k8sClient.Get(h.ctx, types.NamespacedName{Name: "vf-autorec-values", Namespace: h.namespace}, cm)).Should(Succeed())
cm.Data["values.yaml"] = "replicaCount: 4\n"
Expect(k8sClient.Update(h.ctx, cm)).Should(Succeed())
By("forcing a reconcile via the requestreconcile annotation (skips the periodic-resync wait)")
RequestReconcileNow(h.ctx, h.app)
By("expecting the Deployment to roll forward to replicaCount=4 driven by the CM edit alone")
waitForReplicas(h, 4)
By("confirming a new Helm revision was created (release secret count grew)")
Eventually(func(g Gomega) {
secrets := h.getHelmSecrets()
g.Expect(len(secrets.Items)).Should(BeNumerically(">", initialCount))
}, 60*time.Second, 5*time.Second).Should(Succeed())
})
})
})
func init() {
// ensure helmchart test file is compiled and registered
_ = "helm chart tests registered"
@@ -108,19 +108,22 @@ template: {
namespace?: string | *context.namespace
}
// Inline values (highest priority)
// +usage=Inline values merged with the highest priority; override everything in valuesFrom.
values?: {...}
// Value sources (merged in order) - TODO: Not yet implemented
// valuesFrom?: [...{
// kind: "Secret" | "ConfigMap" | "OCIRepository"
// name: string
// namespace?: string
// key?: string // Specific key in ConfigMap/Secret
// url?: string // For OCIRepository
// tag?: string // For OCIRepository
// optional?: bool | *false // Don't fail if source doesn't exist
// }]
// +usage=Additional values sources merged in array order. Later entries override earlier ones on conflict, and inline `values` override everything in valuesFrom. Deep-merges map keys; arrays are replaced (not concatenated); null is preserved. On every reconcile the controller computes a content fingerprint over all referenced sources and folds it into the workflow revision; an external edit to a referenced ConfigMap/Secret triggers a `helm upgrade` on the next reconcile (default resync ~5 min). Sources are read from the control-plane cluster regardless of where the chart is deployed. Suppressed when the `app.oam.dev/publishVersion` annotation is set so explicit pins remain hard.
valuesFrom?: [...{
// +usage=Source kind. Only Secret and ConfigMap are supported; OCIRepository is reserved for a future release.
kind: "Secret" | "ConfigMap"
// +usage=Name of the Secret or ConfigMap.
name: string
// +usage=Namespace of the Secret or ConfigMap. Defaults to the chart release namespace, which itself defaults to the Application's own namespace when release.namespace is unset. Cross-namespace references are rejected to prevent cross-tenant reads via the controller's cluster-wide RBAC.
namespace?: string
// +usage=Key inside .data whose value is parsed as YAML. Defaults to "values.yaml" (FluxCD / Helm convention). Only ConfigMap.data and Secret.data are read; ConfigMap.binaryData is rejected.
key?: string
// +usage=If true, a missing Secret/ConfigMap or missing key is skipped silently. Parse errors and permission errors still fail the render.
optional?: bool | *false
}]
// Health status criteria - defines when the Helm deployment is considered healthy
healthStatus?: [...{
@@ -236,10 +239,9 @@ template: {
values: parameter.values
}
// TODO: valuesFrom not yet implemented
// if parameter.valuesFrom != _|_ {
// valuesFrom: parameter.valuesFrom
// }
if parameter.valuesFrom != _|_ {
valuesFrom: parameter.valuesFrom
}
options: _options
// Pass KubeVela ownership context so the provider can inject labels