Skip to content

Commit b42805d

Browse files
kvapsclaude
andauthored
fix(controller): auto-tiebreaker witness must honour AutoplaceTarget=false (BUG-040) (#154)
* fix(controller): auto-tiebreaker witness must honour AutoplaceTarget=false (BUG-040) The tiebreaker picker only consulted the EVICTED/LOST flag set, so the auto-witness — an automatic placement — landed on a node the operator had drained with AutoplaceTarget=false, violating the maintenance-drain contract the placer has honoured since corner F4. Upstream LINSTOR routes tiebreaker selection through the autoplacer, which honours the prop, so upstream never pins a witness to a drained node. Exclude AutoplaceTarget=false nodes from the witness candidate set and from the pre-create authoritative-reader probe. When no eligible spare remains the witness is not created and the quorum computation now sees the real replica set instead of a phantom witness, resolving to quorum=off for a 2-voter RD (pre-fix it stamped majority, arming a both-halves freeze on partition). Existing witnesses survive a later AutoplaceTarget flip — the prop gates new placements only, mirroring the placer's no-migration semantic. Also pin the rebalance-refill wiring: the refill never lands on AutoplaceTarget=false or EVICTED nodes and never exceeds the RG place_count. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> * fix(e2e): repair node-lifecycle cli-matrix cells exposed by the BUG-040 sweep The release-gate sweep surfaced a family of cells whose assertions never matched the wire contract: - n-d-evicted-rejected / n-evacuate-prunes-source counted total Resource rows after autoplace=2, racing the legal auto-tiebreaker witness ("never reached count=2 (last=3)"). Add wait_diskful_count and gate on the diskful count, the convention the rest of the suite already follows. The evacuate cell also picked its spare via "no Resource CRD" — impossible with the witness parked there; the replacement legitimately promotes that witness in place. - n-evict-tiebreaker-no-shuffle invoked 'linstor node evict', a verb linstor-client 1.27.1 does not have — the CLI died in argparse with no REST call and the cell could never pass. Drain via node evacuate, which stamps the same EVICTED flag the cell asserts on. - n-rst-recreates-tiebreaker one-shot-checked the 'r l' wire view the instant the witness CRD appeared; the apiserver list view reads its informer cache and can lag the write. Poll briefly. - auto-diskful-evicted-node demanded 3 healthy diskful after evacuating one of 3 workers (topologically impossible) and parked the RD in DfltRscGrp whose empty place_count makes the auto-diskful controller skip it. Rebuild the cell on an achievable shape: place_count-2 RG, user-diskless promotion candidate, evacuate one diskful, assert the deficit repairs onto the healthy spare and never onto the evicted node. - bug-278-skipdisk-autoclear stamped SkipDisk via kubectl merge-patch, so the key belonged to the kubectl field manager — which the SSA release path correctly refuses to clear (operator-set SkipDisk must survive). Stamp via server-side apply under the observer's field manager so the cell exercises the actual defensive-stamp contract. - multi-volume-late-vd-create: widen the initial-sync budget; the failure dump showed all replicas UpToDate moments after the old deadline under sweep load. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com> --------- Signed-off-by: Andrei Kvapil <kvapss@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92030f3 commit b42805d

12 files changed

Lines changed: 798 additions & 111 deletions
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
/*
4+
Copyright 2026 Cozystack contributors.
5+
6+
Licensed under the Apache License, Version 2.0 (the "License");
7+
you may not use this file except in compliance with the License.
8+
You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing, software
13+
distributed under the License is distributed on an "AS IS" BASIS,
14+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
See the License for the specific language governing permissions and
16+
limitations under the License.
17+
*/
18+
19+
package controller_test
20+
21+
import (
22+
"context"
23+
"testing"
24+
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
27+
28+
blockstoriov1alpha1 "github.com/cozystack/blockstor/api/v1alpha1"
29+
controllerpkg "github.com/cozystack/blockstor/internal/controller"
30+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
31+
"github.com/cozystack/blockstor/pkg/store"
32+
)
33+
34+
// BUG-040 (release-gate, stand dev sweep @73895806c) — the
35+
// auto-tiebreaker witness landed on the one node the operator had
36+
// explicitly drained with `linstor node set-property <node>
37+
// AutoplaceTarget false` (corner F4 maintenance-drain workflow).
38+
//
39+
// Stand repro (cli-matrix/n-autoplace-target-excludes):
40+
//
41+
// $ linstor n sp dev-worker-1 AutoplaceTarget false
42+
// $ linstor rd c ccf-aptgt-1 && linstor vd c ccf-aptgt-1 256M
43+
// $ linstor r c --auto-place=2 -s lvm-thin ccf-aptgt-1
44+
// → diskful on dev-worker-2 + dev-worker-3 (placer honours the prop),
45+
// but ensureTiebreaker then stamped a [DISKLESS TIE_BREAKER] row on
46+
// dev-worker-1 — controller log 2026-06-13T00:45:16Z, reconcileID
47+
// 6874c482: "ensureTiebreaker … willCreate=true".
48+
//
49+
// pickTiebreakerNodeForRD only consulted the EVICTED/LOST flag set;
50+
// the AutoplaceTarget prop — which the placer has honoured since
51+
// corner F4 (#102) — was never read, so the witness (an automatic
52+
// placement) violated the operator's drain. Upstream LINSTOR routes
53+
// tiebreaker selection through the same autoplacer that honours
54+
// AutoplaceTarget, so upstream never pins a witness to a drained node:
55+
// with no other spare the witness is simply not created and quorum
56+
// falls back to off (isQuorumFeasible: 2 diskful + 0 diskless → off).
57+
58+
// TestBug040WitnessNeverLandsOnAutoplaceExcludedNode pins the create
59+
// branch: 2 diskful + the only spare node carrying
60+
// AutoplaceTarget=false. The witness MUST NOT be created anywhere and
61+
// the quorum prop MUST resolve to off — not the phantom-witness
62+
// majority the pre-fix applyWitnessDecision computed.
63+
func TestBug040WitnessNeverLandsOnAutoplaceExcludedNode(t *testing.T) {
64+
t.Parallel()
65+
66+
scheme := newScheme(t)
67+
st := store.NewInMemory()
68+
ctx := context.Background()
69+
70+
for _, n := range []string{"n1", "n2"} {
71+
if err := st.Nodes().Create(ctx, &apiv1.Node{
72+
Name: n, Type: apiv1.NodeTypeSatellite,
73+
}); err != nil {
74+
t.Fatalf("seed node %s: %v", n, err)
75+
}
76+
}
77+
78+
if err := st.Nodes().Create(ctx, &apiv1.Node{
79+
Name: "n3", Type: apiv1.NodeTypeSatellite,
80+
Props: map[string]string{apiv1.PropAutoplaceTarget: "false"},
81+
}); err != nil {
82+
t.Fatalf("seed n3 (AutoplaceTarget=false): %v", err)
83+
}
84+
85+
for _, n := range []string{"n1", "n2"} {
86+
if err := st.Resources().Create(ctx, &apiv1.Resource{
87+
Name: "pvc-bug040", NodeName: n,
88+
}); err != nil {
89+
t.Fatalf("seed diskful %s: %v", n, err)
90+
}
91+
}
92+
93+
rd := &blockstoriov1alpha1.ResourceDefinition{
94+
ObjectMeta: metav1.ObjectMeta{Name: "pvc-bug040"},
95+
}
96+
97+
cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(rd).Build()
98+
99+
rec := &controllerpkg.ResourceDefinitionReconciler{
100+
Client: cli,
101+
Scheme: scheme,
102+
Store: st,
103+
}
104+
105+
if err := rec.EnsureTiebreaker(ctx, rd); err != nil {
106+
t.Fatalf("EnsureTiebreaker: %v", err)
107+
}
108+
109+
all, err := st.Resources().ListByDefinition(ctx, "pvc-bug040")
110+
if err != nil {
111+
t.Fatalf("list: %v", err)
112+
}
113+
114+
// Invariant 1: nothing landed on the drained node.
115+
if _, err := st.Resources().Get(ctx, "pvc-bug040", "n3"); err == nil {
116+
t.Errorf("BUG-040: a replica landed on the AutoplaceTarget=false node n3; entries=%v", all)
117+
}
118+
119+
// Invariant 2: exactly the two diskful replicas — no witness was
120+
// created anywhere (the drained node was the only spare).
121+
diskful, diskless, witness := classifyReplicas(all)
122+
if diskful != 2 || diskless != 0 || witness != 0 {
123+
t.Errorf("BUG-040: composition diskful=%d diskless=%d witness=%d, want 2/0/0; entries=%v",
124+
diskful, diskless, witness, all)
125+
}
126+
127+
// Invariant 3: with only 2 voters the quorum prop must be off —
128+
// the pre-fix code appended a phantom witness to the quorum
129+
// computation and stamped majority on a 2-voter RD. EnsureTiebreaker
130+
// mutates the in-memory rd it was handed (setQuorum's optimistic
131+
// loop), so assert on that.
132+
if q := rd.Spec.Props["DrbdOptions/Resource/quorum"]; q != "off" {
133+
t.Errorf("BUG-040: quorum prop got %q, want off (2 diskful, no witness possible)", q)
134+
}
135+
}
136+
137+
// TestBug040WitnessLandsWhenAutoplaceTargetNotFalse pins the fail-open
138+
// parse: true / garbage / unset values keep the spare node eligible,
139+
// so the witness lands there exactly as before the fix.
140+
func TestBug040WitnessLandsWhenAutoplaceTargetNotFalse(t *testing.T) {
141+
t.Parallel()
142+
143+
for _, tc := range []struct {
144+
name string
145+
props map[string]string
146+
}{
147+
{name: "explicit-true", props: map[string]string{apiv1.PropAutoplaceTarget: "true"}},
148+
{name: "garbage-value", props: map[string]string{apiv1.PropAutoplaceTarget: "nope"}},
149+
{name: "unset", props: nil},
150+
} {
151+
t.Run(tc.name, func(t *testing.T) {
152+
t.Parallel()
153+
154+
scheme := newScheme(t)
155+
st := store.NewInMemory()
156+
ctx := context.Background()
157+
158+
for _, n := range []string{"n1", "n2"} {
159+
if err := st.Nodes().Create(ctx, &apiv1.Node{
160+
Name: n, Type: apiv1.NodeTypeSatellite,
161+
}); err != nil {
162+
t.Fatalf("seed node %s: %v", n, err)
163+
}
164+
}
165+
166+
if err := st.Nodes().Create(ctx, &apiv1.Node{
167+
Name: "n3", Type: apiv1.NodeTypeSatellite, Props: tc.props,
168+
}); err != nil {
169+
t.Fatalf("seed n3: %v", err)
170+
}
171+
172+
for _, n := range []string{"n1", "n2"} {
173+
if err := st.Resources().Create(ctx, &apiv1.Resource{
174+
Name: "pvc-bug040b", NodeName: n,
175+
}); err != nil {
176+
t.Fatalf("seed diskful %s: %v", n, err)
177+
}
178+
}
179+
180+
rd := &blockstoriov1alpha1.ResourceDefinition{
181+
ObjectMeta: metav1.ObjectMeta{Name: "pvc-bug040b"},
182+
}
183+
184+
cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(rd).Build()
185+
186+
rec := &controllerpkg.ResourceDefinitionReconciler{
187+
Client: cli,
188+
Scheme: scheme,
189+
Store: st,
190+
}
191+
192+
if err := rec.EnsureTiebreaker(ctx, rd); err != nil {
193+
t.Fatalf("EnsureTiebreaker: %v", err)
194+
}
195+
196+
witnessRow, err := st.Resources().Get(ctx, "pvc-bug040b", "n3")
197+
if err != nil {
198+
t.Fatalf("witness must land on the eligible spare n3 (props=%v): %v", tc.props, err)
199+
}
200+
201+
diskful, _, witness := classifyReplicas([]apiv1.Resource{witnessRow})
202+
if diskful != 0 || witness != 1 {
203+
t.Errorf("n3 row must be a TIE_BREAKER witness; flags=%v", witnessRow.Flags)
204+
}
205+
})
206+
}
207+
}
208+
209+
// TestBug040ExistingWitnessSurvivesAutoplaceTargetFlip pins the
210+
// existing-replica half of the AutoplaceTarget contract: the prop
211+
// excludes the node from NEW placements only — a witness that already
212+
// lives on the node stays put when the operator later stamps
213+
// AutoplaceTarget=false (same "no migration" semantic the placer
214+
// documents for diskful replicas, and what upstream's drain workflow
215+
// promises).
216+
func TestBug040ExistingWitnessSurvivesAutoplaceTargetFlip(t *testing.T) {
217+
t.Parallel()
218+
219+
scheme := newScheme(t)
220+
st := store.NewInMemory()
221+
ctx := context.Background()
222+
223+
for _, n := range []string{"n1", "n2"} {
224+
if err := st.Nodes().Create(ctx, &apiv1.Node{
225+
Name: n, Type: apiv1.NodeTypeSatellite,
226+
}); err != nil {
227+
t.Fatalf("seed node %s: %v", n, err)
228+
}
229+
}
230+
231+
// The witness host: drained AFTER the witness landed.
232+
if err := st.Nodes().Create(ctx, &apiv1.Node{
233+
Name: "n3", Type: apiv1.NodeTypeSatellite,
234+
Props: map[string]string{apiv1.PropAutoplaceTarget: "false"},
235+
}); err != nil {
236+
t.Fatalf("seed n3: %v", err)
237+
}
238+
239+
for _, n := range []string{"n1", "n2"} {
240+
if err := st.Resources().Create(ctx, &apiv1.Resource{
241+
Name: "pvc-bug040c", NodeName: n,
242+
}); err != nil {
243+
t.Fatalf("seed diskful %s: %v", n, err)
244+
}
245+
}
246+
247+
if err := st.Resources().Create(ctx, &apiv1.Resource{
248+
Name: "pvc-bug040c", NodeName: "n3",
249+
Flags: []string{apiv1.ResourceFlagDiskless, apiv1.ResourceFlagTieBreaker},
250+
}); err != nil {
251+
t.Fatalf("seed existing witness on n3: %v", err)
252+
}
253+
254+
rd := &blockstoriov1alpha1.ResourceDefinition{
255+
ObjectMeta: metav1.ObjectMeta{Name: "pvc-bug040c"},
256+
}
257+
258+
cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(rd).Build()
259+
260+
rec := &controllerpkg.ResourceDefinitionReconciler{
261+
Client: cli,
262+
Scheme: scheme,
263+
Store: st,
264+
}
265+
266+
if err := rec.EnsureTiebreaker(ctx, rd); err != nil {
267+
t.Fatalf("EnsureTiebreaker: %v", err)
268+
}
269+
270+
witnessRow, err := st.Resources().Get(ctx, "pvc-bug040c", "n3")
271+
if err != nil {
272+
t.Fatalf("BUG-040: existing witness on n3 must survive the AutoplaceTarget flip: %v", err)
273+
}
274+
275+
_, _, witness := classifyReplicas([]apiv1.Resource{witnessRow})
276+
if witness != 1 {
277+
t.Errorf("n3 row must still be a TIE_BREAKER witness; flags=%v", witnessRow.Flags)
278+
}
279+
280+
// Quorum stays majority: 2 diskful + the surviving witness.
281+
if q := rd.Spec.Props["DrbdOptions/Resource/quorum"]; q != "majority" {
282+
t.Errorf("quorum prop got %q, want majority (witness survived)", q)
283+
}
284+
}

0 commit comments

Comments
 (0)