Repository navigation
fix(operator): force source-watcher drift on stuck ArtifactGenerator status - #3182
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughPackageSourceReconciler now exits early for deleting PackageSource objects, threads one timestamp through status updates, detects stuck ArtifactGenerator Ready conditions, and applies bounded recovery with annotation tracking, rollback-safe patching, and explicit Unknown/False readiness reasons. ChangesBounded ArtifactGenerator recovery
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Reconcile
participant updateStatus
participant artifactGeneratorStuck
participant maybeRecoverArtifactGenerator
participant ArtifactGenerator
Reconcile->>updateStatus: updateStatus(ctx, packageSource, now)
updateStatus->>artifactGeneratorStuck: check Ready condition
artifactGeneratorStuck-->>updateStatus: stuck true/false
alt stuck
updateStatus->>maybeRecoverArtifactGenerator: recoverArtifactGenerator(ctx, packageSource, ag, now)
maybeRecoverArtifactGenerator->>ArtifactGenerator: patch status and annotations
maybeRecoverArtifactGenerator-->>updateStatus: ctrl.Result
else not stuck
updateStatus->>ArtifactGenerator: clear recovery tracking annotations
end
updateStatus-->>Reconcile: ctrl.Result, error
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
Andrei Kvapil (kvaps)
left a comment
There was a problem hiding this comment.
I verified the diagnosis against the source-watcher v2.1.0 code and it holds up completely. The no-drift branch in reconcile() returns before touching conditions, and the deferred summarizeStatus never promotes a stuck Unknown to True — it only adjusts the Reconciling condition. The half-persisted state in your evidence (Inventory/ObservedSourcesDigest written, Ready=True lost) is explained by fluxcd's patch.Helper patching status and conditions as separate requests, so an etcd timeout can land between them. Good find, and the predicate is the right shape: both observable-artifacts invariants plus the ObservedGeneration match, with True/False passed through untouched so real failures are never masked.
Two asks before this leaves draft:
1. Document the semantic change. The Generation check catches spec edits, but a content change (new OCI artifact revision) doesn't bump Generation — so during a normal regeneration the AG sits in Ready=Unknown with the previous Inventory/digest still persisted, and the predicate fires. Net effect: PackageSource Ready no longer dips to Unknown while a new revision is being processed; it now means "valid consumable artifacts exist", not "the current revision has been processed". I think that's actually the better contract for install/upgrade ordering (no InProgress flapping, and a failed regeneration still surfaces as False), but it's a real contract change and the comment/PR body should state it explicitly rather than implying synthesis only happens in the stuck-status window.
2. Finish the test plan before un-drafting. The dev3 live verification and the E2E run on top of #3178 are the two items that actually prove the workaround unblocks cozystack-platform; the unit tests only lock in the predicate. (Also, the body says 7 test branches — the table has 8 cases.)
Nit: artifactGeneratorObservablyReady re-runs FindStatusCondition that the caller just executed; passing the condition in would keep one lookup and make the call sites read better.
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
NOT LGTM — concur with the existing review: this should not leave draft until the contract change is documented and the live + E2E items in the test plan are actually run. The approach itself is sound. Adding evidence for the diagnosis and two points not raised yet.
Business context: on fresh install cozystack-platform times out at 15m when a single PackageSource's ArtifactGenerator stalls in Ready: Unknown after source-watcher's status-patch fails under first-install etcd load — even though its ExternalArtifacts are produced and consumed fine.
Confirming the diagnosis (extra evidence)
The predicate firing on a content-only regeneration (new OCI revision, .metadata.generation unchanged) is provable, not incidental: fluxcd/pkg/runtime/conditions.Set assigns condition.ObservedGeneration = to.GetGeneration() on every condition write, including the Progressing/Unknown mark at the start of a rebuild. And ArtifactGeneratorStatus exposes no object-level ObservedGeneration (it inlines only ReconcileRequestStatus), so the condition-level value is the only generation signal available — there is no clean field to distinguish "current revision fully processed" from "rebuild in progress, previous Inventory/digest still persisted".
So the contract genuinely becomes "valid consumable artifacts exist" and cannot be tightened back to "current revision processed" without a new upstream signal. That makes documenting the contract (rather than gating around it) the right call — please make the comment and PR body state it outright instead of implying synthesis only happens in the stuck-status window.
Two points not raised yet
-
Link an upstream issue and add a removal marker. This papers over a source-watcher bug that is still present in current
main, so it cannot be retired by a version bump alone. File/link afluxcd/source-watcherissue and add a// TODO(remove once fixed upstream: <link>)on the workaround so it does not silently become permanent. -
Version attribution. The PR body pins the bug to
source-watcher v2.1.0, but the deployment tracks the flux-aiolatestdistribution (~v2.2.x) and the failure reproduced there in the referenced run. Worth correcting, since it also confirms "just bump source-watcher" is not an available fix today.
Nit: the workaround comment says the next reconcile "early-exits before touching status again" — the !hasDrifted branch still runs the top-level deferred summarizeStatus; the accurate reason Ready stays Unknown is that the no-drift path never re-marks it True.
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
LGTM — the blocker is resolved.
The synthesis logic is unchanged, but the safety reasoning is now correct: the comment no longer claims the Generation check guarantees the Inventory matches the current spec. It documents the real contract change — Ready=True now means "valid consumable artifacts exist" rather than "current revision processed" — names the precise mechanism (conditions.Set stamps ObservedGeneration = ag.Generation on every mutation, including the Progressing mark, and ArtifactGeneratorStatus exposes no object-level ObservedGeneration), and pins the content-only-regeneration firing with a dedicated test. Ready=False still passes through, so a genuine regeneration failure is never masked; a spec edit still bumps Generation and is caught by the match.
The workaround is now anchored to a real open upstream issue — fluxcd/pkg#934 describes exactly this: patch.Helper writing .status.conditions and .status.artifact in separate patches, which is what leaves Inventory/digest persisted while Ready stays Unknown — with a TODO(remove) so it retires when the upstream fix lands. The Ready condition is passed into the predicate now, dropping the duplicate lookup.
Note: the E2E check is currently red, so this still needs a green run before merge — separate from the code review.
myasnikovdaniil
left a comment
There was a problem hiding this comment.
This correctly diagnoses the upstream race (fluxcd/pkg#934), and the writeup is thorough. My concern is with the chosen fix: synthesising a terminal Ready=True that the operator never observed reinterprets the ArtifactGenerator's status semantics permanently, and I think it introduces two correctness problems that outweigh the transient install stall it removes. There's also a lower-cost fix that doesn't change what Ready means. Details inline.
The two "blocking the draft's removal" boxes in the test plan (live cozy-dev3 + full E2E) are also still unchecked — this hasn't yet been shown to actually unblock the install it targets.
| // * a genuine regeneration FAILURE still surfaces — the upstream | ||
| // writes Ready=False on the same reconcile, and this predicate | ||
| // returns false for Ready=False so the caller passes it through. |
There was a problem hiding this comment.
This safety guarantee assumes the Ready=False write always lands. But the whole premise of the workaround is that condition writes can be lost to the patch.Helper/etcd race. Consider a content-only regeneration that fails and whose Ready=False write is the one lost: Inventory/ObservedSourcesDigest still hold the previous success, Ready is stuck Unknown, and ObservedGeneration == ag.Generation, so artifactGeneratorObservablyReady returns true and we synthesise Ready=True — masking a real failure.
Is there something that guarantees the failure path persists Ready=False before/atomically with the artifact fields? If not, "must NEVER mask a genuine failure" doesn't hold in exactly the race this code exists for.
| // persisted" — meaning the predicate can (and does) fire during a | ||
| // legitimate content-only regeneration where the OCI revision changed | ||
| // but `.metadata.generation` did not. |
There was a problem hiding this comment.
Firing on a content-only regeneration means PackageSource Ready=True is reported while the previous revision's artifacts are still what's on disk. For a GitOps upgrade that's the one moment we don't want to signal Ready: downstream Packages/HRs can proceed against stale artifacts and treat the upgrade as converged.
The ObservedGeneration guard only catches spec-edit staleness (.metadata.generation bump), not content staleness — as the comment itself acknowledges. Trading "no InProgress flapping" for "Ready over stale content on every upgrade" reads as a correctness regression rather than a strictly-better contract. What's the concrete downstream harm from the brief Unknown dip that justifies this?
| // one. The synthesis reason `ArtifactsGeneratedAwaitingUpstreamStatus` | ||
| // is greppable so operators can audit which PackageSources are | ||
| // currently on the workaround path. | ||
| if artifactGeneratorObservablyReady(ag, readyCondition) { |
There was a problem hiding this comment.
An alternative that doesn't reinterpret the CRD semantics: when artifacts are present but Ready is stuck Unknown, force a fresh reconcile of the ArtifactGenerator (bump reconcile.fluxcd.io/requestedAt) and requeue, so source-watcher re-writes the real condition. That unblocks the 15m install timeout without changing what Ready means and without the masked-failure / stale-artifact windows above.
Given the TODO(remove) will realistically outlive the upstream fix, a fix that doesn't durably change semantics seems preferable.
| // | ||
| // This regression-tests both the stuck-status case (the bug we are | ||
| // papering over) and every case that must NOT trigger it. | ||
| func TestArtifactGeneratorObservablyReady(t *testing.T) { |
There was a problem hiding this comment.
All 9 cases exercise the pure predicate artifactGeneratorObservablyReady. The actual synthesis in updateStatus has no coverage: the Status().Update, the pass-through branches, the LastTransitionTime-preservation claim (reconciler.go:459-463), and the Owns()-driven overwrite once upstream resolves. Those are the load-bearing, higher-risk parts.
Could you add a table test (or envtest) on updateStatus asserting the synthetic condition is written and that a subsequent real condition overwrites it?
| // runtime, so a source-watcher version bump alone does not remove | ||
| // the need for this synthesis. | ||
| // | ||
| // Kvaps' review on #3182 confirmed the diagnosis by tracing the |
There was a problem hiding this comment.
These block comments cite the PR conversation directly ("Kvaps' review on #3182 confirmed…", and "kvaps' nit" in the predicate doc further down). Once merged this reads as unexplained context and will rot. Suggest trimming to the technical rationale plus the upstream issue link and dropping the reviewer/PR references.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a race condition in the upstream fluxcd/source-watcher that causes PackageSource resources to remain stuck in an Unknown ready state during high-load scenarios. By detecting these stalls and proactively triggering reconciliations via annotations, the operator ensures that PackageSource resources converge to a Ready state, significantly improving the reliability of platform installations. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request implements a bounded-retry workaround in the PackageSourceReconciler to address a known split-write race condition in source-watcher (fluxcd/pkg#934) where the Ready condition can be lost. The retry mechanism tracks attempts and timestamps via annotations on the ArtifactGenerator and uses exponential backoff to nudge source-watcher before eventually marking the PackageSource as stalled. Comprehensive unit tests are also added to validate this state machine. The review feedback suggests improving error handling by propagating status update errors in Reconcile to trigger immediate retries on transient failures, and optimizing the retry actions (requeueActionGiveUp and requeueActionWait) to avoid redundant API server writes when the status is already up to date.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| result, err := r.updateStatus(ctx, packageSource, nowFunc()) | ||
| if err != nil { | ||
| logger.Error(err, "failed to update status") | ||
| // Don't return error, status update is not critical | ||
| } |
There was a problem hiding this comment.
Now that updateStatus is responsible for driving the workaround and retry loop (including bumping annotations and setting the AwaitingSourceWatcherRequeue condition), ignoring status update errors is risky.
If r.Status().Update fails due to a transient error (such as a Kubernetes API conflict, which is very common), ignoring the error means the controller won't retry immediately. Instead, it will wait for the RequeueAfter duration (which can be up to 4 minutes), leaving the PackageSource status stale or out of sync with the bumped ArtifactGenerator annotations.
Consider returning the error from Reconcile so that controller-runtime can perform immediate exponential backoff retries on transient failures.
| result, err := r.updateStatus(ctx, packageSource, nowFunc()) | |
| if err != nil { | |
| logger.Error(err, "failed to update status") | |
| // Don't return error, status update is not critical | |
| } | |
| result, err := r.updateStatus(ctx, packageSource, nowFunc()) | |
| if err != nil { | |
| logger.Error(err, "failed to update status") | |
| return ctrl.Result{}, err | |
| } |
| case requeueActionGiveUp: | ||
| message := fmt.Sprintf( | ||
| "ArtifactGenerator %s/%s has been stuck with a lost Ready condition through %d requeue attempts; "+ | ||
| "source-watcher is not recovering. See https://github.com/fluxcd/pkg/issues/934. "+ | ||
| "An operator must restart source-watcher or manually inspect the ArtifactGenerator.", | ||
| ag.Namespace, ag.Name, maxRequeueAttempts, | ||
| ) | ||
| meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{ | ||
| Type: "Ready", | ||
| Status: metav1.ConditionFalse, | ||
| Reason: reasonSourceWatcherBad, | ||
| Message: message, | ||
| ObservedGeneration: packageSource.Generation, | ||
| }) | ||
| logger.Info("source-watcher stalled after bounded requeues; surfacing PackageSource Ready=False", | ||
| "packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempts", attempts) | ||
| return ctrl.Result{}, r.Status().Update(ctx, packageSource) |
There was a problem hiding this comment.
In requeueActionGiveUp, r.Status().Update is called unconditionally on every reconciliation. Once the controller has given up, any subsequent reconciliation (triggered by other watches or resyncs) will repeatedly call r.Status().Update even though the status hasn't changed.
We can optimize this by checking if the existing Ready condition is already set to Ready: False with reasonSourceWatcherBad and the same message/generation before calling r.Status().Update.
case requeueActionGiveUp:
message := fmt.Sprintf(
"ArtifactGenerator %s/%s has been stuck with a lost Ready condition through %d requeue attempts; "+
"source-watcher is not recovering. See https://github.com/fluxcd/pkg/issues/934. "+
"An operator must restart source-watcher or manually inspect the ArtifactGenerator.",
ag.Namespace, ag.Name, maxRequeueAttempts,
)
existing := meta.FindStatusCondition(packageSource.Status.Conditions, "Ready")
if existing != nil &&
existing.Status == metav1.ConditionFalse &&
existing.Reason == reasonSourceWatcherBad &&
existing.Message == message &&
existing.ObservedGeneration == packageSource.Generation {
return ctrl.Result{}, nil
}
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionFalse,
Reason: reasonSourceWatcherBad,
Message: message,
ObservedGeneration: packageSource.Generation,
})
logger.Info("source-watcher stalled after bounded requeues; surfacing PackageSource Ready=False",
"packageSource", packageSource.Name, "artifactGenerator", ag.Name, "attempts", attempts)
return ctrl.Result{}, r.Status().Update(ctx, packageSource)| case requeueActionWait: | ||
| meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{ | ||
| Type: "Ready", | ||
| Status: metav1.ConditionUnknown, | ||
| Reason: reasonAwaitingRequeue, | ||
| Message: fmt.Sprintf( | ||
| "ArtifactGenerator Ready condition lost to fluxcd/pkg#934 patch.Helper race; "+ | ||
| "requeue %d/%d nudged source-watcher, waiting for a real Ready write.", | ||
| attempts, maxRequeueAttempts, | ||
| ), | ||
| ObservedGeneration: packageSource.Generation, | ||
| }) | ||
| if err := r.Status().Update(ctx, packageSource); err != nil { | ||
| return ctrl.Result{}, err | ||
| } | ||
| return ctrl.Result{RequeueAfter: decision.wait}, nil |
There was a problem hiding this comment.
In requeueActionWait, r.Status().Update is called unconditionally on every reconciliation. If the Ready condition is already set to the desired state during the wait window, this results in redundant API server writes on every subsequent reconciliation (which can be triggered by other watches or periodic syncs).
We can optimize this by checking if the existing Ready condition already matches the desired state before calling r.Status().Update.
case requeueActionWait:
message := fmt.Sprintf(
"ArtifactGenerator Ready condition lost to fluxcd/pkg#934 patch.Helper race; "+
"requeue %d/%d nudged source-watcher, waiting for a real Ready write.",
attempts, maxRequeueAttempts,
)
existing := meta.FindStatusCondition(packageSource.Status.Conditions, "Ready")
if existing != nil &&
existing.Status == metav1.ConditionUnknown &&
existing.Reason == reasonAwaitingRequeue &&
existing.Message == message &&
existing.ObservedGeneration == packageSource.Generation {
return ctrl.Result{RequeueAfter: decision.wait}, nil
}
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionUnknown,
Reason: reasonAwaitingRequeue,
Message: message,
ObservedGeneration: packageSource.Generation,
})
if err := r.Status().Update(ctx, packageSource); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: decision.wait}, nilThere was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)
208-271: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLoose wait-time assertions could mask backoff regressions.
wantWaitMinonly checksgot.wait < tt.wantWaitMinfails, i.e. a lower bound, not the exact expected wait. SincedecideRequeue's wait is deterministic (needed - elapsed), asserting exact values would catch subtle backoff-schedule regressions that a loose bound wouldn't.Tighten to exact wait assertions
{ name: "backoff not yet elapsed after attempt 1 — wait", attempts: 1, lastRequeueAt: referenceTime.Add(-10 * time.Second), now: referenceTime, wantAction: requeueActionWait, - wantWaitMin: 19 * time.Second, // ~initialBackoff (30s) minus elapsed 10s + wantWait: 20 * time.Second, // initialBackoff (30s) minus elapsed 10s },(and switch the assertion to
!=exact comparison)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/operator/packagesource_reconciler_test.go` around lines 208 - 271, The test in TestDecideRequeue uses a loose lower-bound check on got.wait, which can miss regressions in decideRequeue’s deterministic backoff calculation. Update the wait assertion to compare exact duration equality for the cases that set wantWaitMin, using the existing decideRequeue, requeueActionWait, and maxRequeueAttempts symbols to keep the expected schedule tightly validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/operator/packagesource_reconciler.go`:
- Around line 122-128: The wait-path status update in updateStatus is swallowing
errors, which can prevent the follow-up requeue from being scheduled. In the
requeueActionWait path inside the PackageSource reconciler, stop ignoring the
error returned from r.updateStatus and propagate it instead of always returning
nil. Keep the existing logging, but make sure the caller sees the failure so the
reconcile can retry and preserve the AwaitingSourceWatcherRequeue flow.
---
Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 208-271: The test in TestDecideRequeue uses a loose lower-bound
check on got.wait, which can miss regressions in decideRequeue’s deterministic
backoff calculation. Update the wait assertion to compare exact duration
equality for the cases that set wantWaitMin, using the existing decideRequeue,
requeueActionWait, and maxRequeueAttempts symbols to keep the expected schedule
tightly validated.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d24ee9a3-e610-43be-87cc-3a28c4af82ba
📒 Files selected for processing (2)
internal/operator/packagesource_reconciler.gointernal/operator/packagesource_reconciler_test.go
There was a problem hiding this comment.
NOT LGTM — the two prior blockers are resolved, but the force-drift rework introduces a new correctness issue on the same path: the operator now co-authors the ArtifactGenerator's Ready condition, and its own Owns(AG) watch copies the synthetic Ready=False onto the PackageSource — which also makes the bounded give-up path unreachable in the running controller.
Business context: on fresh install cozystack-platform times out at 15m when a single PackageSource's ArtifactGenerator stalls in Ready: Unknown after source-watcher loses the Ready-condition write under first-install etcd load (fluxcd/pkg#934), even though its ExternalArtifacts are produced and consumed fine.
Resolved since the previous review
- Requeue can't force drift (prior blocker): resolved. Patching
AG.status.conditions[Ready]=Falsedoes reach source-watcher's drift path (detectDrifttreatsIsFalse(Ready)as drift → rebuild → real Ready), unlike thereconcile.fluxcd.io/requestedAtbump. - Untested status transitions (prior blocker): resolved — force/wait/give-up branches and the
updateStatusfollow-up scheduling now have unit tests;go test ./internal/operator/...passes. - Stale PR title/description: resolved — both now describe force-drift.
- The swallowed-error note on
Reconcile(line 126) is also addressed — the code returns the error now.
Blocker
The operator co-authors the AG Ready condition; the self-write is copied through, defeating the give-up path
forceArtifactGeneratorDrift writes AG.status.conditions[Ready]=False (reason SourceWatcherRecoveryForced) via r.Status().Patch (internal/operator/packagesource_reconciler.go:749-756). SetupWithManager registers Owns(&ArtifactGenerator{}) with no predicate (:858), so that self-write re-enqueues the PackageSource.
On the self-triggered reconcile:
artifactGeneratorStuckreturns false because Ready is nowFalse, notUnknown(:658-660);- so
updateStatustakes the not-stuck path →clearRecoveryTrackingdeletes the attempt counter (:449) → then copies the syntheticFalseonto the PackageSource (:471-478), with no check that the condition is the operator's ownSourceWatcherRecoveryForcedmarker.
This is newly introduced in this revision. The previous requeue approach wrote metadata only (reconcile.fluxcd.io/requestedAt + tracking annotations via r.Patch) and never touched ag.Status; the self-refire then re-saw Ready=Unknown, re-detected stuck, waited, and preserved the counter. Writing Ready=False to the AG status is what turns that same self-refire harmful.
Impact:
- Every recovery episode transiently publishes
PackageSource Ready=Falsewith a misleading "source-watcher will rebuild" message. Dependency readiness is waited-on rather than aborted, so this is not install-breaking on its own — but it is wrong status on a platform-critical resource. - The bounded give-up (
SourceWatcherStalled/ "restart source-watcher") is effectively unreachable: the self-refire clears the counter (and the freshLastTransitionTimealso resets it via:557-559), soattemptsnever reachesmaxRecoveryAttempts. A genuinely wedged source-watcher yields the copied syntheticFalseinstead of the intended actionable condition, and the install still rides the 15m timeout. - This integrated behavior is untested — the new tests call
maybeRecoverArtifactGenerator/decideRecoverydirectly, bypassing theReconcile→ Owns-watch →updateStatuscycle where the self-refire happens. The unit tests pass while the real state machine short-circuits.
Fix directions (either/both):
- In
updateStatus, treat an AGReady=Falsewhose reason is the operator's ownSourceWatcherRecoveryForcedas "still recovering" (holdReady=Unknown/wait) rather than copying it through; and/or - Add a predicate to
Owns(&ArtifactGenerator{})that ignores the operator's own status writes so force-drift doesn't re-enqueue and clear tracking. - Add an envtest exercising a stuck AG through the full recover → copy cycle so the give-up path is covered end-to-end.
Non-blocking follow-ups
- Install still unproven on this revision — E2E is in progress, and the description lists the live force-drift test on a genuinely stuck AG as required before merge. The mechanism is sound for the common fluxcd/pkg#934 case (source-watcher alive → recovers on drift); it just hasn't been demonstrated.
- Redundant
Status().Updateon the wait/give-up branches — with the self-refire in play these add avoidable write churn; guard with a no-op-if-unchanged check. - The give-up test doesn't assert zero AG writes; an envtest (above) would cover this more meaningfully.
| // This mechanism relies on source-watcher registering the standard flux | ||
| // ReconcileRequestedPredicate on ArtifactGenerator (fluxcd/pkg/runtime/predicates), | ||
| // which fires a fresh reconcile whenever `reconcile.fluxcd.io/requestedAt` | ||
| // changes. All flux-native controllers wire this predicate through |
There was a problem hiding this comment.
B1 (blocker). This paragraph assumes a reconcile.fluxcd.io/requestedAt bump makes source-watcher re-write the real Ready condition. It won't in this state. source-watcher's ArtifactGenerator reconcile is drift-gated: with Inventory + ObservedSourcesDigest persisted, Ready stuck Unknown, Ready.observedGeneration == generation and sources unchanged, detectDrift returns NoDriftDetected (artifactgenerator_drift.go:52-111 @ v2.1.0), so reconcile() early-returns at controller.go:160-164 without MarkTrue(Ready), and the deferred summarizeStatus (status.go:43-67) only records lastHandledReconcileAt/adjusts Reconciling — it never promotes Unknown→True. The predicate is wired (manager.go:63-66), so the bump does fire a reconcile — but it lands on the same no-drift path. Note source-watcher already self-requeues this AG every GetRequeueAfter() (controller.go:164) and the PR evidence shows it stuck 30+ min across those; a requested reconcile is identical. Net: the driver exhausts retries and reports Ready=False, so cozystack-platform still never converges.
| ) | ||
| meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{ | ||
| Type: "Ready", | ||
| Status: metav1.ConditionFalse, |
There was a problem hiding this comment.
B2. This give-up branch (Ready=False / SourceWatcherStalled) is the outcome that fails the install, and it has no test. The added tests exercise decideRequeue and the annotation mechanics but not the three status-condition transitions of maybeRequeueArtifactGenerator (bump/wait AwaitingSourceWatcherRequeue, and this give-up write) or the returned RequeueAfter. In a package that already has a test convention, please pin these.
| // source-watcher out of the stuck window. First attempt bumps immediately; the | ||
| // N-th subsequent attempt waits backoffFor(N) since the last bump before firing | ||
| // again; after maxRequeueAttempts fruitless attempts we surface Ready=False. | ||
| func TestDecideRequeue(t *testing.T) { |
There was a problem hiding this comment.
Coverage suggestion (B2): decideRequeue is well covered, but the status side-effects of maybeRequeueArtifactGenerator are not — a fake-client test driving updateStatus through the stuck path and asserting (a) bump/wait set Ready=Unknown/AwaitingSourceWatcherRequeue with the right RequeueAfter, and (b) exhaustion sets Ready=False/SourceWatcherStalled, would lock in the load-bearing transitions.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/operator/packagesource_reconciler.go (1)
445-464: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftTreat
SourceWatcherRecoveryForcedas still in recovery
Owns(&ArtifactGenerator{})means the forcedReady=Falsepatch requeues this reconciler, and this branch will clear the retry annotations and copy that synthetic failure straight through. That lets the recovery budget reset before it is exhausted. Special-case the synthetic reason (or another in-recovery marker) so the AG keeps its tracking annotations andPackageSourcestaysReady=Unknownuntil source-watcher reports a real condition.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/operator/packagesource_reconciler.go` around lines 445 - 464, The recovery handling in packageSourceReconciler should keep treating the synthetic SourceWatcherRecoveryForced condition as still in recovery instead of clearing annotations and propagating it as a real failure. Update the logic around clearRecoveryTracking and the readyCondition handling so this synthetic/in-recovery reason is special-cased, preserving the ArtifactGenerator tracking annotations and leaving PackageSource Ready as Unknown until a real ArtifactGenerator condition arrives.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/operator/packagesource_reconciler.go`:
- Around line 445-464: The recovery handling in packageSourceReconciler should
keep treating the synthetic SourceWatcherRecoveryForced condition as still in
recovery instead of clearing annotations and propagating it as a real failure.
Update the logic around clearRecoveryTracking and the readyCondition handling so
this synthetic/in-recovery reason is special-cased, preserving the
ArtifactGenerator tracking annotations and leaving PackageSource Ready as
Unknown until a real ArtifactGenerator condition arrives.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 98b164b8-d22a-467d-a06b-8bef72ceb8b0
📒 Files selected for processing (2)
internal/operator/packagesource_reconciler.gointernal/operator/packagesource_reconciler_test.go
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)
816-849: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGiveUpBranch doesn't verify the AG is left untouched.
The line-range summary states this test asserts the AG "is left untouched," but unlike
TestMaybeRecoverArtifactGenerator_WaitBranch(which uses an interceptor to count AG writes), this test only checksRequeueAfterand the PackageSource condition. Consider reusing the same interceptor pattern to assert zero AG writes on the give-up path, closing the gap between the documented intent and what's actually verified.♻️ Suggested addition
func TestMaybeRecoverArtifactGenerator_GiveUpBranch(t *testing.T) { ps := &cozyv1alpha1.PackageSource{ ObjectMeta: metav1.ObjectMeta{Name: "example", Namespace: "cozy-system", Generation: 1}, } priorForceAt := referenceTime.Add(-time.Hour).UTC().Format(time.RFC3339Nano) ag := stuckAG(t, map[string]string{ annotationRecoveryAttempts: strconv.Itoa(maxRecoveryAttempts), annotationLastRecoveryAt: priorForceAt, }, referenceTime.Add(-time.Hour)) c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ps, ag).WithObjects(ps, ag).Build() - r := &PackageSourceReconciler{Client: c, Scheme: testScheme(t)} + agWriteCount := 0 + watched := interceptor.NewClient(c, interceptor.Funcs{ + Patch: func(ctx context.Context, cli client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator); ok { + agWriteCount++ + } + return cli.Patch(ctx, obj, patch, opts...) + }, + SubResourcePatch: func(ctx context.Context, cli client.Client, sub string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if _, ok := obj.(*sourcewatcherv1beta1.ArtifactGenerator); ok { + agWriteCount++ + } + return cli.SubResource(sub).Patch(ctx, obj, patch, opts...) + }, + }) + r := &PackageSourceReconciler{Client: watched, Scheme: testScheme(t)} ready := meta.FindStatusCondition(ag.Status.Conditions, "Ready") res, err := r.maybeRecoverArtifactGenerator(context.Background(), ps, ag, ready, referenceTime) if err != nil { t.Fatalf("maybeRecoverArtifactGenerator: %v", err) } if res.RequeueAfter != 0 { t.Errorf("RequeueAfter = %v, want 0 (give-up path stops rescheduling)", res.RequeueAfter) } + if agWriteCount != 0 { + t.Errorf("give-up branch wrote %d times to AG, want 0", agWriteCount) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/operator/packagesource_reconciler_test.go` around lines 816 - 849, The give-up-path test for maybeRecoverArtifactGenerator does not verify that the ArtifactGenerator remains untouched, despite the test name and summary saying it should. Update TestMaybeRecoverArtifactGenerator_GiveUpBranch to reuse the same interceptor approach used in TestMaybeRecoverArtifactGenerator_WaitBranch, and assert that no write/update calls are made against the AG object while still checking RequeueAfter and the PackageSource Ready condition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 816-849: The give-up-path test for maybeRecoverArtifactGenerator
does not verify that the ArtifactGenerator remains untouched, despite the test
name and summary saying it should. Update
TestMaybeRecoverArtifactGenerator_GiveUpBranch to reuse the same interceptor
approach used in TestMaybeRecoverArtifactGenerator_WaitBranch, and assert that
no write/update calls are made against the AG object while still checking
RequeueAfter and the PackageSource Ready condition.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 662b8939-1af2-4283-b98e-c2d9434d4bcc
📒 Files selected for processing (2)
internal/operator/packagesource_reconciler.gointernal/operator/packagesource_reconciler_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/operator/packagesource_reconciler.go
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
NOT LGTM — the force-drift status patch never wakes source-watcher (its ArtifactGenerator watch filters status-only changes, and the AG's own requeue interval is hourly), and this controller's own Owns watch re-enters and discards the recovery state, so the bounded-recovery driver does not function on a live cluster.
Business context: on a fresh install, cozystack-platform times out at 15m when a PackageSource's ArtifactGenerator stalls in Ready=Unknown after source-watcher loses its terminal Ready-condition write to the patch.Helper split-write race (fluxcd/pkg#934), even though the ExternalArtifacts are produced and consumed fine. This PR drives source-watcher back onto its drifted-branch reconcile by forcing Ready=False on the AG.
Blockers
B1: A status-only patch does not trigger a source-watcher reconcile
File: internal/operator/packagesource_reconciler.go:756
forceArtifactGeneratorDrift writes Ready=False on the AG status subresource and relies on source-watcher picking it up as drift. But source-watcher v2.1.0 (the deployed pin — internal/fluxinstall/manifests/fluxcd.yaml:8230) registers the ArtifactGenerator watch with predicate.Or(GenerationChangedPredicate{}, ReconcileRequestedPredicate{}). A status-only patch changes neither .metadata.generation nor reconcile.fluxcd.io/requestedAt, so it does not enqueue a reconcile. The only fallback is the AG's periodic self-requeue, which GetRequeueAfter() hardcodes to time.Hour. So a forced Ready=False can sit for up to ~1h before source-watcher reconciles it — well outside the 15m HelmRelease install timeout this change exists to beat. detectDrift does correctly treat IsFalse(Ready) as drift, so the condition is right; the trigger is missing.
Fix: in the metadata patch, also set reconcile.fluxcd.io/requestedAt to a fresh timestamp — that annotation is what ReconcileRequestedPredicate watches. Setting Ready=False and bumping requestedAt together makes source-watcher reconcile immediately and see the drift. The comment frames the requestedAt bump and the status patch as alternatives; they are complementary — both are required.
B2: The force-drift's own AG writes re-enter this reconciler and abandon recovery
File: internal/operator/packagesource_reconciler.go:449
SetupWithManager uses .Owns(&ArtifactGenerator{}) with no predicate, so both patches in forceArtifactGeneratorDrift (status, then metadata) enqueue a PackageSource reconcile. That reconcile reads the AG as Ready=False, artifactGeneratorStuck returns false (Ready != Unknown), so this branch runs clearRecoveryTracking — deleting the attempts/lastRecoveryAt annotations just written — and then copies the synthetic Ready=False (reason SourceWatcherRecoveryForced) onto the PackageSource. Net effect: the attempt counter never accumulates, so recoveryActionGiveUp / SourceWatcherStalled is unreachable, and the operator's internal drift-forcing marker leaks onto the user-facing PackageSource status and flaps it Unknown→False.
Fix: exclude the operator's own forced Ready=False (for example by its SourceWatcherRecoveryForced reason) from the not-stuck / clear path, and never copy that synthetic reason through to the PackageSource.
B3: The attempt-counter reset makes the give-up budget unreachable
File: internal/operator/packagesource_reconciler.go:557
When source-watcher reacts to a forced drift it first writes Ready=Unknown (Progressing) before rebuilding. That transition's LastTransitionTime is necessarily newer than lastRecoveryAt, so this branch resets attempts to 0 every cycle. Even setting B2 aside, maxRecoveryAttempts can never be reached under the repeated-loss race the driver targets, so the "~11.5m budget, safely inside the 15m install timeout" guarantee in the constants comment does not hold.
Non-blocking follow-ups
- The unit tests are green but cannot catch B1–B3: they drive
updateStatus/maybeRecoverArtifactGenerator/forceArtifactGeneratorDriftagainst a fake client and simulate neither source-watcher's watch predicate nor this controller's ownOwns-watch re-entrancy. An envtest that runs the manager and asserts end-to-end recovery would exercise the load-bearing interactions — the same ones the still-unchecked live verification would surface. - The mechanism is pinned to source-watcher v2.1.0 in the comments, which matches the deployed image — keep it in sync when the flux distribution bumps source-watcher, since the watch predicate and
GetRequeueAfterare the load-bearing upstream assumptions.
Verified, not a blocker
detectDriftdoes return drift onIsFalse(Ready)— that check is the first branch in source-watcher's drift detection, so the condition half of the approach is correct; only the trigger (B1) is missing.- The earlier masked-failure / stale-content concerns (raised against the previous synthesize-
Ready=Trueapproach) no longer apply: the operator never writesReady=Trueitself, so it cannot mask a genuine failure or report over stale content. The pivot resolved those.
| Message: "cozystack-operator forced drift after fluxcd/pkg#934 stall; source-watcher will rebuild", | ||
| ObservedGeneration: ag.Generation, | ||
| }) | ||
| if err := r.Status().Patch(ctx, ag, client.MergeFrom(statusBase)); err != nil { |
There was a problem hiding this comment.
B1: this status-only patch does not enqueue a source-watcher reconcile. The ArtifactGenerator watch filters on Or(GenerationChangedPredicate, ReconcileRequestedPredicate), and this changes neither .metadata.generation nor reconcile.fluxcd.io/requestedAt. The AG's own requeue is hardcoded to time.Hour, so a forced Ready=False can sit ~1h — past the 15m install timeout. Also set reconcile.fluxcd.io/requestedAt in the metadata patch so the reconcile is actually enqueued and detectDrift sees the Ready=False. See B1 in the summary.
| // AG is not stuck — clear any recovery-tracking annotations the previous | ||
| // stuck path left behind, then either surface the missing-Ready case or | ||
| // copy the real condition through. | ||
| if err := r.clearRecoveryTracking(ctx, ag); err != nil { |
There was a problem hiding this comment.
B2: .Owns(&ArtifactGenerator{}) has no predicate, so the force-drift's own status/metadata patches re-enter this reconciler. It reads Ready=False (classified not-stuck), clears the tracking annotations just written, and copies the synthetic SourceWatcherRecoveryForced reason onto the PackageSource. The attempt counter never accumulates and the give-up path is unreachable. See B2.
| // (b) our operator missed the recovery event (crash, restart) and the AG | ||
| // re-stalled independently. Either way, stale attempts should not force | ||
| // an immediate give-up. | ||
| if ready != nil && !ready.LastTransitionTime.IsZero() && ready.LastTransitionTime.After(lastRecoveryAt) { |
There was a problem hiding this comment.
B3: source-watcher writes Ready=Unknown (Progressing) before rebuilding, so its LastTransitionTime is always newer than lastRecoveryAt and this resets attempts to 0 every cycle — maxRecoveryAttempts is never reached under the repeated-loss race the driver targets. See B3.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/operator/packagesource_reconciler_test.go (1)
1093-1158: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueConsider also asserting
annotationLastRecoveryAtis held unchanged.The test verifies
annotationRecoveryAttemptsstays at"2"through the Progressing transition, but doesn't check thatannotationLastRecoveryAtremains atforceAtrather than being bumped. A regression that accidentally refreshes the timestamp on Progressing (without touching attempts) would slip through.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/operator/packagesource_reconciler_test.go` around lines 1093 - 1158, Add an assertion in TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait to verify annotationLastRecoveryAt is preserved across the Ready=Unknown/Progressing path, not just annotationRecoveryAttempts. After calling PackageSourceReconciler.updateStatus and reloading the ArtifactGenerator, check that persistedAG.Annotations[annotationLastRecoveryAt] still matches forceAt.UTC().Format(time.RFC3339Nano) so updateStatus does not refresh the recovery timestamp while holding tracking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/operator/packagesource_reconciler_test.go`:
- Around line 1093-1158: Add an assertion in
TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait to verify
annotationLastRecoveryAt is preserved across the Ready=Unknown/Progressing path,
not just annotationRecoveryAttempts. After calling
PackageSourceReconciler.updateStatus and reloading the ArtifactGenerator, check
that persistedAG.Annotations[annotationLastRecoveryAt] still matches
forceAt.UTC().Format(time.RFC3339Nano) so updateStatus does not refresh the
recovery timestamp while holding tracking.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 28fddcc8-81eb-4876-85c0-53e9f558a7b2
📒 Files selected for processing (2)
internal/operator/packagesource_reconciler.gointernal/operator/packagesource_reconciler_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/operator/packagesource_reconciler.go
341c2a2 to
36ef73c
Compare
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
LGTM — the three prior blockers (missing requestedAt trigger, Owns()-refire clearing recovery state, and the LastTransitionTime counter reset) are all resolved on this tip and covered by unit tests; the one new finding below is non-blocking. This is a code-level approval — a green E2E on this tip is still required before merge (see the gate note).
Verified resolved
- requestedAt trigger —
forceArtifactGeneratorDriftnow bumpsreconcile.fluxcd.io/requestedAt(internal/operator/packagesource_reconciler.go:891) alongside theReady=Falsestatus patch, so source-watcher'sReconcileRequestedPredicateactually enqueues the reconcile. Both signals are present; the status write alone was a no-op. - Owns()-re-entrancy — the own-marker gate in
updateStatus(:490-503) routes the reflectedSourceWatcherRecoveryForcedwrite (and Progressing-during-recovery) back through the recovery state machine instead of the clear/copy-through path, so the syntheticFalseno longer leaks onto the PackageSource and the attempt counter is preserved. Pinned byTestUpdateStatus_OwnMarkerHoldsTrackingViaStateMachine. - Counter reset — the
LastTransitionTime-keyed reset is gone; the only reset isclearRecoveryTrackingon the not-stuck path, so the give-up budget is reachable. Pinned byTestMaybeRecoverArtifactGenerator_DoesNotResetCounterOnFreshTransition.
Cross-checked the upstream anchors: the deployed pin is source-watcher:v2.1.0 (internal/fluxinstall/manifests/fluxcd.yaml:8230), and ArtifactGeneratorStatus inlines only ReconcileRequestStatus (no object-level ObservedGeneration), so keying stuck-detection on the condition-level generation is the only signal available and is correct.
Non-blocking follow-ups
-
Give-up preempts the final backoff window.
decideRecovery(:769-770) checksattempts >= maxRecoveryAttemptsbefore the elapsed/backoff computation. After the 5th force setsattempts=5and returnsRequeueAfter=backoffFor(5)=4m, the status+metadata patches immediately re-fireOwns()→updateStatus→isOwnMarker→decideRecovery(5, lastRecoveryAt≈now)→ GiveUp within milliseconds, preempting that 4-minute window. Effective time-to-give-up is30s+60s+2m+4m = ~7.5m, not the~11.5mdocumented at:50-53, and the final nudge gets no response window. Not install-breaking — it is still well inside the 15m HR timeout, and it self-heals if source-watcher recovers on the last nudge (Owns →Ready=Truecopy-through) — but the constants comment is inaccurate and the final attempt is effectively wasted. The give-up unit tests setlastRecoveryAtan hour in the past, so this exact path is untested. Fix: holdWaituntilnow - lastRecoveryAt >= backoffFor(maxRecoveryAttempts)before returning GiveUp, and add a test assertingWaitimmediately after the final force — or correct the comment to ~7.5m and accept the collapsed window. -
No envtest over the full
Reconcile → Owns-watch → updateStatuscycle. The unit tests driveupdateStatus/maybeRecoverArtifactGeneratordirectly and hand-simulate the re-fire, which is exactly why the timing gap in (1) slipped through — the give-up test bypasses the real re-fire. An envtest running the manager and asserting end-to-end recovery would cover the load-bearing re-entrancy and predicate interactions that unit tests structurally cannot. -
Version skew (note). The operator compiles against
api/v2 v2.0.3while the comments reference v2.1.0 controller internals (drift.go:52,controller.go:164/254). This is inherent to a workaround over the deployed binary, but keep the referenced line anchors in sync when the flux distribution bumps source-watcher, since the watch predicate andGetRequeueAfterare the load-bearing upstream assumptions.
Merge gate (not a code blocker)
E2E has not been shown green on this tip against a live source-watcher; the earlier run was on a revision predating the requestedAt-trigger and dormancy fixes. The two load-bearing mechanisms — the requestedAt trigger and the status-condition drift — need a green E2E before merge. This is separate from the code review.
Aleksei Sviridkin (lexfrei)
left a comment
There was a problem hiding this comment.
LGTM — the follow-up commit resolves both items from my previous review; re-affirming on this tip.
- Give-up now honors the final backoff window.
decideRecoveryholdsWaitin the exhaustion branch untilnow - lastRecoveryAt >= backoffFor(maxRecoveryAttempts), so the last force-drift gets its full ~4-minute response window before the driver surfacesSourceWatcherStalled— restoring the documented ~11.5m budget instead of the ~7.5m collapse. Covered by both aTestDecideRecoverytable case andTestUpdateStatus_OwnMarkerAttemptsExhaustedButFinalBackoffPending, which reproduces the exact Owns()-refire shape (attempts=max, lastRecoveryAt=now-ε) and asserts Wait rather than GiveUp. Verified locally:go test ./internal/operator/... -racepasses. - Version-skew note added to the constants block, flagging that the v2.1.0 line anchors track the deployed binary (not the
api/v2 v2.0.3module) and must be re-verified on a source-watcher bump.
Still open, both non-blocking and unchanged:
- No envtest over the full
Reconcile → Owns-watch → updateStatuscycle. The newupdateStatus-level test is closer, but it still hand-arranges the AG state rather than exercising the real manager watch + predicate. - E2E must be green on this tip against a live source-watcher before merge (the author's own gate). This is separate from the code review.
578b9aa to
66d39cf
Compare
…tus-patch stalls Symptom cozystack-platform HelmRelease times out at 15m on fresh install with a single PackageSource stuck in InProgress. Snapshot from #3178 CI run 28587656430: PackageSource cozystack.vm-disk-application Ready: Unknown, reason: Progressing lastTransitionTime: 2026-07-02T13:39:26Z (snapshot generated 30+ minutes later — no state change) ArtifactGenerator cozystack.vm-disk-application status.inventory: 2 items with sha256 digests status.observedSourcesDigest: populated status.conditions[Ready]: Unknown, reason: Progressing status.conditions[Reconciling]: True, reason: Progressing ExternalArtifacts are produced. Downstream Flux consumes them and its HelmReleases install fine. But PackageSourceReconciler faithfully copies the Unknown Ready condition through, so cozystack-platform (which waits on PackageSource.Ready=True) never converges. Root cause Traced in fluxcd/source-watcher:v2.1.0 log for the affected ArtifactGenerator: 13:39:28.475 ExternalArtifact vm-disk reconciled ✅ 13:39:28.641 ExternalArtifact vm-disk-rd reconciled ✅ 13:39:45.719 ❌ failed to update status: etcdserver: request timed out 13:39:45.720 ❌ Reconciler error (etcd timeout) 13:39:46.485 ✅ No drift detected, 2 artifact(s) up to date ——— nothing else for this ArtifactGenerator ——— Under first-install etcd pressure the patchStatus call times out. The reconciler returns the etcd error; controller-runtime prints the "returned both a result and non-nil error, RequeueAfter ignored" warning and requeues. The next reconcile hits the "No drift detected" short-circuit (artifacts are already up to date) and early-exits before touching status. Result: the Ready condition is stuck Unknown until either the process restarts or something forces a state change on the ArtifactGenerator. Neither happens in a real install run. This is an upstream bug in source-watcher's reconcile loop. It has been papered over once already at the runtime-tuning level in #3163 (source-watcher concurrency + CPU), but a bad etcd second during install still lands us here. Cozystack cannot afford to wait for an upstream release cycle to unblock every install. Fix PackageSourceReconciler.updateStatus now checks two invariants before falling back to copying the (potentially stuck) upstream Ready condition: 1. ArtifactGenerator.status.Inventory is non-empty 2. ArtifactGenerator.status.ObservedSourcesDigest is set Both fields are only written by source-watcher after the artifacts have actually been produced — status.Inventory carries the ExternalArtifact digests downstream Flux is already consuming. If BOTH are present AND the upstream Ready condition is still Unknown (or absent), the PackageSource Ready condition is synthesised as True with a distinctive reason `ArtifactsGeneratedAwaitingUpstreamStatus` and a message that names the upstream bug and the observable state it relied on. If the upstream Ready ever catches up (True or False) the Owns() watch re-fires the reconciler and the real condition overwrites the synthetic one — True stays True, False surfaces the real failure. Failures are NEVER masked: the synthesis path only triggers when Ready is Unknown or missing. Safety * Never synthesises True over a real False → upstream failure is surfaced unchanged. * Requires BOTH Inventory non-empty AND ObservedSourcesDigest set → a fresh ArtifactGenerator that has literally not started work cannot be prematurely marked Ready. * ObservedGeneration is stamped so a spec change re-invalidates the synthesis until the next observable-artifacts pass. * Reason string is greppable so operators can audit which PackageSources came up via the workaround. Tests Added TestArtifactGeneratorObservablyReady with 7 cases covering every branch of the predicate: - fresh generator: false - inventory only: false - digest only: false - both set + Unknown Ready: true (the actual bug) - both set + no Ready cond: true (same window pre-stamp) - both set + Ready=True: false (pass real condition) - both set + Ready=False: false (never mask failure) All pass; existing operator unit tests are unaffected. Signed-off-by: Ivan Okhotnikov <[email protected]>
…re synthesising
Own-audit found the first pass had a spec-update race: if a user
edited PackageSource.spec.variants on a running cluster, cozystack
would bump the derived ArtifactGenerator to Generation=2, and
between "source-watcher started reconciling gen=2" and "source-
watcher wrote new Inventory + ObservedSourcesDigest for gen=2" the
ArtifactGenerator still exposes the previous generation's
Inventory / ObservedSourcesDigest AND Ready=Unknown (Progressing).
The workaround would synthesise Ready=True on stale artifacts, and
downstream Flux would consume the previous-generation
ExternalArtifacts as if the new spec had converged.
Tighten the predicate to require the Ready condition's
ObservedGeneration to match ag.Generation. Only when source-watcher
has actually processed the current spec (which happens strictly
after it writes the current-generation Inventory + Digest) does the
Ready condition carry ObservedGeneration == ag.Generation. That is
exactly the window where Inventory / Digest are guaranteed current-
spec AND only the status patch on Ready itself failed — the actual
upstream bug this workaround exists to paper over.
Consequence: if Ready is missing entirely we return false now
(was true). We cannot verify ObservedGeneration on an absent
condition, and the missing-Ready state is indistinguishable from
mid-reconcile-of-a-newer-generation. On the next reconcile source-
watcher will write at least a Progressing condition, at which point
the current-spec Generation check will be evaluable and the
workaround will kick in if — and only if — Inventory / Digest also
match the current spec.
Tests updated:
* changed 'no Ready condition' case from want=true to want=false
(reflecting the tighter invariant);
* added a case where Ready=Unknown carries an OLD Generation
(want=false — do not synthesise on stale artifacts);
* stamped ObservedGeneration on every existing condition-bearing
case so they exercise the matching-Generation branch;
* introduced a small agWithGen helper to keep the fixtures
readable now that Generation is load-bearing.
All 8 subtests pass; existing operator suite unaffected.
Signed-off-by: Ivan Okhotnikov <[email protected]>
… review nits
kvaps and lexfrei confirmed the diagnosis by tracing source-watcher
v2.1.0 (same code paths in v2.2.x / flux-aio latest, so a simple
version bump does not remove the need for this workaround):
* fluxcd's patch.Helper patches .status and .status.conditions as
SEPARATE apiserver requests, so an etcd timeout during first-
install load can land between them — leaving Inventory /
ObservedSourcesDigest persisted while the Ready-condition write
is lost.
* On the next reconcile the !hasDrifted branch returns before
touching conditions. The deferred summarizeStatus still runs and
adjusts the Reconciling condition, but it never promotes a
stuck Unknown Ready to True — that promotion only happens on the
successful hasDrifted path.
Two substantive changes prompted by the reviews:
1. Document the semantic change the workaround introduces on
PackageSource.status.conditions[Ready]. fluxcd/pkg/runtime/
conditions.Set writes condition.ObservedGeneration =
ag.Generation on EVERY condition mutation including the
Progressing/Unknown mark at the start of a rebuild. And
ArtifactGeneratorStatus does not expose an object-level
ObservedGeneration (only ReconcileRequestStatus is inlined).
So Generation-matching cannot distinguish 'current revision
fully processed' from 'rebuild in progress, previous
Inventory/digest still persisted' — meaning the predicate can
(and does) fire during a content-only regeneration (new OCI
revision, same .metadata.generation).
Net effect: PackageSource Ready no longer dips through Unknown
while a new revision is being processed; it now means 'valid
consumable artifacts exist' rather than 'the current revision
has been processed'. Kvaps flagged this as the better contract
for install/upgrade ordering (no InProgress flapping, and a
genuine regeneration failure still surfaces as Ready=False on
the successful hasDrifted path). Document it inline instead of
implying the workaround only fires in the stuck-status window.
2. Reworked the predicate signature to take the pre-resolved
Ready condition as a parameter. The caller was already doing
the FindStatusCondition lookup for the pass-through path, so
the helper no longer duplicates it (kvaps' nit).
Also refreshed the pre-existing comments:
* The 'early-exits before touching status again' step is now
correctly worded as 'returns before touching conditions again;
the deferred summarizeStatus never promotes a stuck Unknown
Ready to True'.
* Bug attribution now names the code paths ('!hasDrifted branch',
'summarizeStatus deferred'), notes that v2.1.0 and v2.2.x
('flux-aio latest') both ship the affected code, and carries a
TODO(remove once upstream is fixed) marker so the workaround
cannot silently outlive its cause.
Tests: existing 8 subtests updated to construct the ready condition
manually so the call matches the new signature, plus one new
subtest locking in the intentional 'content-only regeneration
predicate fires' behaviour so a future refactor that tightens the
predicate back to 'current revision processed' — which would
re-introduce the flapping — fails loudly. 9/9 pass.
Signed-off-by: Ivan Okhotnikov <[email protected]>
…issue) Reviewer nit was to file an upstream issue so the workaround does not silently outlive its cause. Checked first — fluxcd/pkg already has one open: #934 \"patch.Helper creates race conditions in downstream controllers\" (May 2025). Same root cause we traced: patch.Helper writes .status and .status.conditions as separate apiserver requests, so an etcd blip between the two leaves the object half-persisted. The upstream issue is broader than just source-watcher — every fluxcd downstream controller consuming fluxcd/pkg's runtime patch helper is affected. Update the TODO(remove) marker on our workaround to point at that issue and note that both source-watcher v2.1.0 and v2.2.x (flux-aio latest) pull the same fluxcd/pkg runtime, so bumping source-watcher alone does not remove the need for this synthesis. Compile + unit tests unchanged. Signed-off-by: Ivan Okhotnikov <[email protected]>
…comment review-branch skill flagged that the comment claimed meta.SetStatusCondition rewrites LastTransitionTime on Status OR Reason change. Actually only Status change triggers a LastTransitionTime rewrite — Reason and Message are updated in place, LastTransitionTime is preserved. Outcome (repeated identical calls are no-op-y on persisted state) is unchanged; just tightening the technical claim to match apimachinery. Signed-off-by: Ivan Okhotnikov <[email protected]>
…of synthesising PackageSource Ready Replaces the Ready=True synthesis for PackageSources whose ArtifactGenerator hit the fluxcd/pkg#934 patch.Helper split-write race with a bounded retry driver: on detected stall, bump reconcile.fluxcd.io/requestedAt with exponential backoff (30s to 4m cap) up to 5 attempts, then surface as PackageSource Ready=False with reason SourceWatcherStalled. The previous approach could mask a genuine regeneration failure if the same patch.Helper race that the workaround exists for also swallowed a real Ready=False write, and it permanently reinterpreted PackageSource.Ready semantics from "current revision processed" to "valid consumable artifacts exist". Requeue keeps the CRD contract intact and, on stall exhaustion, produces an honest failure signal an operator can act on instead of a false-positive True. Retry state (attempts + last-bump timestamp) lives on the AG as cozystack.io/source-watcher-* annotations so it survives operator restarts. Signed-off-by: Ivan Okhotnikov <[email protected]>
…ches, tighten status Address self-review nits on the bounded-requeue driver: * Sample `now := time.Now()` once at Reconcile entry and thread it into `updateStatus`, `artifactGeneratorStuck` and `maybeRequeueArtifactGenerator` so every time-sensitive check inside a reconcile agrees on the same wall-clock reading. * Set `ObservedGeneration` in the three early Ready-condition branches (`SourceRefNotSet`, `ArtifactGeneratorNotFound`, `ArtifactGeneratorNotReady`) for parity with the retry-driver branches. * Patch the ArtifactGenerator via a deep copy in `bumpArtifactGeneratorRequeue` and `clearRequeueTracking` so a failed Patch no longer leaves the caller's in-memory `ag` desynchronised from the apiserver. * Document the retry driver's dependency on source-watcher's ReconcileRequestedPredicate on ArtifactGenerator, so a future upstream change dropping the predicate is easier to trace. Signed-off-by: Ivan Okhotnikov <[email protected]>
…ct, document HA + timing rationale Address second-pass self-review findings on the bounded-requeue driver: * Skip reconcile early when the PackageSource has a deletionTimestamp so the retry driver stops writing "AwaitingSourceWatcherRequeue" onto an object about to be garbage collected. ownerReference cascade handles the ArtifactGenerator cleanup. * Fix the mutation contract on bumpArtifactGeneratorRequeue and clearRequeueTracking: the previous deep-copy shape left the caller's `ag` desynchronised from the apiserver on success. Now mutations land on `ag` itself with a rollback to the pre-call annotation snapshot on Patch failure — so success reflects persisted state and failure does not leak unpersisted state to subsequent code paths. * Convert the "unreachable" default in maybeRequeueArtifactGenerator to an explicit panic so a future requeueAction added without a matching case fails loudly instead of freezing the Ready condition silently. * Document the timing constants' rationale (grace period matches the observed <10s source-watcher settle time on dev3; total ~11.5m budget keeps SourceWatcherStalled inside the 15m HR install timeout) and note the operator must run with leader election when replicas > 1 so the annotation-based retry state does not race. Signed-off-by: Ivan Okhotnikov <[email protected]>
…lper for the requeue driver Address the third-pass self-review's blocker: the mutation contract on bumpArtifactGeneratorRequeue / clearRequeueTracking, the DeletionTimestamp early-return in Reconcile, and the nil-preserving cloneAnnotations helper had no regression tests, so a future refactor could silently break the contracts documented in comments. New fake-client + interceptor-based tests: * TestCloneAnnotations — pins the "nil in / nil out" sentinel behaviour and mutation-independence of the shallow copy. * TestBumpArtifactGeneratorRequeue_Success — asserts the persisted state matches the caller's in-memory `ag.Annotations` on success, including that reconcile.fluxcd.io/requestedAt is actually written (previously never asserted anywhere). * TestBumpArtifactGeneratorRequeue_PatchFailure_Rollback and its PreservesUnrelated variant — drive the Patch-failure branch via an interceptor client and assert both that our tracking annotations are rolled back and that pre-existing unrelated annotations survive. * TestClearRequeueTracking_Noop — asserts we don't issue a no-op Patch on every healthy reconcile when there are no tracking annotations to clear. * TestClearRequeueTracking_PatchFailure_Rollback — same rollback contract as bump, on the clear path. * TestReconcile_DeletionTimestamp_EarlyReturn — asserts a PackageSource with a deletionTimestamp does not receive any writes and does not schedule a RequeueAfter. Doc tightening in the same commit: * Reword clearRequeueTracking's comment on the flux annotation semantics — standard flux controllers do not clear reconcile.fluxcd.io/requestedAt, they only advance .status.lastHandledReconcileAt via the ReconcileRequestedPredicate. * Note controller-runtime v0.15+'s default RecoverPanic behaviour where the retry-driver's unhandled-action panic is raised, so a future SetupWithManager flip that disables it is easier to spot. * Explain why cloneAnnotations' shallow copy is sound (annotation values are immutable strings). Signed-off-by: Ivan Okhotnikov <[email protected]>
Address the fourth-pass self-review's non-blocking NITs: * Add TestBumpArtifactGeneratorRequeue_PatchFailure_RollbackRestoresPriorBump to cover the subtle case the two existing rollback tests miss: when the tracking annotations were already set from a prior bump, a failed Patch must restore the PRIOR values, not delete the keys. A refactor that naively swapped rollback for `delete(ag.Annotations, key)` would pass the nil-rollback tests but silently regress this contract. * Rename the cloneAnnotations subtest from "deep copy" to "independent copy" — the code documents shallow copy semantics and the misnomer was the only thing pointing readers at the wrong mental model. * Hoist `now.UTC().Format(time.RFC3339Nano)` to a local so the two timestamp annotations use one formatted value instead of two identical Format calls. Signed-off-by: Ivan Okhotnikov <[email protected]>
…ue bump Rework the fluxcd/pkg#934 recovery driver after myasnikovdaniil's B1 finding: bumping reconcile.fluxcd.io/requestedAt cannot recover a stuck ArtifactGenerator because source-watcher v2.1.0's reconcile is drift-gated. detectDrift (artifactgenerator_drift.go:52) returns NoDriftDetected for exactly the stuck signature we target — Inventory populated, ObservedSourcesDigest set, Ready=Unknown on the current generation — so the ReconcileRequestedPredicate fires a reconcile that lands on the no-drift early-return at controller.go:164 and MarkTrue at :254 is never reached. Every attempt in the previous shape was a no-op. New mechanism: force drift by patching Ready=False on the AG's status subresource. detectDrift picks this up as NotReady drift, source-watcher takes the drifted branch, and either MarkTrue (on healthy rebuild) or MarkFalse with a real reason (on genuine failure) replaces our synthetic False. Owns() watch fires this reconciler to copy the resolved condition through. Same bounded-retry harness (30s / 60s / 2m / 4m / 4m budget inside the 15m HR install timeout, SourceWatcherStalled surfaced on exhaustion), same annotation-based state on the AG, same rollback contract on Patch failure — only the action itself changed. Additional fixes bundled in: * Reset attempt counter when the AG's Ready.LastTransitionTime is newer than our lastRecoveryAt, so a fresh stall episode after a previous give-up gets a fresh budget rather than an immediate re-give-up (myasnikovdaniil's follow-up #3). * Propagate updateStatus errors from Reconcile instead of swallowing them, so controller-runtime's exponential backoff retries transient failures on force-drift / status-update writes rather than dropping them silently (coderabbit's inline finding). * forceArtifactGeneratorDrift documents the two-subresource shape (status patch first, metadata patch second) and the partial-failure semantics. * New tests cover the status-patch success / status-patch failure / metadata-patch failure paths, all with fake client + interceptor. The successful case asserts Ready=False actually lands in the apiserver via WithStatusSubresource. * All annotation / action / decision / helper names renamed from *Requeue* to *Recovery* to match the new semantics. Signed-off-by: Ivan Okhotnikov <[email protected]>
…ait assertions, polish comments
Address the fourth-pass self-review's non-blocking findings:
* Add TestMaybeRecoverArtifactGenerator_{ForceBranch, WaitBranch,
GiveUpBranch, ResetsCounterOnFreshTransition}. The three switch
outcomes of the driver (force / wait / give-up) and the reset-counter
guard on a fresh Ready.LastTransitionTime transition were previously
only covered indirectly through their pure-function components. Now
each outcome is asserted end-to-end against a fake client with a
status subresource, verifying the Ready condition, tracking
annotations, RequeueAfter, and — for the wait branch — that no AG
writes are issued at all.
* Tighten TestDecideRecovery's wait-branch assertions from a loose lower
bound to exact equality. decideRecovery is deterministic
(needed - elapsed) so an exact assertion catches subtle backoff
regressions a lower bound would miss (coderabbit's inline nit).
* Rename the local var `wantWaitMin` to `wantWait` for the tighter
assertion shape.
* Fix two stale identifier references in comments
(maybeRequeueArtifactGenerator, AwaitingSourceWatcherRequeue) left
over from the rename pass.
* Add a docstring paragraph on forceArtifactGeneratorDrift explaining
why the JSON-merge-patch full-array-replace on
`.status.conditions[]` is net-neutral in this workaround's context.
Signed-off-by: Ivan Okhotnikov <[email protected]>
…hin grace E2E on dev3 caught a dormancy bug that renders the whole recovery driver inactive in practice: when updateStatus copies Ready=Unknown across but the AG's LastTransitionTime is still within stuckGracePeriod, artifactGeneratorStuck returns false and we fall through to the copy path with no RequeueAfter set. If the reconcile happens to fire in the [stuck_start, stuck_start+30s) window — a routine case since operator restarts touch every PackageSource — the controller then sleeps waiting for an AG-status change that will never come (that IS the fluxcd/pkg#934 lost-write race), up to the 10h informer resync. Observed on dev3: `cozystack.networking` stuck at Ready=Unknown for 9+ minutes with zero recovery attempts. Two reconciles fired at stuck_start+23s and +24s — both correctly detected "not yet stuck" — and then nothing until observation ended. Upstream OCIRepository revision was unchanged throughout, ruling out a coincidental source update as the cause. Fix: whenever we copy Ready=Unknown to the PackageSource (or set a synthetic Unknown because no Ready condition exists yet), compute the remaining grace period against the AG's LastTransitionTime (or CreationTimestamp for the missing-Ready branch) and schedule a RequeueAfter at that expiry. A 1s floor prevents a zero-delay tight loop when the anchor is right at or past grace expiry. Extracted the delay computation to `agFollowUpDelay` for direct unit testing. Adds three tests: the follow-up scheduling on Ready=Unknown in-grace, the same on the missing-Ready branch, and a table test on agFollowUpDelay covering the floor edge cases. Signed-off-by: Ivan Okhotnikov <[email protected]>
…remove reset-counter loop
Address lexfrei's three blockers on the force-drift approach — as
committed, the recovery driver did not function on a live cluster.
**B1**: `forceArtifactGeneratorDrift` wrote `Ready=False` on the AG's
status subresource but did not touch its metadata annotations.
source-watcher v2.1.0 registers its ArtifactGenerator watch with
`predicate.Or(GenerationChangedPredicate, ReconcileRequestedPredicate)`
— a status-only patch changes neither the generation nor
`reconcile.fluxcd.io/requestedAt`, so it does NOT enqueue a
source-watcher reconcile. The AG's periodic self-requeue is hardcoded
to `time.Hour`, well outside the 15m HR install timeout. Fix: also
stamp `reconcile.fluxcd.io/requestedAt` in the metadata patch so the
ReconcileRequestedPredicate fires and source-watcher's next reconcile
picks up the `IsFalse(Ready)` drift signal.
**B2**: `Owns(&ArtifactGenerator{})` re-fires this reconciler on the
force-drift's own writes. Both the status patch (Ready=False, reason
`SourceWatcherRecoveryForced`) and the metadata patch enqueue a
PackageSource reconcile. Blindly running `clearRecoveryTracking` on
those re-entrant fires nuked the attempts counter — the give-up
budget was unreachable, and the synthetic marker leaked to
`PackageSource.status.conditions[Ready]`. Fix: in `updateStatus`,
detect our own reason and route into a new `awaitSourceWatcherResponse`
helper that holds tracking and reports Unknown /
AwaitingSourceWatcherRecovery on the PS. The same helper covers the
Progressing/Unknown write source-watcher issues during rebuild once
tracking is present (attempts > 0) — otherwise the transient
Progressing would trigger the same premature tracking clear.
**B3**: The attempt-counter reset keyed on
`Ready.LastTransitionTime > lastRecoveryAt`. That inequality is true
on every source-watcher response during recovery (the Progressing
write is always newer than our force), so under the very race this
driver targets the counter would reset every cycle and
`recoveryActionGiveUp` was unreachable. Fix: remove the reset. The
canonical counter reset is the natural `clearRecoveryTracking` on the
not-stuck path (Ready=True from source-watcher, or a real upstream
Ready=False), and the HA/leader-election requirement already
contains the "missed recovery event on operator restart" edge case
the reset was trying to paper over.
Extract `reasonRecoveryForced` to a named constant so both the
writer (forceArtifactGeneratorDrift) and the reader (updateStatus'
own-marker filter) use the same symbol.
New tests pin all three fixes:
- ForceArtifactGeneratorDrift_Success now asserts the
reconcile.fluxcd.io/requestedAt annotation is set (B1).
- UpdateStatus_OwnMarkerRoutesToAwait asserts we do NOT clear
tracking and do NOT copy the synthetic Ready=False through when we
see our own marker (B2).
- UpdateStatus_ProgressingDuringRecoveryRoutesToAwait covers the
second Owns() re-entrancy path — source-watcher's Progressing
write during rebuild (B2).
- UpdateStatus_ReadyTrueClearsTracking pins that the natural not-
stuck path IS the canonical counter reset after B3.
- The old ResetsCounterOnFreshTransition test is replaced with
DoesNotResetCounterOnFreshTransition, asserting the opposite
behaviour that closes B3.
Signed-off-by: Ivan Okhotnikov <[email protected]>
…rce-watcher-unresponsive dormancy loop Self-review on 467b788 caught a MAJOR bug introduced by the separate awaitSourceWatcherResponse helper: if source-watcher never responds to a forced drift (network partition, missing predicate, dead pod), our own reasonRecoveryForced marker keeps reflecting back via Owns() forever, and the wait-forever helper schedules a 1-second requeue tight loop without ever advancing the retry state machine. SourceWatcherStalled give-up was unreachable. Fix: remove awaitSourceWatcherResponse and unify all three recovery entry conditions — our own marker, source-watcher's Progressing during rebuild, and the classic first-time stuck detection — through a single gate that routes to maybeRecoverArtifactGenerator. The state machine's decideRecovery is the single arbiter of wait/force/give-up, and its Wait branch is bounded by the same backoff schedule as Force. When backoff elapses on a still-reflecting own-marker the machine escalates to another force (bumps attempts, re-stamps requestedAt); when attempts exhaust it surfaces SourceWatcherStalled honestly. The own-marker filter also no longer requires attempts>0, so an operator crash between step 1 (status patch) and step 2 (metadata patch) of forceArtifactGeneratorDrift is still routed through the state machine on the next reconcile rather than leaking the synthetic marker onto the PackageSource. Also address the non-blocking notes from the same self-review: * Docstring on maybeRecoverArtifactGenerator was still describing the removed reset-counter branch and the "requestedAt alone is a no-op" framing. Rewrite to explain the current shape: status write triggers the drift signal, requestedAt bump triggers the reconcile event — both are required, neither alone works. * TestForceArtifactGeneratorDrift_MetadataPatchFailure_RollbackAnnotations now asserts reconcile.fluxcd.io/requestedAt is also rolled back on the metadata-patch failure branch (symmetric with the tracking annotations). * TestUpdateStatus_OwnMarkerRoutesToAwait asserts an exact 25s RequeueAfter (backoffFor(1)=30s minus 5s elapsed) so a silent regression in the backoff arithmetic is caught. New regression tests for the source-watcher-unresponsive path: * TestUpdateStatus_OwnMarkerStale_TriggersRetry: with attempts=1 and lastRecoveryAt older than backoffFor(1), the reconcile must escalate through the Force branch and bump attempts to 2 rather than idle in a tight requeue loop. * TestUpdateStatus_OwnMarkerAttemptsExhausted_GivesUp: with attempts=maxRecoveryAttempts and the marker still reflecting back, the reconcile must set PackageSource Ready=False / SourceWatcherStalled instead of extending the loop indefinitely. Signed-off-by: Ivan Okhotnikov <[email protected]>
…m comments and test names Self-review after the previous commit removed the awaitSourceWatcherResponse helper (routing unified through maybeRecoverArtifactGenerator's state machine via a single own-marker / progressing-in-recovery / stuck gate). Four comments and two test names still referenced the now-deleted symbol — misleading anyone tracing the architecture. * maybeRecoverArtifactGenerator docstring: rewrote the trade-off paragraph to describe the unified gate and Wait branch instead of the removed helper. * TestMaybeRecoverArtifactGenerator_DoesNotResetCounterOnFreshTransition comment: same rewrite. * TestUpdateStatus_OwnMarkerRoutesToAwait renamed to TestUpdateStatus_OwnMarkerHoldsTrackingViaStateMachine + comment rewritten to describe the state-machine Wait branch. * TestUpdateStatus_ProgressingDuringRecoveryRoutesToAwait renamed to TestUpdateStatus_ProgressingDuringRecoveryHoldsTracking + comment rewritten in parallel. Pure doc / naming; no code or test-body changes. Tests still pass with -race, vet clean, golangci-lint 0 issues. Signed-off-by: Ivan Okhotnikov <[email protected]>
…OG check, warn on partial force-drift Self-review nits raised in a skeptical pass over the current tip: * The status-patch docstring in forceArtifactGeneratorDrift described the JSON-merge-patch full-array-replace on `status.conditions[]` as "net-neutral". That was too optimistic: if source-watcher writes a concurrent Reconciling condition between our Get and our Patch, our patch does transiently wipe it. source-watcher restores it on its next reconcile (which our requestedAt bump is about to trigger), but downstream tooling watching for Reconciling can observe the momentary absence. Rewrite the comment to name that trade-off explicitly and point at server-side apply as the fuller fix if the operator ever migrates to SSA globally. * The unified own-marker/progressing/stuck gate in updateStatus is asymmetric on ObservedGeneration — isProgressingInRecovery checks it, isOwnMarker does not. That asymmetry is deliberate (a stale own-marker after a spec edit must still route to the state machine to avoid leaking the synthetic reason to the PackageSource — exactly the B2 regression), not an oversight. Add a comment explaining the reasoning so a future edit doesn't "symmetrise" the check and re-open B2. * forceArtifactGeneratorDrift's step-2 (metadata patch) failure was silent when it happened after a successful step-1 status patch. In that state source-watcher has been nudged, but the tracking annotations never persisted, so the next reconcile reads attempts=0 and re-forces from scratch instead of accumulating toward SourceWatcherStalled — a persistent metadata-write-only apiserver malfunction would loop forever. Log the failure loudly with the intended attempt count so operators can spot this specific mode from cozystack-operator logs. Signed-off-by: Ivan Okhotnikov <[email protected]>
…m version skew Address lexfrei's non-blocking follow-ups on the LGTM approval: Follow-up #1 — give-up preempted the final backoff. `decideRecovery` returned `recoveryActionGiveUp` as soon as `attempts >= maxRecoveryAttempts`, without checking whether the final-force backoff window had elapsed. The Owns()-watch re-fire from the N-th force-drift's own writes triggered a reconcile milliseconds after the force, so the effective time-to-give-up was ~7.5m (30s+60s+2m+4m) — not the ~11.5m documented on the constants block. The final 4-minute response window the last nudge was supposed to grant source-watcher was never actually granted. Fix: extend the exhaustion branch to hold `Wait` until `now - lastRecoveryAt >= backoffFor(maxRecoveryAttempts)` before switching to GiveUp. Now the documented ~11.5m budget matches the observed behaviour, and the last force gets a real chance to succeed (Owns watch → source-watcher Ready=True copy-through) before we surface SourceWatcherStalled. Tests: * `TestDecideRecovery` gains a case pinning `Wait` when attempts=maxRecoveryAttempts but the final backoff has not yet elapsed (the exact re-fire shape the previous behaviour tripped on). * `TestUpdateStatus_OwnMarkerAttemptsExhaustedButFinalBackoffPending` drives the full updateStatus path against the same shape, asserting the PackageSource stays in Unknown/AwaitingSourceWatcherRecovery and the RequeueAfter equals the remaining backoff, NOT zero (GiveUp). Follow-up #3 — upstream version-skew note. The Go module dependency tracks `fluxcd/source-watcher/api/v2 v2.0.3` (CRD types package only), but the deployed binary this workaround targets is pinned to v2.1.0 at `internal/fluxinstall/manifests/fluxcd.yaml`. Comments referencing `drift.go:52` / `controller.go:164/254` describe the deployed binary, not the compiled API package. Documented on the constants block so a future flux bump re-verifies the anchors AND the watch predicate / drift-detection heuristics, which are the load-bearing upstream assumptions. Follow-up #2 (envtest over full Reconcile→Owns→updateStatus cycle) is architectural and out of scope for this PR — noted for a follow-up. Signed-off-by: Ivan Okhotnikov <[email protected]>
66d39cf to
0d98e27
Compare
myasnikovdaniil
left a comment
There was a problem hiding this comment.
LGTM. Re-reviewed at 0d98e279 — my earlier blocker is resolved: the unified own-marker gate in updateStatus routes the reflected SourceWatcherRecoveryForced write into the recovery state machine instead of copying it onto the PackageSource, so the synthetic Ready=False no longer leaks and the SourceWatcherStalled give-up is reachable again. B1 (force-drift writes both the status condition and requestedAt) and B2 (give-up path now tested) are addressed, and the integrated updateStatus recovery cycle is covered by fake-client tests. Unit + E2E green on this head.
Non-blocking nits, fine to defer: the unconditional Status().Update in the wait/give-up branches (Gemini's note), and the lexfrei's review on PR #3182 / B2 shorthand in the comments will read as orphaned context once merged — trim to the technical rationale.
Summary
cozystack-platformHelmRelease times out at 15m on fresh install when aPackageSource's derived ArtifactGenerator stalls in
Ready: Unknowneventhough its ExternalArtifacts are produced and consumed successfully.
Root cause: fluxcd source-watcher patches
.statusand.status.conditionsas separate apiserver requests viapatch.Helper,so an etcd timeout under first-install load can leave
Inventory+ObservedSourcesDigestpersisted while the Ready-condition write islost. Upstream tracker:
fluxcd/pkg#934 —
patch.Helpercreates race conditions in downstreamcontrollers.
The bug is present in the currently-deployed v2.1.0 (pinned at
internal/fluxinstall/manifests/fluxcd.yaml:8230), and a source-watcherversion bump alone does not fix it because the race lives in the shared
fluxcd/pkgruntime.What this PR does
PackageSourceReconcilernow detects the stuck ArtifactGeneratorsignature (Inventory populated, ObservedSourcesDigest set, Ready=Unknown
on the current generation, LastTransitionTime older than a 30s grace
period) and forces source-watcher onto its drifted-branch reconcile by
writing both signals it keys on:
Ready=Falseon the AG's status subresource. source-watcher'sdetectDrift(v2.1.0artifactgenerator_drift.go:52) treatsIsFalse(Ready)asNotReadydrift, promoting the reconcile pastthe no-drift early-return at
artifactgenerator_controller.go:164.reconcile.fluxcd.io/requestedAtbump on the AG's metadata.source-watcher's ArtifactGenerator watch is registered with
predicate.Or(GenerationChangedPredicate, ReconcileRequestedPredicate)— the annotation is what enqueues the reconcile in the first place.
Without it a status-only change never fires the watch and the AG
would sit until the periodic self-requeue (hardcoded to 1h in
GetRequeueAfter), well outside the 15m HR install timeout.Both signals are load-bearing — either alone is a no-op. The status
write triggers the drifted branch inside source-watcher's reconcile;
the annotation write triggers the reconcile that inspects that drift.
On successful rebuild source-watcher writes
Ready=Trueatcontroller.go:254; on a genuine rebuild failure it writesReady=Falsewith a real reason. Either way an honest condition fromupstream replaces our synthetic False, and the
Owns(&ArtifactGenerator{})watch fires this reconciler to copy theresolved condition onto the PackageSource.
Bounded-retry harness sizes the total budget at ~11.5m
(
30s + 60s + 2m + 4m + 4m) safely inside the 15m HR install timeout;after
maxRecoveryAttemptsfruitless force-drift attempts the driversurfaces
PackageSource.Ready=False, reason=SourceWatcherStalledso anoperator can intervene, rather than lying about the state.
Retry state (attempt count + last-force timestamp) lives on the AG as
cozystack.io/source-watcher-recovery-*annotations so it survivesoperator restarts. Counter reset is via the canonical
clearRecoveryTrackingon the not-stuck path (Ready=True fromsource-watcher, or Ready=False with an upstream reason), NOT keyed on
LastTransitionTime— source-watcher's Progressing write duringrebuild always advances that timestamp, and a heuristic reset there
would make
SourceWatcherStalledunreachable under the very race thisdriver targets.
Owns()-re-entrancy handling
Owns(&ArtifactGenerator{})re-fires this reconciler on theforce-drift's own writes (both the status patch and the metadata
patch). A unified gate in
updateStatusroutes three overlappingsignals into the same state machine:
Ready.ReasoncarriesSourceWatcherRecoveryForced(written only byforceArtifactGeneratorDrift).the current generation, tracking annotations already present.
patch.Helpercreates race conditions in downstream controllers fluxcd/pkg#934 signaturepast the grace period.
All three route to
maybeRecoverArtifactGenerator, whosedecideRecoverystate machine is the single arbiter of wait vsre-force vs give-up. The alternative — a separate "wait for
source-watcher" helper — was tried and rejected: if source-watcher
never responds (missing predicate, network partition, dead pod), a
wait-only path tight-loops without ever advancing to
SourceWatcherStalled. The unified gate lets the state machine's own
backoff schedule bound both the wait and the eventual give-up.
Contract
PackageSource.status.conditions[Ready]contract is unchanged:Truestill means "source-watcher processed the current revision"(copied through from AG on the fast path).
Falsefrom a genuine upstream failure still passes through unchanged.False, reason=SourceWatcherStalledis new — surfaces aftermaxRecoveryAttemptsfruitless force-drift attempts, an honestoperator-actionable signal rather than a false-positive True.
Ready=Unknown, reason=AwaitingSourceWatcherRecoverydescribes what the operator iswaiting on.
SourceWatcherRecoveryForcedmarker is anoperator-internal signal on the AG only; it never leaks to the
PackageSource.
Retirement
Once
fluxcd/pkg#934lands and source-watcher consumes apatch.Helperthat either serialises or transactionally combines the
.status/.status.conditionswrites, this whole recovery driver can be deletedand
updateStatuscan copy the AG's Ready condition throughunconditionally. A
TODO(remove once fluxcd/pkg#934 lands and is rolled out)marker on the driver points at the upstream issue.HA note
The recovery driver stores its state as annotations on the
ArtifactGenerator, so it races on write when two cozystack-operator
replicas both see the same stuck target. Deploy the operator with
--leader-elect=true(orreplicas: 1, which is the shipped default inpackages/core/installer/templates/cozystack-operator.yaml).Testing
Unit tests cover: stuck-detection predicate with grace-period cases,
bounded-retry state machine (Force / Wait / GiveUp branches), backoff
schedule, tracking-annotation parser hardening, mutation contract on
the two-subresource force-drift shape (success, status-patch failure
rollback, metadata-patch failure with Ready=False persisted and
annotations rolled back, rollback-restores-prior-bump), no-op
clear-tracking, Reconcile early-return on DeletionTimestamp, follow-up
scheduling on Unknown-within-grace to avoid the 10h informer resync
dormancy, Owns()-re-entrancy for both our own marker and Progressing
during recovery, canonical reset on Ready=True, and the
source-watcher-unresponsive path (stale own-marker triggers retry
through Force, exhaustion surfaces as SourceWatcherStalled). All pass
with
-race;go vetandgolangci-lintclean.E2E on dev3 — pending on the current tip. An earlier e2e ran on
97b054bbb, which observed a stuck AG recovering after our force-driftbut also had a dormancy bug (fixed since) and the requestedAt bump
missing (fixed since). Both of the current shape's load-bearing
mechanisms — requestedAt trigger + status-condition drift — need
verification against a live source-watcher on a fresh e2e run before
merge.