Repository navigation
feat(applier): hold the applier, resuming a suspended Kustomization for each apply - #420
Merged
Merged
Conversation
…ew 3 The third review of #419 found that the 24h-interval setup reduces Flux's own applies but never hands their timing to GitOps Reverser: the interval, a controller restart, a retryInterval retry, a spec change and a shared source's new artifact all still apply at moments nobody checked. The measured suspend lifecycle answers that at about 50 ms per apply. The plan makes it the direction: a Flux Kustomization stays suspended while idle and is resumed per apply, as Argo CD runs with automated sync off. kustomize-controller v1.9.6 records a requestedAt as handled on every return, so one patch of suspend plus a token marks the end of the apply, success or failure, and the same on re-suspend fences any reconcile still running. The applier becomes typed resources (FluxApplier, ArgoCDApplier) holding the operation, the owed Repair and the suspension. Built as a stack on #419 and released with it, so spec.applier is never released. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A write by the applier and another manager in the same second is mirrored, because it may be the API edit itself. It was recorded as an edit only when the object had been edited before it, so a tie as the first write in the window left the object unrecorded, and the applier's write alone a second later (its apply reverting that edit) was mirrored as the applier's commit. A tie is now always recorded as an edit; it counts as ambiguous only when it collides with an earlier edit. Review 3, finding 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ld rollback An attempt that asked an applier to apply is now an operation the branch worker follows to its end, not a single "Delivered" report. The dispatcher reports it as it moves (Requested, Running) until it completes (Succeeded or Failed), and the worker keeps the attempt out and the apply window open until then. Settling is in the vocabulary for the suspended Flux lifecycle. An operation not completed after 5 minutes is Unresolved instead of "delivered anyway": a timer that closes the window does not stop the apply. It is still followed, at an interval doubling to a minute, each read bounded by its own timeout, and holds its applier's next attempt back until it completes. A deleted applier object completes it as Failed; an applier no GitTarget names any more abandons it (ApplyRequest.Abandoned). It records an ApplyUnresolved Event and shows in the new apply_operations_unresolved gauge. A completed operation that failed (an Argo CD sync ending Failed or Error, or cleared before it recorded an outcome) gives back what it carried and is retried on the applier's backoff, with an ApplyFailed Event for the first failure of a run. The could-not-ask outcome is renamed RequestFailed. A late apply the window withholds owes its applier a Repair: an apply of the tip at the next quiet moment, with no quiet period, for Flux and Argo CD alike. An Argo CD Application, which never catches up, used to stay on the reverted value until something else synced it. Repair is a new ApplyReason, ranked between Refusal and ForeignCommit, and a Flux Repair stamps the Kustomization as a Refusal does. Review 3, findings 2 and 4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Important Review skippedToo many files! This PR contains 236 files, which is 136 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. ⚙️ Run configuration
📒 Files selected for processing: 236
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
sunib
added this pull request to stack #421
October 17, 2026 19:31
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…iggers Suspension is the only Flux mode, a shared source is information only, an external resume is always fenced and re-suspended, CatchUp stays as convergence, and our resources are triggers (FluxTrigger, ArgoCDTrigger, GitTarget.spec.applyTriggerRef, ApplyTriggerReady), because "applier" already names the Kustomization or Application itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
….applyTriggerRef
An apply trigger holds the applier of a folder: a FluxTrigger holds a
Flux Kustomization (kustomizationRef, quietPeriod), an ArgoCDTrigger an
Argo CD Application (applicationRef), each in the cluster its required,
immutable clusterProviderRef names. A GitTarget names one in its own
namespace with spec.applyTriggerRef {kind, name}, replacing the
unreleased spec.applier and status.applier outright.
The trigger reconciler checks the applier alone (Configured: exists, a
source it can drive, writable, automated sync off; UnsupportedSource for
a pinned or non-Git source) behind its ClusterProvider's accessFrom, and
publishes status.source. The GitTarget's ApplyTriggerReady mirrors the
trigger's Ready and adds the binding check against its own repository
and branch (SourceMismatch), so the GitTarget no longer reaches the
applier's cluster at all.
The branch worker resolves appliers through their triggers and records
each operation on the trigger's status.operation and status.repairOwed
at every transition, through a coalescing StatusWriter off its loop, so
the status is current without a reconcile. The dispatcher's in-memory
ledger is gone. An Unresolved operation makes the trigger Ready=False
(OperationUnresolved).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
UPGRADING, the architecture overview, the bi-directional guide, the metrics page, the e2e corner spec and the design pages now describe the FluxTrigger and ArgoCDTrigger kinds, spec.applyTriggerRef and ApplyTriggerReady. The plan records what step 3 settled as built. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An omitted clusterProviderRef on a FluxTrigger or ArgoCDTrigger is now
the ClusterProvider named "default", with the same schema default
({name: default}) and the same fallback for a literal built in code as
GitTarget.spec.clusterProviderRef. It stays immutable.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… it per apply A Flux Kustomization no longer applies on its own. Its FluxTrigger suspends it (adoption), and every apply GitOps Reverser starts is one server-side apply of spec.suspend: false with a requestedAt token. The operation completes when the reconcile that read the token has ended, read from Ready and lastAppliedRevision, and once a second apply, suspending it again with a fence token, is handled: nothing that started before the suspend is still running. A failed apply is suspended too, so Flux's retryInterval retry cannot fire; the worker owns the retry. - The worker takes the token and records the Requested operation as it sends the attempt; the dispatcher writes it through (StatusWriter.PersistApplyTriggerStatus) after the recheck and before the resume, so a restart finds every apply that may have started. - Just before the resume the artifact and the decision are read again (measurement point 6); a fetch that ends elsewhere is Stale or a failure. Current needs a suspended Kustomization that applied the commit. An overdue operation is suspended at once. - spec.suspend applied by another field manager is Configured=False, SuspendOwnedElsewhere. The reconciler fences an external resume every time, with a Normal Event, recorded as an Unresolved operation, and the trigger's finalizer hands the Kustomization back on deletion. - Gone: the stamp outcomes, the lastAttemptedRevision heuristic and the handled-while-suspended bug (measurement point 3). - apply_revision_mismatches_total counts an apply of another revision. - internal/applier/fluxsim is the fake that applies the source facts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The trigger's status.operation is written before an apply can start, so a worker that starts reads it back before it mirrors its first live event. An operation that has not completed is followed to its end by its recorded tokens, asking the applier for nothing (ApplyRequest.Recovered), with its apply window open from the recovery: a late write over an edit made after the restart is withheld, and the Kustomization is suspended again before the operation completes. repairOwed is restored. A worker's own record is read back once, since later reads can lag its own writes; an apply the trigger's reconciler recorded for somebody else's resume (no request token) is followed whenever it is listed, so its window opens while the process runs too. A recorded token that is nowhere on the Kustomization ends the operation Failed after a fence: whether its resume was made cannot be told. The Flux Restart Recovery e2e spec (Serial) restarts the manager while a wait: true apply runs, and checks that the same operation completes and the Kustomization ends suspended. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rce, prefetch A FluxTrigger publishes, in status.source.otherConsumers, the Kustomizations and HelmReleases that apply its GitRepository and that no FluxTrigger on the same ClusterProvider holds, information only, and status.source.prefetch when there are none: a new artifact then applies nothing on its own. Flux appliers on one branch that apply one GitRepository have one operation at a time, so a sibling's fetch cannot land between an operation's recheck and its reconcile. A push somebody else made, observed while the branch is busy, has each prefetching source fetch at once, so the artifact is ready when the apply is due; the dispatcher holds it back while any FluxTrigger on that source has an operation in flight. The applier role gains list on kustomizations and helmreleases. The third review's A/B probe (an edit to B during A's fetch keeps its value) holds: B, suspended, ignores the artifact A's request fetched. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Flux guide now describes the applier the code drives: the FluxTrigger holds its Kustomization suspended and resumes it for one apply at a time, the manifest omits spec.suspend, flux resume is the override, and nothing applies while GitOps Reverser is down. Its example drops retryInterval and keeps a long source interval, which the guide test now holds it to. apply-trigger.md corrects its Flux lessons (a long interval is not exclusive control; Flux has no fetch-only mode unless its consumers are suspended) and takes in the plan and the measurement it rested on: the kustomize-controller source facts, the operation, who owns spec.suspend, restart recovery, shared sources and prefetch, the measured cost and the decisions. Both pages it folds are removed, and every reference points at its section instead. The bi-directional, configuration, architecture, Argo CD and upgrade pages follow. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The configuration reference now carries the Suspended condition, the SuspendOwnedElsewhere reason, the consumer and fence fields, the finalizer and hand-back, the list grants, and the FluxTrigger's own re-read cadence. Correct the flux reconcile advice, the sent outcome, the external-resume Unresolved timing, and which rejections take the 15s backoff floor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sendDue walked the appliers by name, so the first applier of a group went again after each of its own operations: a push somebody else made while it was out is owed to the whole group. Pushes arriving faster than one operation lasts starved its siblings. The group's next attempt now goes to the applier whose last turn is oldest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Consumers missed a HelmRelease whose spec.chartRef names a HelmChart built from the GitRepository, and listed HelmReleases only at v2, so a Flux that serves v2beta* read as having none. Both made prefetch true wrongly. HelmReleases and HelmCharts are now listed at the newest version Flux has served that the cluster serves. A cluster serving no HelmRelease version has none only when no HelmChart is built from the source; otherwise the consumers are unknown and Consumers returns an error (no prefetch). The Flux applier role gains list on source.toolkit.fluxcd.io helmcharts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…aw it when the apply never started The worker published the Requested operation the moment it sent an attempt, so a request the dispatcher then refused (Rejected, Dropped, Stale, RequestFailed) left a phantom Requested record on the trigger: the rollback carried an older requestedAt, which the writer never wrote over. The same rule let Record return nil without writing over a newer external-resume record, and a released trigger whose publication the background writer had dropped still recorded "successfully". The operation now reaches the trigger first through RecordApplyOperation, written through just before the resume or sync, and is published only once the requester reports it. The writer matches operations by identity (token and requestedAt) instead of by time: it moves the same operation on, writes over none, never replaces another, and puts back what a record displaced when the worker withdraws it. Record fails for a released trigger, and when the status holds another operation in flight, so nothing is resumed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uncached completeExternalOperation deep-copied the cached trigger's status.operation, which can still be nil while the uncached read the hold decided on shows the operation, and then dereferenced nil. It now completes the live operation and does nothing without one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…when it is made A FluxTrigger counted as holding its Kustomization whether or not it held it: one that is not Configured (SuspendOwnedElsewhere), or not fenced yet, left a Kustomization Flux still applies out of otherConsumers, so a sibling published prefetch: true. A trigger now holds its Kustomization only while its Suspended condition is True; its own Kustomization is never another consumer, and prefetch also needs it held in this pass. That status is up to a minute old, so the dispatcher reads the source's consumers again before it stamps the source: each must be a Kustomization a FluxTrigger on the cluster names, suspended, held by GitOps Reverser and fenced now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A forbidden Suspend of a resumed Kustomization was retried forever, while a forbidden read or release let the trigger go; it now lets go the same way, with a ReleaseFailed Warning. And a suspend that is never handled (kustomize-controller down) held the trigger with no end: past 10 minutes after the deletionTimestamp the finalizer goes anyway, with a ReleaseFailed Warning that says what the Kustomization is left as. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a real API server a forced apply of the value a field holds shares the field with the manager that set it, so a suspend that took over a `flux suspend` co-owned spec.suspend with that Update entry, and Release's empty apply left the Kustomization suspended while the Released Event said it resumed (measured in envtest). Release now takes such an update's share first with a forced spec.suspend: false, and returns the manager that still keeps it suspended (one that applies the field), which the Released Event names. fluxsim gave every field one owner; it now keeps ownership as the API server does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…name Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nanswered resume A token handled is no proof of an apply: a suspend between the resume and its reconcile (flux suspend, the trigger's own) makes that reconcile a suspended return, and the old Ready read as Succeeded. A Succeeded now needs Ready's lastTransitionTime or the latest status.history snapshot to have moved since the resume (review 4, E). An applied revision other than the commit is Succeeded only when the source holds it now; an older artifact read from kustomize-controller's cache is Failed, so the worker asks again (G). A recovered token stamped over is judged on what ran since it was asked for, not failed outright (I). A resume whose write failed without an answer (timeout, lost connection) is an Unconfirmed operation, followed and fenced, not a RequestFailed that left the Kustomization resumed (B). fluxsim models history, Ready transitions, a lagging source cache and a paused queue. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review 4, B: an abandoned operation now fences its Kustomization before the attempt lets it go, and a recovered attempt asked for before the dispatcher starts waits for Start instead of failing once. The dispatcher reports which operations it follows (Following); the FluxTrigger reconciler leaves a Kustomization resumed for a worker's operation only while it is followed, or for a minute after it first saw nobody following it, and then fences it and completes the operation Failed. F: a resume needs the trigger Suspended=True, read uncached (Ask.Held), and the reconciler reads the operation before and after the Kustomization, so an operation completing in between is no external resume. The trigger is also read uncached before each step that may suspend, so a stale cached read cannot re-suspend a Kustomization a hand-back released. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The design doc and the Flux guide now say what the code does after review 4: an operation reaches the trigger only through its record and is withdrawn when nothing started; a Succeeded verdict needs a reconcile that ran resumed; an older artifact and an unfollowed operation are asked again or fenced; prefetch counts only triggers that are Suspended; the hand-back takes a shared spec.suspend and gives up after 10 minutes; and the Flux grant lists helmcharts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sunib
marked this pull request as ready for review
October 18, 2026 07:21
ApplierDynamicClient serves a declared cluster through the client its watches run on, which is all SourceDynamicClient did for production; its rationale moves to ApplierDynamicClient. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… duplicates Behaviour is unchanged. Gone: ApplyTriggerReconciler.newFlux and its factory type (never set), inflightAttempt.reason/commit/sentAt and PrefetchRequest.Branch (written, never read), Suspension.Token (read by one test), Dispatcher.PollTimeout (never varied; now a const), and three guards that cannot fail. Collapsed: one request-token fallback, APIReader defaulted in NewDispatcher, Flux.fence through Suspend, one keptCondition for three identical helpers, one DescribeCommit, BranchWorker.branchKey. SourceClients is applier.Clients, and "source cluster" is the applier's cluster wherever it meant that. Comments still describing the old requestedAt patch, or a reconciler that never writes status.operation, now describe the code. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t applies bi-directional.md is now the overall picture, and opens with who needs it: only a folder that GitOps Reverser writes and Flux or Argo CD applies. Mirroring a folder nothing applies, and Flux or Argo CD applying other folders, need no trigger and keep their usual configuration. flux.md and argocd.md become bi-directional-flux.md and bi-directional-argocd.md. The guide tests follow the rename and still hold each complete example to the code. State diagrams for the quiet branch, the life of an apply, how a FluxTrigger holds its Kustomization, and the Application and its sync operation. The receiver steps live once, in the overview. The apply-trigger section of configuration.md keeps the fields, the status shape and the conditions, and leaves the lifecycle to the guides. The Argo CD support-boundary record drops what now repeats the guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ecked prefetch checks the trigger's status.source GitRepository, but Flux.Prefetch stamped whatever the Kustomization's spec.sourceRef names now. A Kustomization repointed since its trigger last published leaves the old source with no consumer to fail the check, and its new source, never checked, was stamped: a consumer Flux applies on its own would apply the commit at once. Prefetch now takes the checked source and stamps nothing when the Kustomization applies another, as readAgain already refuses on the apply path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Suspended condition described the Kustomization, and read Suspended=True while an apply had it resumed. It is now Held, with one reason per state: Suspended (suspended under our field manager, the last suspend handled), Applying (an operation of a branch worker in flight and followed), Suspending (a suspend sent, not handled yet) and NotConfigured. A Held printer column shows the reason at a glance. "Fence" is retired for "suspend": status.operation.fenceToken becomes suspendToken, and the Go names follow (SuspendToken, Suspension.Handled, suspendAbandoned, suspendUnfollowed, followedSuspend, applyTriggerSuspendingInterval, ReasonSuspending). The operation phases, Running and Settling included, are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nces it A trigger no GitTarget names serves nobody, so it now does nothing: it reads and writes nothing in the applier's cluster, is not in line for its applier, and reports Configured=Unknown and Ready=True, both NotReferenced. Ready=True keeps a Flux health check that waits on the trigger from deadlocking against the Kustomization that creates the GitTarget naming it. A GitTarget is eligible when it is in the trigger's namespace, not being deleted, names the trigger, is admitted by its ClusterProviders, and writes the repository and branch the applier fetches; never judged from its Ready. One eligible binds the trigger. None eligible while one cannot be judged yet is Configured=Unknown NotBound (progressing); each refused is Configured=False with the shared reason (SourceMismatch, NamespaceNotAuthorized) or NotBound (stalled), every GitTarget in the message. status.source is published only while the binding is satisfied. A FluxTrigger puts its finalizer in place just before its first adoption write, and keeps it until it has handed the Kustomization back or over, also while its configuration is invalid (Held=Unknown NotConfigured then says the Kustomization is left suspended). Once no eligible GitTarget references it, conclusively, it retires through the same path as a deletion: Held=False Releasing, suspended first if an apply has it resumed, then released or left for the next in line, within 10 minutes of Held turning Releasing. It stays the holder until that is done; a reference returning before then holds the Kustomization again. An operation still recorded in flight that nobody here follows completes Failed. The line for an applier ranks only triggers in line: an eligible reference, or a FluxTrigger carrying its finalizer. A FluxTrigger with an apply in flight comes first, then one that holds the Kustomization already, then the oldest, so an older trigger with the wrong repository, or not admitted, keeps no younger one out, and a holder is never displaced mid-release. The reconciler watches what eligibility reads: GitTargets (before and after an update), GitProviders, ClusterProviders (every trigger) and namespace labels, and the triggers in line re-run when one moves in or out of it. A GitTarget reads ApplyTriggerReady=Unknown NotReferenced until its trigger has observed the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… allowCrossNamespace
A FluxTrigger could suspend any Kustomization its admitted namespace named,
flux-system/flux-system included, and with the chart default accessFrom
every namespace is admitted. ClusterProvider.spec.allowCrossNamespace now
groups the two powers an admin grants an admitted namespace outside itself:
- sources: a WatchRule may watch other namespaces, "*" included. It
replaces allowAnySourceNamespace.
- appliers: a FluxTrigger or ArgoCDTrigger may name a Kustomization or
Application in another namespace. Without it, a trigger naming one
elsewhere is Configured=False ApplierNamespaceNotAllowed (stalled) and
never asks the applier's cluster. The trigger's own namespace is always
allowed; in a remote cluster, the namespace of the same name. The check
is part of ApplyTriggerAdmitted, so the dispatcher refuses too.
Both default to false. It is a delegation to every admitted namespace, not
owner consent, and the guides say so.
allowAnySourceNamespace is removed, not migrated: it is never read, and it
stays in the schema without a default only so a spec-level CEL rule refuses
true ("allowAnySourceNamespace was removed; set allowCrossNamespace.sources").
On spec, any spec change re-runs the rule, so ratcheting cannot pass a
stored true through; false, which the old default stored everywhere, stays
accepted, and so does removing the field. An envtest pins all of it,
including a true stored before the rule existed. A WatchRule that relied on
the old field reports SourceNamespaceNotAllowed naming the new one.
The chart gains clusterProvider.default.allowCrossNamespace.{sources,
appliers}; the e2e applier specs grant appliers on the default provider, as
a user must for a Kustomization in flux-system.
BREAKING CHANGE: ClusterProvider.spec.allowAnySourceNamespace is gone, and
true is refused on any spec change. Set spec.allowCrossNamespace.sources:
true instead; until then, cross-namespace WatchRules stop mirroring. A
trigger whose applier is in another namespace needs
spec.allowCrossNamespace.appliers: true.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… gone A folder holding both a ClusterProvider and a FluxTrigger is removed, and the provider goes first: the hand-back was skipped with an Info log line, the finalizer came off, and the Kustomization stayed suspended under gitops-reverser for good. For a provider without kubeConfig its cluster is still reachable, so the hand-back was possible. A provider that is gone no longer ends the hand-back: it goes on through whatever client still resolves. Only when none does is the trigger let go, with a Warning ReleaseFailed saying the Kustomization is left suspended and that `flux resume` resumes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ps mid-apply An apply is in flight and the operator is stopped (`helm upgrade`, a node drain; the chart's maxSurge 0 means the old pod is gone before the new one starts). The dispatcher's own context ended, so the abandoned path never ran, and the Kustomization stayed resumed until the next process recovered the operation, roughly 30-90 s in which Flux applies a new artifact, or a drift reconcile reverts live edits, on its own. On the stop path, a Flux operation this process resumed and has not suspended again is now suspended once, under context.WithoutCancel with a 5 s timeout, without waiting for it to be handled: recovery already handles a suspend of its own the process did not report. A trigger being deleted is left to hand its Kustomization back itself. The promise is narrowed with it: a Kustomization waits suspended while GitOps Reverser is down, a graceful stop suspends what it had resumed, and after a crash the apply that was running finishes and the next process suspends it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A merge to the branch observed while no GitTarget named an applier owed nobody, so its ForeignCommit was taken and dropped. When a GitTarget then named a trigger, adoption suspended the Kustomization before its GitRepository fetched the merge, and the new applier state was owed nothing: the merge was never deployed until some later push. Separately, with zero appliers the worker stopped listing its GitTargets, so the new name was not seen until another event. A newly created applier state now owes what a first observation owes, a CatchUp where its kind catches up: for a Kustomization already at the tip it costs nothing (Current). The worker lists its GitTargets on every pass; the listing is cached. An Application gets no CatchUp, by the earlier decision (a sync runs its hooks), so a newly bound one waits for the next push, refusal or repair, and the guide says so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…olding it "The latest managedFields entries are the applier's alone" was taken as proof that the applier wrote. It is not: an update that removes every field a manager owns deletes that manager's entry (structured-merge-diff, merge/update.go), and the newest entry left is often kustomize-controller's from an earlier apply. Inside a window, `kubectl label cm/x tmp=1` was mirrored, `kubectl label cm/x tmp-` then looked like the applier alone over a newer edit and was withheld, and the Repair put tmp=1 back: the undo was lost on the cluster and in Git. The Event now carries the time of its latest managedFields entries, and the window records each edit's time. A write of the applier's alone is withheld only when its entry is strictly newer than the edit: a late apply moves the applier's entry forward, an undo does not. A same-second tie is ambiguous and mirrored, as before; an older entry is the API's undo, mirrored as an edit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…it ran Removing .operation is the community workaround for a stuck hook. Argo CD then processes the Application no further (it acts on an operation only while .operation is set, and clears it only once the state completed), and its operationState stays Running. GitOps Reverser followed such a sync of its own forever, and refused every later request as "in progress". Our sync, Running or Terminating with .operation gone, is now Failed once it is overdue (by then any controller pass in flight has written), and the worker asks again. A request that finds such a state, unchanged across its whole wait, treats it as no operation in progress and syncs over it; a retry pending keeps .operation, so it is still waited out. The test of the retry-pending shape now carries .operation, as Argo CD writes it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s reads - onRefusal: Reapply said every Kustomization on the GitRepository applies a refetch and advised a separate GitRepository. Under the suspended model only the consumers no trigger holds, status.source.otherConsumers, apply it. Reworded in the API (and the CRD) and in configuration.md. - The chart README's uninstall now says to delete the GitTargets or the triggers while the operator still runs: a FluxTrigger that holds its Kustomization keeps a finalizer only the operator removes, and one left after helm uninstall wedges namespace and CRD deletion. fluxtriggers and argocdtriggers join the CRD lists. - The status guide said kstatus never reads Ready. It does, as a fallback when Reconciling and Stalled decide nothing, which is why an idle trigger is Ready=True. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The restart-recovery spec asserted that no process had suspended the Kustomization after a rollout restart mid-apply. A graceful stop now suspends a Kustomization the stopping process had resumed, so the spec asserts that instead; the restarted manager still completes the same operation, Succeeded, and the Kustomization stays suspended. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ates precisely A pass over every document the trigger work touches, against the code as built: - "owed" is gone from the user docs; the life of an apply calls the state before a request Due, held only in the branch worker's memory. - Both guides' complete examples declare the default ClusterProvider with allowCrossNamespace.appliers, since their applier is in flux-system or argocd; the guide tests check it through ApplyTriggerAdmitted. - The rule (a trigger acts only while an eligible GitTarget references it), the line of triggers, the release exits, a gone ClusterProvider, the first-naming CatchUp and the newer-than-the-edit withholding rule are stated where the guides, configuration.md, architecture.md and interpreting-metrics.md describe them; definitions.md gains Eligible and In line, and Held lists every reason. - Troubleshooting rows for ApplierNamespaceNotAllowed, NotBound and an idle NotReferenced trigger; the test tables name the new tests. - The design doc records the decisions of 2026-10-19. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The branch worker, the applier dispatcher, the watch plane and their docs described state with accounting words (owed, owes, debt, obligation) that name no state. They now say what is pending, for whom, and what ends it. The status field status.repairOwed on FluxTrigger and ArgoCDTrigger is now status.repairPending, with the RepairPending printer column. It was never released (it arrives with the trigger kinds in this stack). In the branch worker: applyDebt is applyInbox, the mailbox other goroutines write observations and attempt reports into; reapplyOwed/owedRefusal are unsentRefusals/unsentRefusal, a Refusal request decided but not yet sent. The render-fidelity condition message says "waiting for revision N" instead of "owes revision N". docs/definitions.md lists the words under "Words we do not use", and defines pending, carries and answers for apply reasons. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestDispatcher_LooksTheApplierUpInItsCluster failed about 1 run in 10 with two resume patches where it expected one. Its Kustomization was not suspended, so the simulated kustomize-controller reconciled it as soon as the fetch landed the new artifact, and that status write could move the resourceVersion between the resume's read and its apply. The resume was then refused with a Conflict and made again, which is what the dispatcher should do, but patches() lists the refused apply too. The Kustomization a trigger holds is suspended, as every other dispatcher test's is (behindSource). Suspended, the fetch changes nothing the resume reads, and the test checks only what it is about: the lookup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestFactIndex_RewritingOneKeyDoesNotStrandTheEvictionOrder failed in CI on d9b5320 ("2969" is not less than "2000"). Its barrier waited for the eviction order's capacity to pass the rewrite count, but every rewrite appends two references (exact and latest) and the follower stores the batch one fact at a time under the lock. The capacity passes 4000 when 4200 to 5500 of the 8000 references have landed (measured), so the sweep could run mid-batch, compact what had landed, and leave the rest of the batch to grow the order again before the assertion read it. Wait for the order's length to reach 2*rewrites instead, and keep the capacity check as a precondition. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The bi-directional leg is the e2e leg that fails most. 8 of its CI runs
between 2026-09-30 and 2026-10-19 died in `_argocd-installed` before any
spec ran: argocd-redis sat in ImagePullBackOff because
ecr-public.aws.com answered "429 Too Many Requests". 4 more from
2026-09-28 and 2026-09-29 stopped the same way ("argocd-redis not ready,
Available: 0/1"), from before the task dumped pod events, so their cause
is not on record. The argo-cd chart pulls redis from ECR Public, which
rate-limits anonymous pulls by source IP, and GitHub's runners share
IPs. The kubelet's own back-off and the task's second helm attempt both
ran into the same limit, and so did reruns.
mirror.gcr.io, Google's Docker Hub cache, serves the same image. The
values pin the digest ECR refused (sha256:08ad0b1d..., the chart's
8.2.3-alpine), so the change only moves where the bytes come from. The
README says to re-pin them on a chart bump.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
prepare-e2e aborted in `_crds-installed` on CI runs 34824995173 and
35235710425, eight seconds in, with
.status.conditions accessor error: <nil> is of the type <nil>,
expected []interface{}
The task applies the project CRDs and runs `kubectl wait
--for=condition=Established` straight after. Until the apiextensions
controller writes its first conditions, a new CRD has
`status.conditions: null`, and kubectl's condition check returns an
error on that instead of waiting (ConditionalWait.checkCondition in
k8s.io/kubectl pkg/cmd/wait/condition.go), so the wait fails well inside
its timeout. A jsonpath wait fails the same way on the null list.
hack/e2e/wait-crds-established.sh runs the wait again on that error, and
only that one, within the original deadline. A real timeout or any other
failure still fails at once. The three waits that follow their own CRD
apply use it: _crds-installed, the plain-manifests install, and the demo
CRDs.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The full-manager leg of CI run 39246816224 sat 28 minutes in prepare-e2e until the 30-minute suite timeout killed it, and left nothing to go on: utils.Run collects a command's output with CombinedOutput and returns it only when the command ends, so the timed-out BeforeSuite's report held the `running: "task ... prepare-e2e"` line and nothing after it. utils.RunStreaming also copies the output to GinkgoWriter as it comes, and a timed-out node's report includes what GinkgoWriter received. The next hang then names the task it stopped in. A failed prepare now prints its output once, in order, instead of as one quoted string inside the error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A live event queued behind a replay resync could be dropped for good. The
branch worker answered the resync and took the next item at once, while
the scope was recorded clean only by the caller, on another goroutine,
after it read the answer. The event met a gate that was still closed, was
dropped at V(1), and was never replayed: the watch cursor had moved past
it. The e2e spec for a rules[].sourceNamespace override failed 9 times in
CI this way, on a sibling scope whose resync sat ahead of the event.
The worker now records the scope clean itself, once the resync applies and
before it answers. The request carries the stream's render-fidelity
revision, so a replay from a retired stream still opens nothing. The drain
records the same result again, which the gate takes unchanged, and still
publishes the condition. This also opens the gate for a replay the remote
held back: its caller was answered with the outage, recorded nothing, and
the GitTarget stayed closed after the snapshot landed.
A write the closed gate drops is now counted in
gitopsreverser_git_writes_dropped_total{kind, reason}, kind event or
atomic, reason render_fidelity_pending or render_fidelity_diverged. The
first drop under each gate status is logged at Info, naming the scopes the
gate waits for. Drops while another collection of the GitTarget is still
replaying are unchanged and now visible.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A last pass over the docs, the CRD descriptions and the comments before #419 and #420 merge, so nothing describes the intermediate spec.applier model or behaviour that is gone. - Guides: a trigger no GitTarget names reads nothing; one named only by ineligible GitTargets does (NotBound or a refusal). The ArgoCDTrigger also goes Ready=False on OperationUnresolved. Samples say they need allowCrossNamespace.appliers. - Reference: architecture, configuration, definitions, security-model, components, rbac, metrics and status-conditions describe triggers that hold their applier, the Repair reason, the receiver, the optional applier ClusterRoles, and the ratcheting answer for a stored PushEmptyCommit. - Chart: helm upgrade never upgrades crds/, so the README says to apply the release's crds.yaml first; applierAccess comments describe holding. - Design: apply-trigger.md matches the code; superseded receiver and reconcile-trigger mentions elsewhere point at it. - push-notification-and-reconcile-trigger.md goes back to design/: Go code cites it by section, and INDEX keeps such pages there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…like the other servers
Every other listener lives under servers.<name> with bindAddress and
port; the receiver was a top-level receiver.bindAddress with its port
parsed out of the address. It is now servers.receiver.{enabled,
bindAddress, port}, shaped like servers.admission: enabled (default
false) switches the flag, the containerPort and the <release>-receiver
Service on together, bindAddress defaults to :9292 as the kustomize
install binds, and port is the containerPort. The Service stays on port
80, as Flux's webhook-receiver is, since a route sits in front of it.
receiver.bindAddress was never released, so nothing migrates.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sume is never written away The fake source cluster wrote a status by reading the object, changing it and updating it on the tracker, while a client patch did the same in its reactor, with nothing ordering the two. A resume (suspend: false and a requestedAt token) that landed between a reconcile's read and its status update was written away by it: the token was never handled, and TestFluxProgress_AnApplyOfAnOlderArtifactIsAskedAgain failed in CI with the operation still Running after 5s. The API server's resourceVersion check makes such a write fail instead; one mutex around both read-modify-writes stands in for it here. TestSim_AStatusWriteNeverWritesAResumeAway holds a status write between its read and its write and sends a resume in the gap; without the lock the resume lands and is lost. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #419 (base
docs-bi-directional-right). The two release together, sospec.applierfrom #419 is never released: this branch replaces it outright.The applier never applies on its own. A Flux
Kustomizationis held suspended while idle and resumed for one apply at a time; an Argo CDApplicationkeeps automated sync off. An apply trigger holds the applier, and acts only while an eligibleGitTargetreferences it. The design, with the kustomize-controller v1.9.6 and Argo CD 3.5.0 facts it rests on, isdocs/design/apply-trigger.md; the user guides arebi-directional-flux.mdandbi-directional-argocd.md, behind the overviewbi-directional.md.Apply triggers
"Applier" keeps its meaning from
docs/definitions.md: the FluxKustomizationor Argo CDApplicationitself. The resource that holds one is an apply trigger:GitTargetreferences it. Eligible: in the trigger's namespace, not being deleted, naming it, admitted by itsClusterProviders, and writing the repository and branch the applier fetches; never judged from theGitTarget'sReady. A trigger noGitTargetnames reads and writes nothing in the applier's cluster, is not in line for its applier, and isConfigured=Unknown,Ready=True, bothNotReferenced(so a Flux health check waiting on it does not deadlock against theKustomizationthat creates theGitTarget). With severalGitTargets, one eligible binds it; none eligible while one cannot be judged yet isNotBound(progressing); each refused isSourceMismatch,NamespaceNotAuthorizedorNotBound(stalled), everyGitTargetin the message.status.sourceis published only while bound.Configuredis otherwiseApplierNotFound,UnsupportedSource,SuspendOwnedElsewhere,AutomatedSyncEnabled,Forbidden,NamespaceNotAuthorized,ClusterProviderNotFound,ApplierNamespaceNotAllowedorHeldByAnotherTrigger.Held(FluxTrigger), from the trigger's side:Suspended(suspended under our field manager, last suspend handled),Applying(an operation of ours in flight and followed),Suspending(a suspend sent, not handled yet),Releasing(handing it back),NotReferenced,NotConfigured(whose message says when theKustomizationis left suspended). It is a printer column. The operation phase keepsRunning, andSettling.GitTargetreportsApplyTriggerReady: the trigger'sReady, plus the binding check against its own repository and branch (SourceMismatch),ApplyTriggerNotFound, orUnknown(NotReferenced) until the trigger has observed the reference.status.applierandApplierConfiguredare gone.GitTargets (before and after an update),GitProviders,ClusterProviders and namespace labels.Cross-namespace grants (breaking)
ClusterProvider.spec.allowCrossNamespacegroups the two powers an admin grants an admitted namespace outside itself, both defaultfalse:sources: aWatchRulemay watch other namespaces,"*"included. It replacesallowAnySourceNamespace.appliers: a trigger may name aKustomizationorApplicationin another namespace, such asflux-system. Without it,Configured=False(ApplierNamespaceNotAllowed); the dispatcher refuses too.It is a delegation to every admitted namespace, not owner consent, and the guides say so.
allowAnySourceNamespaceis never read; it stays in the schema without a default only so aspec-level CEL rule refusestrueon create and on any spec change (ratcheting cannot pass a storedtrueonce spec moves), whilefalse, which v0.52 stored everywhere, and removing the field stay accepted. AWatchRulethat relied on it reportsSourceNamespaceNotAllowednaming the new field. The chart gainsclusterProvider.default.allowCrossNamespace.{sources,appliers}, andUPGRADING.mdhas the entry.The Flux applier, suspended
ConfiguredFluxTriggerputs its finalizer in place, then suspends itsKustomizationwith a forced server-side apply under the field managergitops-reverserand arequestedAttoken, and isHeld=True(andReady) once that token is handled. Another manager that appliesspec.suspendisSuspendOwnedElsewhere; a one-off update such asflux resumeis taken back. A trigger that never adopted has no finalizer and deletes at once.spec.suspend: falseand a token T1, guarded by the uid andresourceVersionjust read. When T1 is handled, readReadyandlastAppliedRevision, then suspend with a second token T2 (Settling). T2 handled proves no reconcile started before the suspend is still running. A failed apply is suspended too, so Flux'sretryIntervalretry cannot fire; the worker retries on its backoff.Ready'slastTransitionTimeor the topstatus.historyentry must have moved since the resume. An applied revision other than the commit isSucceededonly when theGitRepositoryholds it now.Held=True, not being deleted, and first in line.StatusWriter.RecordApplyOperation, before the resume (or the Argo CD sync); the writer matches operations by token, never by time.Failed, and sends anOperationUnfollowedEvent. A graceful stop of the operator suspends aKustomizationit had resumed, once and without waiting for it to be handled; after a crash, the apply that was running finishes and the next process suspends it.Kustomizationfound resumed with no operation of ours in flight is suspended again, every time, with aResumedElsewhereEvent, and recorded as anUnresolvedoperation that completes once that suspend is handled.GitTargetnaming it, hands theKustomizationback:Held=Releasing, suspended first if an apply has it resumed, thenspec.suspendreleased (a forcedsuspend: falsefirst, since our apply over aflux suspendshares the field). Until that is done the trigger stays the holder in line. A reference returning before the release holds it again; one after adopts it anew. AClusterProviderthat is gone does not end it: it goes on through whatever client still reaches the cluster, and only when none does is the trigger let go, with aReleaseFailedWarning. Past 10 minutes fromHeldturningReleasing, the same.repairPendingis restored.status.source.otherConsumerslists theKustomizations andHelmReleases on the sameGitRepositorythat no trigger holds, information only. Appliers sharing a source have one operation at a time. With no other consumers and its ownKustomizationheld (prefetch: true), a push seen while the branch is busy is fetched at once, after the dispatcher checks on the cluster that every consumer is still held, and only into theGitRepositorywhose consumers it checked: theKustomization's livesourceRefmust still be that one.Ownership and races
FluxTriggercarrying its finalizer), one holds it: one with an apply in flight first, then one holding it already, then the oldest. An older trigger with the wrong repository, or not admitted, keeps no younger one out; a holder is never displaced mid-apply or mid-release.resourceVersion-guarded refresh write: once that lands the lost update can never land, and it isFailedand asked again. No time bound.GitProviders on the same repository and branch.Repairis pending. An entry in the same second is ambiguous and mirrored; an older one is the API's undo of an edit (an update that removes every field a manager owns deletes its entry) and is mirrored. A new attempt keeps the earlier window's edits until it would have closed.CatchUp, so a commit observed before it was named is applied once the branch is quiet (Currentwhen it already is). AnApplicationgets noCatchUp, and waits for the next push..operationwas removed whileRunning(the stuck-hook workaround) isFailedonce overdue; a request finds such a state, unchanged for its whole wait, no operation in progress.Operations, for both appliers
Each apply is followed as an operation on the trigger's
status.operation:Requested,Running,Settling(Flux), thenSucceededorFailed. One not completed after 5 minutes isUnresolved(Ready=False,OperationUnresolved, anApplyUnresolvedEvent,apply_operations_unresolved); it is still followed, and a Flux one is suspended at once.Validation
task lintandtask testpass onb987bdd8. CI ond9b53208was green on every leg: Lint, Unit tests, and the six e2e legs (full-manager, full-core, bi-directional, source-cluster, quickstart-install, image-refresh); later commits are e2e hardening, one render-fidelity fix, the "pending" vocabulary and a last docs pass. Locally,task test-e2epassed 99 of 100 specs, the one failure being Flux Restart Recovery before its spec learned that a graceful stop now suspends theKustomization(it then passed alone), andtask test-e2e-bi-directionalpassed 9 of 9.Each fix of the seventh review has a regression test that failed without it: a
ClusterProvidergone before its trigger, a stop mid-apply, an applier named after the merge it needs, an undo inside the apply window, and an Argo CD.operationremoved whileRunning. The rule has its own test file (applytrigger_rule_test.go), the removed field an envtest that stores atruebefore the CEL rule exists, and both guides' complete examples are checked against the admission they need. The Manager Flux Applier e2e spec now creates the trigger before anyGitTargetnames it, sees it refused for the wrong branch, adopted once the source is fixed, handed back when the reference is dropped, and adopted again.Known limits: on Flux older than v1.7 (no
status.history) the only proof of a reconcile isReady's transition time at one-second precision; a request that arrives while a trigger is still adopting fails once with anApplyRequestFailedWarning and is retried;ExternalArtifactconsumers of the source are not listed; the binding compares theGitProviderURL, not write access, soallowCrossNamespace.appliersis a delegation; the gap between prefetch's consumer check and its stamp of theGitRepositoryis accepted (milliseconds, and the worst case fetches early what a consumer would fetch on its own interval).🤖 Generated with Claude Code