#6042·flux2

`flux diff ks --recursive`: Removing a parent Kustomization silently hides cascade deletions of its children

Author: asouchangCreated Aug 25, 2026Updated Aug 25, 2026

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 HelmRelease inside 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:

  1. Delete clusters/prod/infra.yaml from the local working tree (do NOT push yet).
  2. Run:
    bash
    flux diff ks flux-system --path ./clusters/prod \
      --recursive \
      --local-sources GitRepository/flux-system/flux-system=.

Actual output:

► Kustomization/flux-system/infra deleted

Expected 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):

go
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):

go
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:

  1. Fetch the stale KS from the live cluster (it's still there since we haven't pushed yet).
  2. Read its .status.inventory.
  3. Recursively report all entries as deleted (and repeat for any sub-KS entries, depth-first).

Pseudocode for the fix in diff.go:

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