Skip to content

fix: recreate discovery containers when their owner's spec changes (OP-358) - #2752

Closed
caspx wants to merge 1 commit into
mainfrom
op-358-discovery-propagation
Closed

fix: recreate discovery containers when their owner's spec changes (OP-358)#2752
caspx wants to merge 1 commit into
mainfrom
op-358-discovery-propagation

Conversation

@caspx

@caspx caspx commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Discovery WekaContainers now track their owner's spec — a stale (e.g. wrong-image) weka-dsc-* container is deleted and recreated instead of being reused forever.

What changed?

  • DiscoverNodeOperation.EnsureContainers no longer early-returns on an existing discovery container: if the container's spec (image, imagePullSecret, tolerations, serviceAccountName) no longer matches the owner-derived desired spec, it is deleted and recreated on a subsequent pass (delete + WaitError, never
    same-pass create, since the old object may still be terminating).
  • Drift is enforced only by the container's controller-owner (metav1.IsControlledBy) — weka-dsc-<node> is a shared per-node singleton, and without this gate two owners with different specs (e.g. a client container and a drivers-builder) delete each other's container in a ~2s loop.
  • Unit tests for the drift/owner/terminating/create branches of EnsureContainers.

Why make this change?

Discovery containers were created once and never reconciled against the current owner spec, so after an image fix they kept the broken image indefinitely - Fixes OP-358.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

caspx commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

How to use the Graphite Merge Queue

Add the label main-merge-queue to this PR to add it to the merge queue.

You must have a Graphite account in order to use the merge queue. Sign up using this link.

An organization admin has required the Graphite Merge Queue in this repository.

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

This stack of pull requests is managed by Graphite. Learn more about stacking.

@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Reviewing PR #2752

  • Read PR diff
  • Read full discover_node.go context
  • Verify NormalizeTolerations / DeleteContainers semantics
  • Run build + tests
  • Post review

View job run · branch op-358-discovery-propagation

@caspx
caspx marked this pull request as ready for review August 18, 2026 10:03
@caspx
caspx requested a review from a team as a code owner August 18, 2026 10:03
Copilot AI lite review requested due to automatic review settings August 18, 2026 10:03
@claude

claude Bot commented Aug 18, 2026

Copy link
Copy Markdown

Claude finished @caspx's task in 4m 48s —— View job


Review: PR #2752 — discovery container spec drift

  • Read PR diff
  • Read full discover_node.go context + call sites
  • Verify DeleteContainers / step-ordering semantics
  • Check for spec-mutation sources that could cause recreate thrash
  • Run build + testsblocked, see note below
  • Post review

Verdict

The core fix is sound and the diagnosis is right. I traced the failure mode and it holds up: with a broken image the container never sets Status.ExecutionResult, so PollResults returns a WaitError forever, DeleteOnFinish never runs, and the node annotation is never written — so FinishOnExistingInfo (the only pre-existing path that deletes weka-dsc-*) is unreachable. The container is pinned on the bad image permanently. Delete + WaitError instead of a same-pass Create is the correct shape given the name is a fixed per-node key.

I also checked the thrash risk specifically — nothing in the repo mutates a discovery container's Spec.Image/Tolerations/ServiceAccountName after creation (the align loops in client_reconciler_loop.go and funcs_upgrade.go operate on cluster/client containers), and o.tolerations is compared against the same unexpanded value used to create the container. So there's no defaulting-vs-desired oscillation. Good.

5 inline comments posted. Nothing blocking; the delete race is the one I'd fix before merge.

Findings

# Severity Issue
1 Medium Drift delete isn't UID-guarded — can delete a container a peer owner just recreated
2 Low No logging on any of the three new branches, incl. the silent foreign-owner bail
3 Low isContainerSpecChanged omits Labels, which EnsureContainers does write
4 Nit k8sutil import out of alphabetical order (goimports/gci will flag)
5 Nit &lifecycle.WaitError{} vs the repo's var waitErr *lifecycle.WaitError idiom

