Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ainav/operations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Manual operations, policies, CSI, and driver management.

| File | Purpose |
|------|---------|
| `discover_node.go` | Node discovery |
| `discover_node.go` | Node discovery; recreates discovery container on owner-spec drift (image/tolerations/pullSecret/serviceAccount) |
| `ensure_nics.go` | NIC configuration |
| `trace_session.go` | Remote trace collection |
| `cleanup_persistent_dir.go` | Cleanup operations |
Expand Down
28 changes: 27 additions & 1 deletion internal/controllers/operations/discover_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"reflect"

"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)

"github.com/weka/go-weka-observability/instrumentation"
weka "github.com/weka/weka-k8s-api/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -170,10 +172,34 @@ func (o *DiscoverNodeOperation) GetContainer(ctx context.Context) error {
return nil
}

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

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.

spec := o.container.Spec
return spec.Image != o.image ||
spec.ImagePullSecret != o.pullSecret ||
spec.ServiceAccountName != o.serviceAccount ||
!reflect.DeepEqual(k8sutil.NormalizeTolerations(spec.Tolerations), k8sutil.NormalizeTolerations(o.tolerations))
}

func (o *DiscoverNodeOperation) EnsureContainers(ctx context.Context) error {

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 {
Comment on lines +198 to +199

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 →

return err
}
return lifecycle.NewWaitError(fmt.Errorf("discovery container spec changed, recreating"))
}
Comment on lines 186 to 203

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.


// If we already have valid discovery information, skip container creation
Expand Down
217 changes: 217 additions & 0 deletions internal/controllers/operations/discover_node_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package operations

import (
"context"
"errors"
"testing"

weka "github.com/weka/weka-k8s-api/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

"github.com/weka/go-steps-engine/lifecycle"
)

func newDiscoverNodeTestScheme(t *testing.T) *runtime.Scheme {
t.Helper()
scheme := runtime.NewScheme()
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
if err := weka.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
return scheme
}

func newDiscoverNodeTestOp(scheme *runtime.Scheme, existing ...*weka.WekaContainer) *DiscoverNodeOperation {
builder := fake.NewClientBuilder().WithScheme(scheme)
for _, c := range existing {
builder = builder.WithObjects(c)
}
kclient := builder.Build()

owner := &weka.WekaClient{
ObjectMeta: metav1.ObjectMeta{
Name: "test-client",
Namespace: "default",
UID: "test-client-uid",
},
}
return &DiscoverNodeOperation{
client: kclient,
scheme: scheme,
nodeName: "test-node",
image: "quay.io/weka.io/weka-in-container:4.5.0",
pullSecret: "pull-secret",
serviceAccount: "weka-sa",
tolerations: []corev1.Toleration{{Key: "gpu", Operator: corev1.TolerationOpExists}},
ownerRef: owner,
node: &corev1.Node{
ObjectMeta: metav1.ObjectMeta{Name: "test-node"},
},
}
}

func (o *DiscoverNodeOperation) desiredTestContainer() *weka.WekaContainer {
controller := true
return &weka.WekaContainer{
ObjectMeta: metav1.ObjectMeta{
Name: o.getContainerName(),
Namespace: o.ownerRef.GetNamespace(),
OwnerReferences: []metav1.OwnerReference{{
APIVersion: "weka.weka.io/v1alpha1",
Kind: "WekaClient",
Name: o.ownerRef.GetName(),
UID: o.ownerRef.GetUID(),
Controller: &controller,
}},
},
Spec: weka.WekaContainerSpec{
Mode: weka.WekaContainerModeDiscovery,
NodeAffinity: weka.NodeName(o.node.Name),
Image: o.image,
ImagePullSecret: o.pullSecret,
Tolerations: o.tolerations,
ServiceAccountName: o.serviceAccount,
},
}
}

func TestIsContainerSpecChanged(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)

