Skip to content

feat(flux-react): disable Suspend when spec.suspend is GitOps-managed - #2004

Merged
marians merged 3 commits into
mainfrom
flux-suspend-field-ownership
Jul 29, 2026
Merged

feat(flux-react): disable Suspend when spec.suspend is GitOps-managed#2004
marians merged 3 commits into
mainfrom
flux-suspend-field-ownership

Conversation

@marians

@marians marians commented Jul 29, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Follow-up to #2001, from Flux-team feedback about manipulating .spec from a UI. Three changes.

1. Detect a contested spec.suspend and disable the toggle.

The concern raised was that a manual suspend on a GitOps-managed resource gets overwritten. That is right — but the precise condition is narrower than "the resource is GitOps-managed". Server-side apply is field-level: kustomize-controller only touches fields present in the manifest it applies, and removes a field only if it previously owned it and has since dropped it. So what matters is whether the applied manifest asserts spec.suspend.

When it does, the applying controller force-takes the field back on its next apply — ssa/manager_apply.go in fluxcd/pkg always passes client.ForceOwnership — and the suspension undoes itself within one of that controller's intervals, not the resource's own. Silently: no conflict error, nothing degraded.

We can see this exactly, without guessing, from metadata.managedFields (already in our GET responses): any entry with operation Apply that owns f:spec.f:suspend. When present, the Suspend/Resume toggle is disabled with a tooltip naming the owning manager(s).