Plus test-coverage gaps (no end-to-end recreate assertion; tests use *weka.WekaClient as owner where production always passes *weka.WekaContainer).

On the IsControlledBy gate

The reasoning in the PR body is correct — without it you get mutual deletion — but it's worth being explicit that it makes the fix best-effort in multi-owner namespaces, because the contention is broader than the description suggests. NewDiscoverNodeOperation has exactly one call site (funcs_getters.go:365, passing r.container), so every WekaContainer scheduled on a node is a candidate owner of the same weka-dsc-<node>. Whichever one wins the create race owns it; the rest defer forever.

Concretely: owner A detects drift and deletes, then B (racing) recreates with B's spec and becomes the controller-owner. A now fails IsControlledBy and silently accepts B's container — including B's image. This does not loop (the gate works), and it's benign in practice because the discovery payload is node hardware facts that don't depend on the image. But it does mean a stale weka-dsc-* can survive if its owner isn't the one reconciling. Owner-ref GC covers the deleted-owner case. I'd just add this to the comment at lines 190-191 so the next reader doesn't assume drift enforcement is guaranteed.

Behaviour change worth a second look

The DeletionTimestamp check sits before the ownership check, so a foreign-owned terminating container now blocks you with a WaitError where it previously fell through to PollResults and could read the existing ExecutionResult. Arguably more correct — you can't create a same-named object while the old one is terminating — and mostly moot since UpdateNodes writes the node annotation before DeleteOnFinish, so other owners hit the cache in GetNode. Calling it out only because it's an unflagged semantic change; if a weka-dsc-* finalizer ever wedges, every owner on that node now blocks instead of degrading gracefully.

Adjacent pre-existing issue

GetContainer (lines 166-172) swallows non-NotFound errors:

if err != nil && apierrors.IsNotFound(err) {
    return nil
}
if container != nil { o.container = container }
return nil

A transient API error leaves o.container == nil, so EnsureContainers skips the new drift logic entirely and goes to Create, which then fails AlreadyExists. Not introduced here and severity is unchanged by this PR, but CLAUDE.md says "Never swallow errors unless explicitly asked for" and this PR is hardening precisely this path — reasonable to fix in the same change or file a follow-up.

Other checks

  • Security: nothing — no new privileges, no user-controlled input, no secret handling. ImagePullSecret is compared by name only.
  • Performance: negligible. reflect.DeepEqual over two small toleration slices, once per reconcile, only when the container exists. The delete path converges in one extra reconcile.
  • .ainav: correctly updated per repo convention.

⚠️ Could not build or test

pkg/weka-k8s-api and pkg/go-steps-engine are replace-directive submodules and are empty in this checkout, so go build / go vet / go test cannot resolve k8sutil.NormalizeTolerations or lifecycle.WaitError. My review is static.

Two things I could not verify and that CI should confirm:

  • k8sutil.NormalizeTolerations treats nil and []corev1.Toleration{} as equal — the "nil vs empty tolerations is not drift" test asserts this. If it doesn't normalize empty→nil, that test fails and an owner with no tolerations would delete/recreate on every pass. Existing callers (client_reconciler_loop.go:753, funcs_upgrade.go:316) use it the same way, so this is very likely fine.
  • lifecycle.NewWaitError returns a *lifecycle.WaitError (required by both errors.As assertions).

If you want me to re-check once submodules are initialized, or apply the UID-precondition fix, just say the word.
· branch op-358-discovery-propagation

@graphite-app

graphite-app Bot commented Aug 18, 2026

Copy link
Copy Markdown

Graphite Automations

"Add anton/matt/sergey/kristina as reviwers on operator PRs" took an action on this PR • (08/18/26)

3 reviewers were added to this PR based on Anton Bykov's automation.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves reconciliation of per-node discovery WekaContainer objects by detecting owner-spec drift and triggering a delete/recreate cycle, preventing stale weka-dsc-* containers (e.g., with old images) from being reused indefinitely.

