mirror of
https://github.com/kubevela/kubevela.git
synced 2026-08-18 03:56:36 +00:00
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:
co-authored by
Ayush Kumar
Anaswara Suresh
parent
2268d95c1c
commit
28892ccc7e
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user