Two details worth reviewing:

  • Not restricted to kustomize-controller. A human kubectl apply --server-side, or helm-controller's drift correction when spec.driftDetection is enabled, has the same effect. Any SSA applier of the field will revert us, so the check is generic.
  • Detection covers SSA appliers only, and is a best-effort signal. Update-operation owners are skipped, which correctly excludes our own merge patches (so our writes don't lock the button out) — but also misses the two common non-SSA declarative writers, which do keep a stored desired state and re-assert it: client-side kubectl apply (kubectl-client-side-apply) and a plain helm upgrade, whose three-way merge resets drift on chart-declared fields. So a chart shipping a Kustomization with spec.suspend declared still shows an enabled toggle whose change the next upgrade reverts. This is documented on getApplyFieldOwners rather than papered over by matching manager names, which would be a guess.
  • Stale ownership is handled. A managedFields entry is only rewritten by a write, so it outlives the applier — see the escape-hatch note below.

Disabling is precisely correct here rather than merely conservative: the toggle always flips away from the current value, and the current value is what the apply-owner last asserted — so whenever the field is apply-owned, the action on offer is exactly the one that would be reverted.

2. Reconcile is deliberately left enabled, including on managed resources.

reconcile.fluxcd.io/requestedAt is never part of an applied manifest, so no apply-owner asserts or prunes it. kustomize-controller's Cleanup.Annotations list is explicit and short (last-applied-configuration, two legacy fluxcd.io checksums) and does not include it. The controller then copies the value into status.lastHandledReconcileAt and leaves the annotation in place — that pair is what our pending-request check compares. There is no race to lose, so gating Reconcile would be wrong. There's a comment saying so, to stop a future reader "fixing" it.

3. An explicit field manager on our writes.

Writes now set ?fieldManager=giantswarm-backstage. Previously the apiserver derived the manager name from the request's User-Agent, which for a write proxied through the Backstage backend is both unpredictable and useless in an audit trail.

The suggestion in the thread was to masquerade as flux so the controller would treat our writes like CLI writes. I checked the source, and there is nothing to imitate:

  • flux2 never sets a field manager at all — internal/utils/utils.go builds the client as client.NewWithWatch(cfg, client.Options{Scheme: scheme}). The manager: flux visible in --show-managed-fields is just the apiserver deriving it from client-go's default User-Agent.
  • kustomize-controller's disallowed-manager list (kustomization_controller.go, feeding ApplyOptions.Cleanup.FieldManagers) covers kubectl/Apply, kubectl/Update, before-first-apply/Update and its own name with Update, plus any --override-manager values. flux is absent, so a CLI suspend gets no special treatment in either direction.

So impersonation would buy nothing and destroy attribution. An honest name also gives operators a value they can pass to a controller's --override-manager if they want these changes force-reverted.

4. Corrects the ImagePolicy justification from #2001.

The comment claimed Flux supports neither operation for ImagePolicy. That was wrong: both crds.fluxcd.v1.ImagePolicy and v1beta2 declare spec.suspend and status.lastHandledReconcileAt, and the CLI covers the kind too — cmd/flux/reconcile_image_policy.go, suspend_image_policy.go, resume_image_policy.go all exist. Including the kind was right; the reason given was not. (The claim also appeared in #2001's description and changeset, which are already merged — noted here for the record.)

What is the effect of this change to users?

On a resource whose spec.suspend comes from Git, the Suspend/Resume button is now visibly disabled and says why, instead of appearing to work and then quietly undoing itself minutes later. This is the Adidas case from the thread, where Kustomizations in the default namespace set spec.suspend explicitly.

Everything else is unchanged: Reconcile works everywhere it did before, and Suspend/Resume still works on resources whose suspend field is not declaratively managed.

Accepted trade-off: you can no longer take a deliberate short-lived suspend on a managed resource from the UI, and if such a resource was suspended elsewhere you cannot resume it here. Both were already true of flux suspend/resume in the sense that the change wouldn't stick; the difference is that now we say so up front rather than letting it fail silently. The fix in that situation is to change it in Git.

How does it look like?

unmanaged spec.suspend:
  [ ⟳ Reconcile ]  [ ⏸ Suspend ]

spec.suspend applied by kustomize-controller:
  [ ⟳ Reconcile ]  [ ⏸ Suspend ]
                    (disabled)
   tooltip: "spec.suspend is applied by kustomize-controller,
             so a change made here would be reverted on the
             next reconciliation. Change it in Git instead."

Any background context you can provide?

The Slack thread asked whether the Flux controllers handle a CLI-set spec.suspend specially — e.g. keying off the field manager. They don't; see point 3 above. The behaviour difference between "suspend sticks" and "suspend evaporates" is entirely about what's in the applied manifest.

Three per-object annotations exclude a resource from apply and therefore make manual changes durable — kustomize.toolkit.fluxcd.io/reconcile: disabled, kustomize.toolkit.fluxcd.io/ssa: Ignore, and kustomize.toolkit.fluxcd.io/ssa: IfNotPresent (from applyOpts in kustomization_controller.go).

An earlier revision of this PR claimed these "will correctly show an enabled toggle, since no apply-owner claims the field". That was wrong, as pointed out in review: a managedFields entry is only ever rewritten by a write, so it survives the applier walking away. An object handed over for manual control keeps its stale Apply entry, and an ssa: IfNotPresent object carries one from creation onwards despite never being applied again — so both would have shown a permanently disabled toggle. Those three annotations now short-circuit the check.

Verify on a real MC with:

kubectl -n default get ks pcn01 -o yaml --show-managed-fields | \
  yq '.metadata.managedFields[] | select(.manager == "kustomize-controller")'

Do the docs need to be updated?

No.

Should this change be mentioned in the release notes?

  • A changeset describing the change and affected packages was added. (more info)

Server-side apply is field-level, so what decides whether an imperative suspend
survives is not "is this resource GitOps-managed" but whether the applied
manifest asserts `spec.suspend`. When it does, the applying controller
force-takes the field back on its next apply — Flux's SSA always passes
ForceOwnership — so the suspension silently undoes itself within one of *that*
controller's intervals.

Detect it from `metadata.managedFields`, which the API already returns: any
entry with operation Apply owning `f:spec.f:suspend`. The toggle is disabled
with a tooltip naming the owning manager(s). Not restricted to
kustomize-controller, since helm-controller and a human `kubectl apply
--server-side` have the same effect; Update-operation owners are ignored as they
hold ownership without a desired state to restore.

Reconcile stays enabled even on managed resources: the requestedAt annotation is
never part of an applied manifest, and the controller records its value into
status rather than clearing it, so there is no race to lose.

Also set `?fieldManager=giantswarm-backstage` on writes. The apiserver otherwise
derives the manager from the User-Agent, which for a proxied write is
unpredictable and useless for auditing. Not masquerading as `flux` — nothing in
Flux keys off that name, so it would buy nothing and destroy attribution.

Corrects the ImagePolicy justification in the actionable-kinds list: the CLI
does cover the kind (`flux reconcile|suspend|resume image policy`), so the
stated reason for its original omission was wrong even though including it was
right.
@marians
marians requested a review from a team as a code owner July 29, 2026 08:03
Comment thread plugins/kubernetes-react/src/lib/k8s/KubeObject.ts Outdated
Comment thread plugins/kubernetes-react/src/lib/k8s/FluxObject.ts
Comment thread plugins/flux-react/src/components/FluxOverview/ResourceCard/utils/fluxActions.ts Outdated
@fiunchinho

Copy link
Copy Markdown
Member

Code review

Found 2 issues:

  1. Contradictory tooltips for a suspended, GitOps-managed resource. When isSuspended and isSuspendFieldManaged are both true (the exact state exercised by the test "disables Resume too, for an already-suspended managed resource"), the Reconcile button is disabled with the hint "Suspended resources are not reconciled. Resume it first." — but this PR also disables the Resume button, so the advice is a dead end. reconcileHint should account for isSuspendFieldManaged and point to Git instead.

let reconcileHint = '';
if (isSuspended) {
reconcileHint = SUSPENDED_HINT;
} else if (isReconcileRequestPending) {
reconcileHint = REQUEST_PENDING_HINT;
}

}
isDisabled={isSuspendFieldManaged}
isPending={isSettingSuspended}

  1. The tooltip always says "Change it in Git instead", but the ownership detection is documented as deliberately not Git-specific — getSuspendFieldApplyOwners()'s doc comment says a helm-controller apply or a human's kubectl apply --server-side counts too. For those owners the "in Git" advice is wrong; consider neutral wording like "Change it at the source that applies it."

function buildManagedSuspendHint(owners: string[]): string {
const by = owners.length === 1 ? owners[0] : owners.join(' and ');
return `spec.suspend is applied by ${by}, so a change made here would be reverted on the next reconciliation. Change it in Git instead.`;
}

*
* Deliberately not restricted to `kustomize-controller`: a HelmRelease-deployed
* Flux object is applied by `helm-controller`, and a `kubectl apply
* --server-side` by a human has the same effect. Any apply-owner will revert us.
*/

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

marians added 2 commits July 29, 2026 11:32
…etection

Five issues raised in review of #2004:

- A `managedFields` entry is only rewritten by a write, so an `Apply` entry
  outlives the applier. Handing an object over for manual control with
  `reconcile: disabled` or `ssa: Ignore` left a stale entry naming the field and
  disabled the toggle forever, and `ssa: IfNotPresent` objects carry such an
  entry from creation onwards. Those three annotations now short-circuit the
  check. The PR description claimed these already worked; they did not.

- Narrow the "Update owners never revert anything" claim. Client-side `kubectl
  apply` and `helm upgrade`'s three-way merge both keep a stored desired state
  and re-assert it while being recorded as `operation: Update`, so detection
  covers SSA appliers only. Documented as a best-effort signal rather than
  extended by guessing at manager names.

- Restore the tooltip on disabled buttons. bui's disabled styling sets only
  `cursor: not-allowed`, so unlike MUI's ButtonBase the native <button> keeps
  swallowing the hover and only the native `title` showed. Adds
  `pointer-events: none` when disabled, to Suspend and the pre-existing
  Reconcile alike.

- Deduplicate and Oxford-join the owner list: one manager can hold several
  entries (keyed by manager + operation + apiVersion + subresource), and three
  owners rendered as "a and b and c".

- Reword the ImagePolicy note, which described a removal absent from history,
  and fix two stale comments still using ImagePolicy as the example of an
  unsupported kind.
Two issues from a second review pass on #2004, both tooltip copy:

- A suspended *and* declaratively managed resource had Reconcile advising
  "Resume it first" while this PR disables Resume for being managed — dead-end
  advice. That state now gets its own hint pointing at the applier instead.

- "Change it in Git instead" contradicted the ownership check, which is
  deliberately not Git-specific: helm-controller's drift correction and a human
  `kubectl apply --server-side` both count. Reworded to "Change it at the source
  that applies it."
@marians

marians commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks @fiunchinho — both valid, both fixed in f3e2d93. Neither was resolved by the earlier review round, and I had missed this comment entirely (I enumerated review comments and review summaries, not issue comments).

1. Contradictory tooltips. Correct, and the state you name was covered by a test that asserted only that both buttons were disabled — never what they said, which is why it slipped through. reconcileHint now branches on isSuspended && isSuspendFieldManaged first, ahead of the plain suspended case, and gets its own message:

Suspended resources are not reconciled, and spec.suspend is applied by kustomize-controller — resume it at the source that applies it.

A test asserts that exact string and additionally that /Resume it first/ is not present in that state, so the dead end cannot come back unnoticed. The plain "Resume it first" wording is kept for the unmanaged case, where it is still the right advice, with its own test.

2. Git-specific wording. Also correct, and a genuine internal contradiction: my own docstring says the check covers helm-controller drift correction and kubectl apply --server-side. Both hints now say "at the source that applies it", with a comment above the builder recording why the wording is deliberately not Git-specific. A test using a helm-controller owner asserts the neutral phrasing and that /in Git/ is absent.

Worth noting the residual imprecision, since I would rather flag it than let it read as fully solved: the tooltip names the manager (e.g. kustomize-controller), which is only an indirection towards the thing a user can actually edit. Resolving it to a concrete source would mean walking kustomize.toolkit.fluxcd.io/name/-namespace ownership labels back to the parent Kustomization and its repository. That is a bigger change and it is not in this PR.

214 tests pass, tsc/lint/prettier clean.

@marians
marians merged commit 2c383a6 into main Jul 29, 2026
10 checks passed
@marians
marians deleted the flux-suspend-field-ownership branch July 29, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants