Skip to content

Commit 7c33115

Browse files
kvapsclaude
andcommitted
fix(drbd): persist node-id on Resource Status (Phase 8.1, data-corruption fix)
The dispatcher used to derive DRBD node-ids by re-sorting the current peer set on every reconcile. Removing a replica re-numbered the survivors, which re-mapped DRBD bitmaps mid-flight and silently corrupted data on resync. Persist DRBDNodeID, DRBDPort, and DRBDMinor on Resource.Status. The controller's allocator picks the lowest free id 0..15 not held by any sibling, then commits via the Status subresource and requeues so the dispatch sees the persisted value. Once allocated, an id is NEVER reassigned for the lifetime of that replica — sibling churn leaves the survivors' ids untouched. Tests: - pkg/drbd/allocator_test.go locks LowestFreeNodeID semantics (lowest gap, deterministic, exhausted at MaxPeers) - internal/controller/drbd_ids_test.go is the load-bearing property test: 4-phase add/remove/re-add churn against a fake client, asserts surviving replicas keep their ids and port/minor stay consistent across the RD Phase 8.1 PLAN item closed. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 34c53ca commit 7c33115

9 files changed

Lines changed: 773 additions & 29 deletions

File tree

api/v1alpha1/resource_types.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ type ResourceStatus struct {
4747
// +optional
4848
InUse bool `json:"inUse,omitempty"`
4949

50+
// drbdNodeID is the DRBD-9 node-id assigned to this replica.
51+
// Allocated once when the Resource first reconciles and never
52+
// changes for the lifetime of the Resource — re-numbering live
53+
// replicas would re-map their DRBD bitmaps and corrupt data on
54+
// peer-to-peer resync. Range 0..15 (drbd-9 max-peers). nil means
55+
// the controller has not yet allocated.
56+
// +optional
57+
DRBDNodeID *int32 `json:"drbdNodeId,omitempty"`
58+
59+
// drbdPort is the TCP port the controller allocated for this RD's
60+
// replication mesh. All replicas of the same RD share the port;
61+
// the controller stamps the same value on every sibling Resource.
62+
// nil means not yet allocated.
63+
// +optional
64+
DRBDPort *int32 `json:"drbdPort,omitempty"`
65+
66+
// drbdMinor is the local /dev/drbd<N> minor number. Same scoping
67+
// rule as drbdPort.
68+
// +optional
69+
DRBDMinor *int32 `json:"drbdMinor,omitempty"`
70+
5071
// volumes is the per-volume runtime state reported by the satellite.
5172
// +optional
5273
Volumes []ResourceVolumeStatus `json:"volumes,omitempty"`

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/blockstor.io.blockstor.io_resources.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,30 @@ spec:
122122
x-kubernetes-list-map-keys:
123123
- type
124124
x-kubernetes-list-type: map
125+
drbdMinor:
126+
description: |-
127+
drbdMinor is the local /dev/drbd<N> minor number. Same scoping
128+
rule as drbdPort.
129+
format: int32
130+
type: integer
131+
drbdNodeId:
132+
description: |-
133+
drbdNodeID is the DRBD-9 node-id assigned to this replica.
134+
Allocated once when the Resource first reconciles and never
135+
changes for the lifetime of the Resource — re-numbering live
136+
replicas would re-map their DRBD bitmaps and corrupt data on
137+
peer-to-peer resync. Range 0..15 (drbd-9 max-peers). nil means
138+
the controller has not yet allocated.
139+
format: int32
140+
type: integer
141+
drbdPort:
142+
description: |-
143+
drbdPort is the TCP port the controller allocated for this RD's
144+
replication mesh. All replicas of the same RD share the port;
145+
the controller stamps the same value on every sibling Resource.
146+
nil means not yet allocated.
147+
format: int32
148+
type: integer
125149
inUse:
126150
description: inUse is whether DRBD reports the resource as primary
127151
anywhere.
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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 controller_test holds property-style tests for the
18+
// reconciler's allocators. These tests run against a fake client to
19+
// keep them fast — envtest covers the integration path.
20+
package controller_test
21+
22+
import (
23+
"context"
24+
"testing"
25+
26+
corev1 "k8s.io/api/core/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/apimachinery/pkg/runtime"
29+
"sigs.k8s.io/controller-runtime/pkg/client"
30+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
31+
32+
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
33+
controllerpkg "github.com/cozystack/blockstor/internal/controller"
34+
)
35+
36+
// TestDRBDNodeIDStableAcrossPeerChurn is the load-bearing invariant
37+
// for DRBD bitmap correctness: an id assigned to a replica must NEVER
38+
// change for the lifetime of that replica, regardless of whether
39+
// other replicas are added or removed. Re-numbering live replicas
40+
// would re-map their bitmaps mid-flight and corrupt data on resync.
41+
func TestDRBDNodeIDStableAcrossPeerChurn(t *testing.T) {
42+
t.Parallel()
43+
44+
ctx := context.Background()
45+
scheme := newScheme(t)
46+
47+
rd := "pvc-stability"
48+
49+
// Phase 1: 3-replica RD, allocate ids in any order.
50+
cli := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&blockstoriov1alpha1.Resource{}).Build()
51+
52+
for _, node := range []string{"n1", "n2", "n3"} {
53+
create(ctx, t, cli, rd, node)
54+
}
55+
56+
rec := &controllerpkg.ResourceReconciler{Client: cli, Scheme: scheme}
57+
58+
allocate(ctx, t, rec, cli, rd)
59+
60+
first := snapshot(ctx, t, cli, rd)
61+
62+
// Phase 2: drop n2 (the middle one); the survivors n1, n3 must
63+
// keep the SAME ids they had in phase 1.
64+
deleteRes(ctx, t, cli, rd, "n2")
65+
allocate(ctx, t, rec, cli, rd)
66+
67+
second := snapshot(ctx, t, cli, rd)
68+
69+
for node, id := range first {
70+
if node == "n2" {
71+
continue
72+
}
73+
74+
if got, ok := second[node]; !ok || got != id {
75+
t.Errorf("phase 2: node %q id changed %d → %d (got=%d, present=%v)", node, id, got, got, ok)
76+
}
77+
}
78+
79+
// Phase 3: add n4 — its id must be a *new* one not in {n1.id, n3.id},
80+
// and the survivors still keep their original ids.
81+
create(ctx, t, cli, rd, "n4")
82+
allocate(ctx, t, rec, cli, rd)
83+
84+
third := snapshot(ctx, t, cli, rd)
85+
86+
for node, id := range first {
87+
if node == "n2" {
88+
continue
89+
}
90+
91+
if got := third[node]; got != id {
92+
t.Errorf("phase 3: node %q id drifted %d → %d", node, id, got)
93+
}
94+
}
95+
96+
if id, ok := third["n4"]; !ok {
97+
t.Errorf("phase 3: n4 not allocated")
98+
} else {
99+
for survivor, sid := range third {
100+
if survivor != "n4" && sid == id {
101+
t.Errorf("phase 3: n4 id %d collides with %s", id, survivor)
102+
}
103+
}
104+
}
105+
106+
// Phase 4: re-add n2 (it was deleted in phase 2). It must NOT
107+
// silently re-claim its old id — the old id is free now and the
108+
// allocator should pick the lowest free, which may or may not
109+
// equal the original. The invariant: ids in `third` must not
110+
// change.
111+
create(ctx, t, cli, rd, "n2")
112+
allocate(ctx, t, rec, cli, rd)
113+
114+
fourth := snapshot(ctx, t, cli, rd)
115+
116+
for node, id := range third {
117+
if got := fourth[node]; got != id {
118+
t.Errorf("phase 4: node %q id drifted %d → %d", node, id, got)
119+
}
120+
}
121+
}
122+
123+
// TestDRBDPortShared: every replica of an RD ends up with the same
124+
// DRBDPort and DRBDMinor. This is the upstream invariant — the
125+
// .res file uses the same port across the connection mesh.
126+
func TestDRBDPortShared(t *testing.T) {
127+
t.Parallel()
128+
129+
ctx := context.Background()
130+
scheme := newScheme(t)
131+
cli := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&blockstoriov1alpha1.Resource{}).Build()
132+
133+
rd := "pvc-port-shared"
134+
135+
for _, node := range []string{"n1", "n2", "n3"} {
136+
create(ctx, t, cli, rd, node)
137+
}
138+
139+
rec := &controllerpkg.ResourceReconciler{Client: cli, Scheme: scheme}
140+
allocate(ctx, t, rec, cli, rd)
141+
142+
var port, minor int32
143+
144+
list := &blockstoriov1alpha1.ResourceList{}
145+
if err := cli.List(ctx, list); err != nil {
146+
t.Fatalf("list: %v", err)
147+
}
148+
149+
for i := range list.Items {
150+
if list.Items[i].Status.DRBDPort == nil || list.Items[i].Status.DRBDMinor == nil {
151+
t.Fatalf("%s: port/minor not allocated", list.Items[i].Name)
152+
}
153+
154+
if port == 0 {
155+
port = *list.Items[i].Status.DRBDPort
156+
minor = *list.Items[i].Status.DRBDMinor
157+
158+
continue
159+
}
160+
161+
if got := *list.Items[i].Status.DRBDPort; got != port {
162+
t.Errorf("%s port mismatch: got %d, want %d", list.Items[i].Name, got, port)
163+
}
164+
165+
if got := *list.Items[i].Status.DRBDMinor; got != minor {
166+
t.Errorf("%s minor mismatch: got %d, want %d", list.Items[i].Name, got, minor)
167+
}
168+
}
169+
}
170+
171+
// allocate runs ensureDRBDIDs over every Resource of the RD until no
172+
// further changes — the controller's behaviour after a few requeues.
173+
func allocate(ctx context.Context, t *testing.T, rec *controllerpkg.ResourceReconciler, cli client.Client, rd string) {
174+
t.Helper()
175+
176+
for range 8 {
177+
list := &blockstoriov1alpha1.ResourceList{}
178+
if err := cli.List(ctx, list); err != nil {
179+
t.Fatalf("list: %v", err)
180+
}
181+
182+
peers := make([]blockstoriov1alpha1.Resource, 0, len(list.Items))
183+
184+
for i := range list.Items {
185+
if list.Items[i].Spec.ResourceDefinitionName == rd {
186+
peers = append(peers, list.Items[i])
187+
}
188+
}
189+
190+
dirty := false
191+
192+
for i := range peers {
193+
target := peers[i].DeepCopy()
194+
if err := cli.Get(ctx, client.ObjectKeyFromObject(target), target); err != nil {
195+
t.Fatalf("get: %v", err)
196+
}
197+
198+
changed, err := rec.EnsureDRBDIDsForTest(ctx, target, peers)
199+
if err != nil {
200+
t.Fatalf("ensureDRBDIDs: %v", err)
201+
}
202+
203+
dirty = dirty || changed
204+
}
205+
206+
if !dirty {
207+
return
208+
}
209+
}
210+
211+
t.Fatalf("ensureDRBDIDs did not converge in 8 passes")
212+
}
213+
214+
func create(ctx context.Context, t *testing.T, cli client.Client, rd, node string) {
215+
t.Helper()
216+
217+
r := &blockstoriov1alpha1.Resource{
218+
ObjectMeta: metav1.ObjectMeta{Name: rd + "." + node},
219+
Spec: blockstoriov1alpha1.ResourceSpec{
220+
ResourceDefinitionName: rd,
221+
NodeName: node,
222+
},
223+
}
224+
225+
if err := cli.Create(ctx, r); err != nil {
226+
t.Fatalf("create %s: %v", node, err)
227+
}
228+
}
229+
230+
func deleteRes(ctx context.Context, t *testing.T, cli client.Client, rd, node string) {
231+
t.Helper()
232+
233+
r := &blockstoriov1alpha1.Resource{ObjectMeta: metav1.ObjectMeta{Name: rd + "." + node}}
234+
if err := cli.Delete(ctx, r); err != nil {
235+
t.Fatalf("delete %s: %v", node, err)
236+
}
237+
}
238+
239+
func snapshot(ctx context.Context, t *testing.T, cli client.Client, rd string) map[string]int32 {
240+
t.Helper()
241+
242+
list := &blockstoriov1alpha1.ResourceList{}
243+
if err := cli.List(ctx, list); err != nil {
244+
t.Fatalf("list: %v", err)
245+
}
246+
247+
out := make(map[string]int32, len(list.Items))
248+
249+
for i := range list.Items {
250+
if list.Items[i].Spec.ResourceDefinitionName != rd {
251+
continue
252+
}
253+
254+
if list.Items[i].Status.DRBDNodeID == nil {
255+
t.Fatalf("%s: id not allocated", list.Items[i].Name)
256+
}
257+
258+
out[list.Items[i].Spec.NodeName] = *list.Items[i].Status.DRBDNodeID
259+
}
260+
261+
return out
262+
}
263+
264+
func newScheme(t *testing.T) *runtime.Scheme {
265+
t.Helper()
266+
267+
s := runtime.NewScheme()
268+
if err := corev1.AddToScheme(s); err != nil {
269+
t.Fatalf("corev1: %v", err)
270+
}
271+
272+
if err := blockstoriov1alpha1.AddToScheme(s); err != nil {
273+
t.Fatalf("blockstor: %v", err)
274+
}
275+
276+
return s
277+
}

0 commit comments

Comments
 (0)