cases := []struct {
name string
mutate func(c *weka.WekaContainer)
want bool
}{
{"identical", func(c *weka.WekaContainer) {}, false},
{"image drift", func(c *weka.WekaContainer) { c.Spec.Image = "quay.io/weka.io/weka-in-container:4.6.0" }, true},
{"pull secret drift", func(c *weka.WekaContainer) { c.Spec.ImagePullSecret = "other-secret" }, true},
{"service account drift", func(c *weka.WekaContainer) { c.Spec.ServiceAccountName = "other-sa" }, true},
{"tolerations drift", func(c *weka.WekaContainer) { c.Spec.Tolerations = nil }, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
op := newDiscoverNodeTestOp(scheme)
container := op.desiredTestContainer()
tc.mutate(container)
op.container = container
if got := op.isContainerSpecChanged(); got != tc.want {
t.Errorf("isContainerSpecChanged() = %v, want %v", got, tc.want)
}
})
}

t.Run("nil vs empty tolerations is not drift", func(t *testing.T) {
op := newDiscoverNodeTestOp(scheme)
op.tolerations = []corev1.Toleration{}
container := op.desiredTestContainer()
container.Spec.Tolerations = nil
op.container = container
if op.isContainerSpecChanged() {
t.Error("nil vs empty tolerations must not count as drift")
}
})
}

func TestEnsureContainers_ExistingMatching(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)
op := newDiscoverNodeTestOp(scheme)
container := op.desiredTestContainer()
op = newDiscoverNodeTestOp(scheme, container)
op.container = container

if err := op.EnsureContainers(context.Background()); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if op.container == nil {
t.Fatal("matching container must be kept")
}
got := &weka.WekaContainer{}
if err := op.client.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: container.Name}, got); err != nil {
t.Fatalf("matching container must not be deleted: %v", err)
}
}

func TestEnsureContainers_SpecChangedDeletesAndWaits(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)
op := newDiscoverNodeTestOp(scheme)
container := op.desiredTestContainer()
container.Spec.Image = "quay.io/weka.io/weka-in-container:old"
op = newDiscoverNodeTestOp(scheme, container)
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

if !errors.As(err, &waitErr) {
t.Fatalf("expected WaitError, got %v", err)
}
got := &weka.WekaContainer{}
getErr := op.client.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: container.Name}, got)
if !apierrors.IsNotFound(getErr) {
t.Fatalf("drifted container must be deleted, got %v", getErr)
}
}

func TestEnsureContainers_ForeignOwnerNotTouched(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)
op := newDiscoverNodeTestOp(scheme)
container := op.desiredTestContainer()
container.Spec.Image = "quay.io/weka.io/weka-in-container:old"
container.OwnerReferences[0].Name = "other-owner"
container.OwnerReferences[0].UID = "other-owner-uid"
op = newDiscoverNodeTestOp(scheme, container)
op.container = container

if err := op.EnsureContainers(context.Background()); err != nil {
t.Fatalf("expected nil error for foreign-owned container, got %v", err)
}
got := &weka.WekaContainer{}
if err := op.client.Get(context.Background(), client.ObjectKey{Namespace: "default", Name: container.Name}, got); err != nil {
t.Fatalf("foreign-owned container must not be deleted: %v", err)
}
}

func TestEnsureContainers_TerminatingWaitsWithoutDelete(t *testing.T) {
scheme := newDiscoverNodeTestScheme(t)
op := newDiscoverNodeTestOp(scheme)
container := op.desiredTestContainer()
container.Spec.Image = "quay.io/weka.io/weka-in-container:old"
now := metav1.Now()
container.DeletionTimestamp = &now
container.Finalizers = []string{"weka.io/test"}
op.container = container

err := op.EnsureContainers(context.Background())
waitErr := &lifecycle.WaitError{}
if !errors.As(err, &waitErr) {
t.Fatalf("expected WaitError for terminating container, got %v", err)
}
if op.container == nil {
t.Fatal("terminating container must not be re-deleted (DeleteContainers resets o.container)")
}
}

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)
}
}
Comment on lines +200 to +217

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.

Loading