Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion pkg/cli/cmd/workload/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,26 @@ func warnDegradations(cmd *cobra.Command, degradations []render.Degradation) {
continue
}

content := "skipped Helm render for HelmRelease %s (validating the resource as-is): %s"

switch degradation.Kind {
case render.DegradationPartialValues:
content = "rendered HelmRelease %s with incomplete values — valuesFrom %s " +
"could not be resolved offline (cluster-managed or in another kustomization); " +
"validation/scan may differ from the cluster"
case render.DegradationMissingValuesKey:
// The referent IS in the repo, so pointing at cluster-managed sources
// would misdirect; the actionable cause is the key itself (often a typo).
content = "rendered HelmRelease %s with incomplete values — valuesFrom %s " +
"is in the repo but has no such key (Flux fails reconciliation on a " +
"missing valuesKey, even when the reference is optional); " +
"validation/scan may differ from the cluster"
case render.DegradationSkippedRender:
}

notify.WriteMessage(notify.Message{
Type: notify.WarningType,
Content: "skipped Helm render for HelmRelease %s (validating the resource as-is): %s",
Content: content,
Args: []any{degradation.HelmRelease, degradation.Reason},
Writer: cmd.ErrOrStderr(),
})
Expand Down
63 changes: 63 additions & 0 deletions pkg/cli/cmd/workload/validate_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,69 @@ func TestValidateDegradesWhenChartUnreachable(t *testing.T) {
assert.Contains(t, output, "skipped Helm render", "degradation should warn the user")
}

// TestValidateWarnsOnUnresolvableValuesFrom verifies the render-fidelity honesty
// contract end-to-end: a HelmRelease whose non-optional valuesFrom references a
// ConfigMap absent from the offline stream (typically cluster-managed) still
// renders and passes, but the user is warned that values were incomplete — not
// the misleading "skipped Helm render" message, since the chart DID render.
func TestValidateWarnsOnUnresolvableValuesFrom(t *testing.T) {
t.Parallel()

dir := t.TempDir()
chartURL := localChartURL(t, "validchart")
files := map[string]string{
"kustomization.yaml": `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ocirepository.yaml
- helmrelease.yaml
`,
"ocirepository.yaml": `apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: chart
namespace: flux-system
spec:
interval: 5m
url: ` + chartURL + `
`,
"helmrelease.yaml": `apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: app
namespace: flux-system
spec:
interval: 5m
chartRef:
kind: OCIRepository
name: chart
valuesFrom:
- kind: ConfigMap
name: cluster-settings
`,
}

for name, content := range files {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
}

output, err := runValidate(t, dir, "--skip-kinds", "OCIRepository")
require.NoError(t, err, "an unresolvable valuesFrom must warn, not fail the run")
assert.Contains(t, output, "incomplete values", "the user should be warned values were partial")
assert.Contains(
t,
output,
"cluster-settings",
"the warning should name the unresolved reference",
)
assert.NotContains(
t,
output,
"skipped Helm render",
"the chart did render, so the skipped-render message must not appear",
)
}

// TestValidateSingleHelmReleaseFileStaysGreen guards the ksail-test-gen-smoke CI
// canary: validating a single HelmRelease file (whose source is not present)
// must not attempt rendering and must pass as a CR-schema check.
Expand Down
108 changes: 100 additions & 8 deletions pkg/svc/gitops/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"maps"
"sort"
"strings"

"github.com/devantler-tech/ksail/v7/pkg/fsutil"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
meta "github.com/fluxcd/pkg/apis/meta"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
"sigs.k8s.io/yaml"
)
Expand Down Expand Up @@ -51,12 +53,39 @@ type Document struct {
Provenance Provenance
}

// Degradation records a HelmRelease that could not be rendered offline. The
// caller falls back to validating/scanning the HelmRelease CR itself. Silent
// degradations are expected in normal repos (e.g. a source object that lives in
// a different Flux Kustomization) and should not be surfaced as warnings.
// DegradationKind classifies why a HelmRelease's offline render is less than
// full-fidelity, so callers can phrase an accurate warning.
type DegradationKind int

