Skip to content

Commit fa07a46

Browse files
kvapsclaude
andcommitted
feat(satellite): auto-detach on disk:Failed (Phase 8.2)
When the storage layer drops out (LV evicted, zvol destroyed, file inode gone) DRBD reports disk:Failed via events2. Without action, the kernel keeps trying to reach the dead lower disk and the local replica thrashes between failure-recovery states. The events2 observer now intercepts those events and runs `drbdadm detach --force <rd>` so the lower disk binding drops cleanly. Peers stay UpToDate, the consumer keeps doing I/O via the network path; the next reconcile will redrive state if the storage layer comes back. The Failed observation still ships through ReportObserved so the controller's status pipeline can surface a "Diskless" condition when that handler lands. pkg/drbd.Adm.Detach is the wrapper; tests pin both Detach (--force) and Resize (--assume-clean) command shapes. End-to-end "pull the LV" verification lives on the 8.8 e2e checklist; satellite-side hook is in place. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 37db7ab commit fa07a46

4 files changed

Lines changed: 69 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ The phases above closed the MVP slice and the csi-sanity REST contract. A deep a
404404
### 8.2 Storage correctness
405405

406406
- [x] **Volume resize** plumbing (2026-05-09): `PUT /v1/resource-definitions/{rd}/volume-definitions/{vn}` already updated the spec; the satellite reconciler now picks up the size delta. Provider interface gained `ResizeVolume` (lvm-thin → `lvextend`, zfs → `zfs set volsize`, file/loopfile → `truncate` + `losetup -c`). On growth the reconciler runs the provider's resize, then `drbdadm resize --assume-clean <rd>` so the kernel re-reads the lower disk. `pkg/luks.Resize` adds the cryptsetup hook for the LUKS layer; satellite-side wiring of LUKS resize lands when the per-resource `encrypted` flag flows through ApplyResources (Phase 6 follow-up). Tests: `TestApplyTriggersResizeOnGrow`, `TestApplyNoResizeOnFreshCreate` for the satellite path; per-provider tests for the resize commands. **End-to-end with a real PVC + checksum verify is still on the e2e harness checklist** (8.8).
407-
- [ ] **Backing-device failure under DRBD**. When the storage layer drops out, the satellite must detach the failed replica (don't oscillate it diskful↔diskless). Today nothing watches `/proc/drbd` for `disk:Failed`. Add an event hook in `Reconciler` that flips the replica to a Diskless attach via `drbdsetup detach` and surfaces a Status condition. e2e: pull the LV out from under DRBD, observe peer stays Primary, no I/O loss.
407+
- [x] **Backing-device failure under DRBD** (2026-05-09). The events2 observer now watches for `disk:Failed` on the local replica and runs `drbdadm detach --force <rd>` so the lower disk stops getting hammered. Peers stay UpToDate, the consumer keeps doing I/O via DRBD's network path. The detach is best-effort (logged, not retried) — the next reconcile will redrive state if the storage layer comes back. The Failed observation still ships to the controller via ReportObserved, so a Status condition reflecting the diskless state is one ResourceObservation handler away. **End-to-end "pull the LV out from under DRBD"** sits on the 8.8 e2e checklist; satellite-side hook is in place.
408408
- [x] **DRBD options hierarchy** controller → resource-group → resource-definition → resource (2026-05-09). `pkg/drbd.ResolveOptions` walks the four scopes, lower wins. The resource controller reads ControllerProps via the KVEntry CRD, the parent RG via `client.Get`, the RD via the existing lookup, then folds in the resource's own props. The merged map flows through `dispatcher.ApplyOptions.EffectiveProps`; `buildDesired` splits it: DrbdOptions/* land on the satellite's drbd_options bag (the .res renderer drops them in the right `net`/`disk`/`peer-device`/`handlers` block via `pkg/drbd.SectionFor`), non-DRBD props stay on the wire-side Props map. Tests: resolver unit tests for override / partial inheritance / non-DRBD-prop pass-through; `TestApplyDRBDOptionsFromEffectiveProps` for the dispatcher wiring.
409409
- [x] **`allow-two-primaries` plumbing** (2026-05-09): the DRBD option-hierarchy now flows arbitrary `DrbdOptions/Net/...` keys (including `allow-two-primaries yes`) through the satellite into the rendered .res file's `net { }` block. Operators set the knob via `linstor c sp DrbdOptions/Net/allow-two-primaries yes` (or RG/RD/Resource scope). The first-activation auto-primary seed still picks one replica deterministically (lowest stable node-id) — that's correct: dual-primary is for the consumer's promotion (Ganesha promoter, KubeVirt live-migration controller), not for initial sync. `splitDRBDOptions` strips the `DrbdOptions/<Section>/` prefix so the .res renderer emits `allow-two-primaries yes;` verbatim. **Live-migration coordination on the controller side** (orchestrating `drbdadm primary` on the destination, then `drbdadm secondary` on the source) lives outside this scope — that's what drbd-reactor + the consumer (KubeVirt VirtualMachineInstanceMigration / Ganesha promoter) own.
410410

pkg/drbd/drbdadm.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ func (a *Adm) Secondary(ctx context.Context, resource string) error {
8383
return a.run(ctx, "secondary", resource)
8484
}
8585

86+
// Detach drops the local lower-disk binding without bringing the
87+
// resource down. The replica becomes Diskless on this node — peers
88+
// stay UpToDate, the consumer keeps doing I/O via DRBD's network
89+
// path. Used when the storage layer fails (LV evicted, zvol
90+
// destroyed, file inode gone) and we want the kernel to stop bashing
91+
// the dead block device. `--force` is required when the disk is
92+
// already in a transient state.
93+
func (a *Adm) Detach(ctx context.Context, resource string) error {
94+
return a.run(ctx, "detach", "--force", resource)
95+
}
96+
8697
// Resize rescans the lower disk's size and tells DRBD to grow the
8798
// replicated volume to match. The lower disk (LV / zvol / dm-crypt
8899
// target) must already be the target size — this is a notify-only

pkg/drbd/drbdadm_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,40 @@ func TestAdmPropagatesError(t *testing.T) {
136136
}
137137

138138
var errFakeFailure = errors.New("drbdadm: simulated failure")
139+
140+
// TestAdmDetachInvokesDrbdadm: Detach → `drbdadm detach --force <res>`.
141+
// --force is required because the disk is already in a transient
142+
// (Failed) state when this gets called; without it drbdadm refuses.
143+
func TestAdmDetachInvokesDrbdadm(t *testing.T) {
144+
fx := storage.NewFakeExec()
145+
adm := drbd.NewAdm(fx)
146+
147+
err := adm.Detach(t.Context(), "pvc-1")
148+
if err != nil {
149+
t.Fatalf("Detach: %v", err)
150+
}
151+
152+
want := "drbdadm detach --force pvc-1"
153+
if !slices.Contains(fx.CommandLines(), want) {
154+
t.Errorf("expected %q in calls; got %v", want, fx.CommandLines())
155+
}
156+
}
157+
158+
// TestAdmResizeInvokesDrbdadm: Resize → `drbdadm resize --assume-clean <res>`.
159+
// --assume-clean skips re-syncing the new bytes (they're freshly
160+
// allocated zeros) — without it growing 3 replicas serialises on
161+
// every resync.
162+
func TestAdmResizeInvokesDrbdadm(t *testing.T) {
163+
fx := storage.NewFakeExec()
164+
adm := drbd.NewAdm(fx)
165+
166+
err := adm.Resize(t.Context(), "pvc-1")
167+
if err != nil {
168+
t.Fatalf("Resize: %v", err)
169+
}
170+
171+
want := "drbdadm resize --assume-clean pvc-1"
172+
if !slices.Contains(fx.CommandLines(), want) {
173+
t.Errorf("expected %q in calls; got %v", want, fx.CommandLines())
174+
}
175+
}

pkg/satellite/agent.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,27 @@ func (a *Agent) runObserveLoop(ctx context.Context, client satellitepb.Controlle
292292
}
293293
}()
294294

295+
adm := drbd.NewAdm(storage.RealExec{})
296+
295297
for ev := range obs.Translate(events) {
298+
// Backing-device failure handler: when the kernel reports
299+
// disk:Failed for a local replica, detach so the lower
300+
// disk stops getting hammered. Peers stay UpToDate and
301+
// the consumer keeps doing I/O via the network path. The
302+
// detach is best-effort — a stale .res or already-Diskless
303+
// state surfaces as an error we log and move past.
304+
if ev.GetDrbdState() == "Failed" {
305+
detachErr := adm.Detach(ctx, ev.GetResourceName())
306+
if detachErr != nil {
307+
a.logger.Error("auto-detach on Failed",
308+
"resource", ev.GetResourceName(),
309+
"err", detachErr)
310+
} else {
311+
a.logger.Info("auto-detached failed replica",
312+
"resource", ev.GetResourceName())
313+
}
314+
}
315+
296316
err := stream.Send(ev)
297317
if err != nil {
298318
a.logger.Error("ReportObserved send", "err", err)

0 commit comments

Comments
 (0)