`flux diff ks --recursive`: Removing a parent Kustomization silently hides cascade deletions of its children
Describe the bug
flux diff ks --recursive has an asymmetric behavior when a parent Kustomization is removed from source vs. when its children are modified:
- Updating a child resource (e.g., a
HelmReleaseinside a sub-Kustomization) → the recursive diff correctly descends and shows the drift ✅ - Deleting the parent Kustomization file entirely → the diff only reports the parent KS itself as
deleted, silently hiding all of its child resources that would also be pruned ❌
This means a user can delete a top-level Kustomization (e.g., clusters/prod/infra.yaml) and flux diff --recursive gives no indication of the hundreds of HelmRelease, ConfigMap, Secret, etc. objects that would be cascade-deleted when that change is applied. The diff output is misleading and dangerous in a production GitOps workflow.
Steps to reproduce
Given a two-level Kustomization hierarchy:
clusters/
└── prod/
├── flux-system/ # root KS (name: flux-system)
├── apps.yaml # KS → apps/prod/
└── infra.yaml # KS (name: infra) → infra/prod/ ← we will delete this
infra/
└── prod/
├── kustomization.yaml
├── loki/helmrelease.yaml
├── grafana/helmrelease.yaml
└── ... (many more HelmReleases)Both KS objects have spec.prune: true.
Reproduce:
- Delete
clusters/prod/infra.yamlfrom the local working tree (do NOT push yet). - Run:
flux diff ks flux-system --path ./clusters/prod \ --recursive \ --local-sources GitRepository/flux-system/flux-system=.
Actual output:
► Kustomization/flux-system/infra deletedExpected behavior
Expected output:
► Kustomization/flux-system/infra deleted
Kustomization/flux-system/infra (cascade):
► HelmRelease/flux-system/loki deleted
► HelmRelease/flux-system/grafana deleted
► HelmRelease/flux-system/prometheus deleted
... (all resources in infra's status.inventory)Screenshots and recordings
No response
OS / Distro
macos 26.6.2
Flux version
v2.9.4
Flux check
► checking prerequisites ✔ Kubernetes 1.36.1-aliyun.1 >=1.33.0-0 ► checking version in cluster ✔ distribution: flux-v2.9.4 ✔ bootstrapped: false ► checking controllers ✔ helm-controller: deployment ready ► ghcr.io/fluxcd/helm-controller:v1.6.3@sha256:16ada99456385100698a5d7adf90aba8a2089d987ab541c9566b6d7b0e897038 ✔ image-automation-controller: deployment ready ► ghcr.io/fluxcd/image-automation-controller:v1.2.4@sha256:0286cbba95a2606a006e370052cb642f4370cb42ceea8353b5cba922cf47770c ✔ image-reflector-controller: deployment ready ► ghcr.io/fluxcd/image-reflector-controller:v1.2.4@sha256:d63550296dc9a6c2b7c9246cb7ef6e52d7469d5b104cd329622301b46971e255 ✔ kustomize-controller: deployment ready ► ghcr.io/fluxcd/kustomize-controller:v1.9.4@sha256:2b8bec54ffb6caf421bd2a6c005d27f567d5dd4db7feb55794fb51fcabd69b8f ✔ notification-controller: deployment ready ► ghcr.io/fluxcd/notification-controller:v1.9.3@sha256:071c351a0fb163eeb6a2bb82f1e894f51b6b0734216d2e97d3d99c9ab9d710b9 ✔ source-controller: deployment ready ► ghcr.io/fluxcd/source-controller:v1.9.4@sha256:8a8ed0a57b8b86f561d5a4309a69f65e62f0cebe4de8801593c5ff35a3bc3c23 ► checking crds ✔ alerts.notification.toolkit.fluxcd.io/v1beta3 ✔ buckets.source.toolkit.fluxcd.io/v1 ✔ externalartifacts.source.toolkit.fluxcd.io/v1 ✔ gitrepositories.source.toolkit.fluxcd.io/v1 ✔ helmcharts.source.toolkit.fluxcd.io/v1 ✔ helmreleases.helm.toolkit.fluxcd.io/v2 ✔ helmrepositories.source.toolkit.fluxcd.io/v1 ✔ imagepolicies.image.toolkit.fluxcd.io/v1 ✔ imagerepositories.image.toolkit.fluxcd.io/v1 ✔ imageupdateautomations.image.toolkit.fluxcd.io/v1 ✔ kustomizations.kustomize.toolkit.fluxcd.io/v1 ✔ ocirepositories.source.toolkit.fluxcd.io/v1 ✔ providers.notification.toolkit.fluxcd.io/v1beta3 ✔ receivers.notification.toolkit.fluxcd.io/v1 ✔ all checks passed
Git provider
No response
Container Registry provider
No response
Additional context
Contrast: the Working Case
If instead you modify a HelmRelease inside infra/prod/ (without deleting infra.yaml), the recursive diff correctly descends and reports the HR-level drift. This proves the recursive wiring works — but only for objects that are present in the new build, not for objects that have been removed.
Root Cause
The asymmetry is caused by two separate, non-interacting code blocks in internal/build/diff.go:
Block 1 — Recursive descent (per-object loop over the new build):
for _, obj := range objects { // objects = kustomize build of LOCAL path
// ...
if b.recursive && isKustomization(obj) && change.Action != ssa.CreatedAction {
// recurse into sub-KS → correctly reports HR drift when infra.yaml EXISTS locally
b.kustomizationDiff(k)
}
}When infra.yaml is deleted, ks/infra is absent from objects, so this block never fires for it.
Block 2 — Stale/prune detection (after the loop):
if b.kustomization.Spec.Prune && len(diffErrs) == 0 {
staleObjects, _ := diffInventory(oldStatus.Inventory, newInventory)
for _, object := range staleObjects {
// Reports the KS itself as deleted — but never recurses into its own inventory
output.WriteString(fmt.Sprintf("► %s deleted\n", ...))
}
}diffInventory compares the current KS's status.inventory (which only contains direct children like ks/infra) against the new build. It never fetches ks/infra's own status.inventory from the cluster to report the cascade.
Proposed Fix
When a stale object in Block 2 is itself a Kustomization kind, the diff tool should:
- Fetch the stale KS from the live cluster (it's still there since we haven't pushed yet).
- Read its
.status.inventory. - Recursively report all entries as
deleted(and repeat for any sub-KS entries, depth-first).
Pseudocode for the fix in diff.go:
for _, object := range staleObjects {
output.WriteString(writeString(fmt.Sprintf("► %s deleted\n", ...), bunt.OrangeRed))
// NEW: if the stale object is itself a Kustomization, cascade into its inventory
if b.recursive && object.GetKind() == "Kustomization" {
subKs := &kustomizev1.Kustomization{}
if err := b.client.Get(ctx, types.NamespacedName{...}, subKs); err == nil {
b.reportCascadeDeletes(subKs, &output) // recursive, reads status.inventory
}
}
}Impact
This is a safety / correctness issue for teams using flux diff --recursive as a pre-merge gate in CI (e.g., GitHub Actions PR checks). A PR that removes a parent KS will pass diff review appearing to delete only one resource, when in reality it will prune an entire layer of infrastructure.
Code of Conduct
- I agree to follow this project's Code of Conduct
Source: fluxcd/flux2