Skip to content

Commit dc1f5c1

Browse files
kvapsclaude
andcommitted
test(satellite/controllers): pin predicate filter invariants
Pure-Go unit tests for the three watch-layer predicates the Phase 10.1 reconcilers wire — without envtest, since the predicates are functions of (event, object) only: * `nodeNamePredicate(self)` — matches Resource/StoragePool whose Spec.NodeName == self. Update events match if EITHER old or new state was ours (catches migrate-away tear-downs). * `snapshotNodePredicate(self)` — matches Snapshot whose Spec.Nodes list contains self. The snapshot semantic is per-node, one CRD → CreateSnapshot fans out across each node in the list. * `dropAllEventsPredicate()` — always-false, used by the ResourceDefinitionReconciler so the cache fills via the list/watch but the reconciler body never runs (RDs are consumed by the Resource reconciler via client.Get). 7 sub-tests cover the happy path, the foreign-node reject, the Update-old-was-ours case, the membership filter both directions, and the cache-warming invariant. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 9762984 commit dc1f5c1

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package controllers
18+
19+
import (
20+
"testing"
21+
22+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
23+
"sigs.k8s.io/controller-runtime/pkg/event"
24+
25+
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
26+
)
27+
28+
// TestNodeNamePredicateMatchesResourceForSelf pins the watch-
29+
// layer filter: a Resource whose Spec.NodeName equals the
30+
// satellite's own name MUST pass the predicate, so its
31+
// reconciler fires. Without this filter every satellite would
32+
// reconcile every Resource in the cluster — N² blow-up on
33+
// big clusters.
34+
func TestNodeNamePredicateMatchesResourceForSelf(t *testing.T) {
35+
t.Parallel()
36+
37+
p := nodeNamePredicate("n1")
38+
res := &blockstoriov1alpha1.Resource{
39+
ObjectMeta: metav1.ObjectMeta{Name: "rd.n1"},
40+
Spec: blockstoriov1alpha1.ResourceSpec{NodeName: "n1"},
41+
}
42+
43+
if !p.Create(event.CreateEvent{Object: res}) {
44+
t.Errorf("CreateEvent on own-node Resource: predicate dropped it")
45+
}
46+
47+
if !p.Delete(event.DeleteEvent{Object: res}) {
48+
t.Errorf("DeleteEvent on own-node Resource: predicate dropped it")
49+
}
50+
}
51+
52+
// TestNodeNamePredicateRejectsResourceForOtherNode pins the
53+
// inverse: a Resource on a different node MUST be filtered out.
54+
func TestNodeNamePredicateRejectsResourceForOtherNode(t *testing.T) {
55+
t.Parallel()
56+
57+
p := nodeNamePredicate("n1")
58+
res := &blockstoriov1alpha1.Resource{
59+
Spec: blockstoriov1alpha1.ResourceSpec{NodeName: "n2"},
60+
}
61+
62+
if p.Create(event.CreateEvent{Object: res}) {
63+
t.Errorf("CreateEvent on foreign-node Resource: predicate let it through")
64+
}
65+
}
66+
67+
// TestNodeNamePredicateMatchesStoragePoolForSelf: same filter
68+
// reused for StoragePool. The predicate uses a type switch
69+
// because StoragePool and Resource both carry Spec.NodeName but
70+
// the field's owning type differs.
71+
func TestNodeNamePredicateMatchesStoragePoolForSelf(t *testing.T) {
72+
t.Parallel()
73+
74+
p := nodeNamePredicate("n1")
75+
pool := &blockstoriov1alpha1.StoragePool{
76+
Spec: blockstoriov1alpha1.StoragePoolSpec{NodeName: "n1"},
77+
}
78+
79+
if !p.Create(event.CreateEvent{Object: pool}) {
80+
t.Errorf("CreateEvent on own-node StoragePool: predicate dropped it")
81+
}
82+
}
83+
84+
// TestNodeNamePredicateUpdateMatchesEitherSide: an Update event
85+
// whose OLD or NEW state has the right node name must pass —
86+
// this catches the case where a Resource gets MIGRATED off this
87+
// node (the satellite still needs to tear down its local copy)
88+
// or migrates ONTO this node.
89+
func TestNodeNamePredicateUpdateMatchesEitherSide(t *testing.T) {
90+
t.Parallel()
91+
92+
p := nodeNamePredicate("n1")
93+
oldRes := &blockstoriov1alpha1.Resource{
94+
Spec: blockstoriov1alpha1.ResourceSpec{NodeName: "n1"},
95+
}
96+
newRes := &blockstoriov1alpha1.Resource{
97+
Spec: blockstoriov1alpha1.ResourceSpec{NodeName: "n2"}, // migrated away
98+
}
99+
100+
if !p.Update(event.UpdateEvent{ObjectOld: oldRes, ObjectNew: newRes}) {
101+
t.Errorf("UpdateEvent old-was-ours, new-isn't: predicate dropped it (must keep for teardown)")
102+
}
103+
}
104+
105+
// TestSnapshotNodePredicateMatchesMembership pins the
106+
// list-membership filter: Snapshot.Spec.Nodes is a list of
107+
// node names; the predicate passes when our name is in the
108+
// list. Pinned because the snapshot semantic is per-node — one
109+
// Snapshot CRD triggers CreateSnapshot on EACH node in its
110+
// Nodes list, and the predicate is how each satellite decides
111+
// "this one's mine".
112+
func TestSnapshotNodePredicateMatchesMembership(t *testing.T) {
113+
t.Parallel()
114+
115+
p := snapshotNodePredicate("n2")
116+
snap := &blockstoriov1alpha1.Snapshot{
117+
Spec: blockstoriov1alpha1.SnapshotSpec{
118+
SnapshotName: "snap-1",
119+
Nodes: []string{"n1", "n2", "n3"},
120+
},
121+
}
122+
123+
if !p.Create(event.CreateEvent{Object: snap}) {
124+
t.Errorf("CreateEvent on Snapshot with our node in Nodes: predicate dropped it")
125+
}
126+
}
127+
128+
// TestSnapshotNodePredicateRejectsAbsentNode: when our name is
129+
// NOT in Spec.Nodes the predicate filters the event out.
130+
func TestSnapshotNodePredicateRejectsAbsentNode(t *testing.T) {
131+
t.Parallel()
132+
133+
p := snapshotNodePredicate("n4")
134+
snap := &blockstoriov1alpha1.Snapshot{
135+
Spec: blockstoriov1alpha1.SnapshotSpec{
136+
Nodes: []string{"n1", "n2", "n3"},
137+
},
138+
}
139+
140+
if p.Create(event.CreateEvent{Object: snap}) {
141+
t.Errorf("CreateEvent on Snapshot without our node: predicate let it through")
142+
}
143+
}
144+
145+
// TestDropAllEventsPredicateAlwaysFalse pins the cache-warming
146+
// invariant: the ResourceDefinitionReconciler's predicate
147+
// MUST drop every event so the cache fills (via the watch the
148+
// manager runs unconditionally) but the reconciler body never
149+
// runs. A regression that let events through would create
150+
// O(RDs)-per-tick reconciles for nothing.
151+
func TestDropAllEventsPredicateAlwaysFalse(t *testing.T) {
152+
t.Parallel()
153+
154+
p := dropAllEventsPredicate()
155+
rd := &blockstoriov1alpha1.ResourceDefinition{}
156+
157+
if p.Create(event.CreateEvent{Object: rd}) {
158+
t.Errorf("Create: must always drop")
159+
}
160+
161+
if p.Update(event.UpdateEvent{ObjectNew: rd, ObjectOld: rd}) {
162+
t.Errorf("Update: must always drop")
163+
}
164+
165+
if p.Delete(event.DeleteEvent{Object: rd}) {
166+
t.Errorf("Delete: must always drop")
167+
}
168+
169+
if p.Generic(event.GenericEvent{Object: rd}) {
170+
t.Errorf("Generic: must always drop")
171+
}
172+
}

0 commit comments

Comments
 (0)