const (
// DegradationSkippedRender marks a HelmRelease whose chart could not be
// rendered offline at all; the caller falls back to validating/scanning the
// HelmRelease CR itself. This is the zero value, so a Degradation left
// without an explicit Kind keeps this meaning.
DegradationSkippedRender DegradationKind = iota
// DegradationPartialValues marks a HelmRelease that DID render, but with a
// non-optional valuesFrom reference that could not be resolved from the
// offline stream — so the rendered children may differ from what Flux applies
// with the real value present.
DegradationPartialValues
// DegradationMissingValuesKey marks a HelmRelease that DID render, but whose
// valuesFrom referent IS present in the stream while lacking the requested
// valuesKey — typically a valuesKey typo. It is a distinct kind because the
// remedy differs: the resource is in the repo, so the caller must not suggest
// the value is cluster-managed. Flux fails reconciliation on a missing
// valuesKey even when the reference is marked optional.
DegradationMissingValuesKey
)

// Degradation records a way a HelmRelease's offline render fell short of what
// Flux would apply. For DegradationSkippedRender the caller falls back to
// validating/scanning the HelmRelease CR itself; for DegradationPartialValues
// the children were rendered but with incomplete values. Silent degradations
// are expected in normal repos (e.g. a source object that lives in a different
// Flux Kustomization) and should not be surfaced as warnings.
type Degradation struct {
HelmRelease string // "namespace/name"
Kind DegradationKind
Reason string
Err error
Silent bool
Expand Down Expand Up @@ -192,6 +221,68 @@ func expandHelmRelease(
newDocument(child, parseObjectMeta(child), provenance),
)
}

// The chart rendered, but a non-optional valuesFrom that could not be resolved
// offline means the children were produced with incomplete values — surface
// that so the shift-left gate is honest about its coverage.
result.Degradations = append(result.Degradations, unresolvedValueRefs(&helmRelease, index)...)
}

// unresolvedValueRefs returns a DegradationPartialValues for each valuesFrom
// reference on the HelmRelease whose value the offline render could not obtain,
// so the rendered output can diverge from what Flux applies with the real value
// present. It mirrors helm-controller's own tolerance rule: "a not found error
// for the values reference is ignored [when optional], but any ValuesKey,
// TargetPath or transient error will still result in a reconciliation failure".
// So only an ABSENT optional referent is silent (typically cluster-managed, or
// defined in another Flux Kustomization); a referent that IS in the stream but
// lacks the requested valuesKey fails in-cluster even when marked optional — a
// `valuesKey` typo is exactly that case — and is always reported.
func unresolvedValueRefs(helmRelease *helmv2.HelmRelease, sources SourceIndex) []Degradation {
key := helmRelease.Namespace + "/" + helmRelease.Name

var degradations []Degradation

for index := range helmRelease.Spec.ValuesFrom {
ref := helmRelease.Spec.ValuesFrom[index]

data, referentFound := lookupValuesSource(ref, helmRelease.Namespace, sources)
if !referentFound {
// Flux ignores a not-found optional referent; a missing required one
// means the chart rendered with incomplete values.
if ref.Optional {
Comment thread
devantler marked this conversation as resolved.
continue
}

degradations = append(
degradations,
valuesDegradation(key, ref, DegradationPartialValues),
)

continue
}

// The referent is in the stream, so a missing valuesKey is a hard
// reconciliation failure in Flux regardless of `optional`.
if _, ok := data[ref.GetValuesKey()]; !ok {
degradations = append(
degradations,
valuesDegradation(key, ref, DegradationMissingValuesKey),
)
}
}

return degradations
}

// valuesDegradation builds the degradation entry naming the valuesFrom reference
// whose value could not be obtained offline, under the given kind.
func valuesDegradation(key string, ref meta.ValuesReference, kind DegradationKind) Degradation {
return Degradation{
HelmRelease: key,
Kind: kind,
Reason: fmt.Sprintf("%s %q (key %q)", ref.Kind, ref.Name, ref.GetValuesKey()),
}
}

// isSilentDegradation reports whether a degradation is expected in normal repos
Expand Down Expand Up @@ -370,9 +461,10 @@ func extractStringData(doc []byte, secret bool) map[string]string {

maps.Copy(out, obj.StringData)

if len(out) == 0 {
return nil
}

// An EMPTY (but non-nil) map is returned deliberately for a referent that
// carries no data: the object still exists in the stream, and callers must be
// able to tell "present but has no such key" (a Flux reconciliation failure,
// even for an optional reference) from "absent" (which optional forgives).
// Only a parse failure yields nil, meaning "not a usable referent at all".
return out
}
Loading
Loading