Changes:

  • Add spec-drift detection (image, imagePullSecret, tolerations, serviceAccountName) for existing discovery containers and recreate when drift is detected (delete + WaitError, no same-pass create).
  • Gate drift enforcement to only the controller-owner (metav1.IsControlledBy) to avoid multiple owners fighting over the shared per-node singleton.
  • Add unit tests covering matching/no-op, spec drift delete+wait, foreign-owner no-op, terminating wait, and missing-create paths.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
internal/controllers/operations/discover_node.go Detects drift on existing discovery containers and coordinates safe delete/recreate behavior with owner gating.
internal/controllers/operations/discover_node_test.go Adds unit tests for drift/ownership/termination/missing-container branches in EnsureContainers.
.ainav/operations/index.md Updates navigation docs to reflect new drift/recreate behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +198 to +199
// recreate on a later pass — a same-name Create would fail while the old container is terminating
if err := o.DeleteContainers(ctx); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delete is not UID-guarded — can delete a container someone else just recreated.

DeleteContainers does a bare o.client.Delete(ctx, o.container). o.container was read back in the GetContainer step, and between then and here another owner on the same node can legitimately delete + recreate weka-dsc-<node> (that is exactly the flow this PR introduces on the drift path). We'd then delete the new, correctly-specced object, and the peer owner — which is now the controller-owner — would just re-create it. Net effect is an extra discovery restart and a wasted reconcile cycle.

Since the object was read at a known ResourceVersion/UID, the delete can be made a no-op when the object underneath changed:

if err := o.client.Delete(ctx, o.container, client.Preconditions{UID: &o.container.UID}); err != nil && !apierrors.IsNotFound(err) && !apierrors.IsConflict(err) {
    return err
}

Simplest version: add a UID-preconditioned delete here rather than changing DeleteContainers, since the other two call sites (FinishOnExistingInfo, DeleteOnFinish) genuinely want an unconditional cleanup.

Fix this →

Comment on lines 186 to 203
if o.container != nil {
return nil
if o.container.GetDeletionTimestamp() != nil {
return lifecycle.NewWaitError(fmt.Errorf("discovery container %s is being deleted", o.container.Name))
}
// the discovery container is a shared per-node singleton; only its controller-owner
// enforces spec drift, otherwise owners with different specs delete each other's container in a loop
if !metav1.IsControlledBy(o.container, o.ownerRef) {
return nil
}
if !o.isContainerSpecChanged() {
return nil
}
// recreate on a later pass — a same-name Create would fail while the old container is terminating
if err := o.DeleteContainers(ctx); err != nil {
return err
}
return lifecycle.NewWaitError(fmt.Errorf("discovery container spec changed, recreating"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No logging on any of the three new branches. The rest of this file uses instrumentation.CurrentSpanLogger(ctx) (see GetNode, Enrich), and deleting a shared per-node singleton is exactly the kind of action you want in a trace when debugging why discovery restarted. Two spots in particular:

  • The drift delete — log the specific field(s) that differ (old vs new image is the OP-358 signal).
  • The !metav1.IsControlledBy bail — this is the branch where a foreign-owned stale container is silently accepted forever. Without a log line, the OP-358 symptom (weka-dsc-* stuck on a broken image) reappears with zero diagnostic, because the owner that could fix it never runs and the owner that notices says nothing.

A logger.Debug/Info on each keeps the multi-owner deferral observable.

Related: isContainerSpecChanged returns a bare bool, so the caller has no way to say what drifted. Returning (bool, string) or a small []string of changed fields would make both the log message and the WaitError text actionable.

Comment on lines +175 to +176
// isContainerSpecChanged reports whether the existing discovery container no longer matches the owner-derived spec
func (o *DiscoverNodeOperation) isContainerSpecChanged() bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drift set is narrower than what EnsureContainers actually writes. The create path below (lines 210-230) also sets Labels from util2.MergeMaps(o.ownerRef.GetLabels(), labels), but isContainerSpecChanged only covers Image / ImagePullSecret / ServiceAccountName / Tolerations. Owner label changes therefore never trigger a recreate, so the container's labels drift permanently from the owner's.

Low severity (discovery-container labels are mostly cosmetic/selection metadata), but it means the function name over-promises. Either add the label comparison — util2.NewHashableMap(...).Equals(...), the idiom used at internal/controllers/wekaclient/client_reconciler_loop.go:769 — or rename to something like isContainerRecreateRequired and add a comment stating labels are deliberately excluded so the omission reads as intentional.

The NodeAffinity/Mode omissions are fine — both are derived from the container name, which is the lookup key.


"github.com/pkg/errors"
"github.com/weka/go-steps-engine/lifecycle"
k8sutil "github.com/weka/weka-k8s-api/util"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import is out of alphabetical order within the group — github.com/weka/weka-k8s-api/util sorts after github.com/weka/go-weka-observability/instrumentation. gofmt won't flag it, but goimports/gci will if either is in the lint config.

Suggested change
k8sutil "github.com/weka/weka-k8s-api/util"
"github.com/weka/go-weka-observability/instrumentation"
weka "github.com/weka/weka-k8s-api/api/v1alpha1"
k8sutil "github.com/weka/weka-k8s-api/util"

(and drop the now-duplicated instrumentation / weka lines below)

op.container = container

err := op.EnsureContainers(context.Background())
waitErr := &lifecycle.WaitError{}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: the repo idiom for this is var waitErr *lifecycle.WaitError (see sign_drives_test.go:220 and funcs_aws_termination_lifecycle_test.go:228). &lifecycle.WaitError{} allocates a throwaway struct and makes the errors.As target a **WaitError — it still works, but it diverges from every other WaitError assertion in the package.

Same at line 192.

Suggested change
waitErr := &lifecycle.WaitError{}
var waitErr *lifecycle.WaitError

Comment on lines +200 to +217

func TestEnsureContainers_CreatesWhenMissing(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)
op := newDiscoverNodeTestOp(scheme)

if err := op.EnsureContainers(context.Background()); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if op.container == nil {
t.Fatal("container must be created")
}
if op.container.Spec.Image != op.image {
t.Errorf("created with image %q, want %q", op.container.Spec.Image, op.image)
}
if op.container.Spec.ServiceAccountName != op.serviceAccount {
t.Errorf("created with serviceAccount %q, want %q", op.container.Spec.ServiceAccountName, op.serviceAccount)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two coverage gaps worth closing, since they're the parts that actually encode OP-358:

  1. No end-to-end recreate test. Every test stops at the WaitError. The behaviour the ticket is about is "next pass creates it with the new image" — worth a test that calls EnsureContainers (drift → delete → WaitError), clears op.container, calls it again, and asserts the persisted object now has op.image. That's what proves delete-then-recreate-on-a-later-pass actually converges rather than just deleting.

  2. The owner in these tests is a *weka.WekaClient, but production always passes a *weka.WekaContainerNewDiscoverNodeOperation is called from exactly one place, internal/controllers/wekacontainer/funcs_getters.go:365, with r.container. That distinction is the whole premise of the IsControlledBy gate: every WekaContainer scheduled on a node is a candidate owner of the same weka-dsc-<node>, so the "two owners fight" scenario is container-vs-container, not client-vs-builder. Using *weka.WekaContainer as ownerRef would make the tests model the real contention.

@graphite-app

graphite-app Bot commented Sep 6, 2026

Copy link
Copy Markdown

Merge activity

  • Sep 6, 8:55 AM UTC: caspx added this pull request to the Graphite merge queue.
  • Sep 6, 8:56 AM UTC: CI is running for this pull request on a draft pull request (#2796) due to your merge queue CI optimization settings.
  • Sep 6, 9:51 AM UTC: Merged by the Graphite merge queue via draft PR: #2796.

@graphite-app graphite-app Bot closed this Sep 6, 2026
@graphite-app
graphite-app Bot deleted the op-358-discovery-propagation branch September 6, 2026 09:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants