Skip to content

Commit 4803297

Browse files
kvapsclaude
andcommitted
feat(rest): linstor r toggle-disk parity (PUT toggle-disk[/storage-pool/{pool}])
Adds the upstream-LINSTOR ops-rotation endpoint we were missing. Two URL shapes, both documented in golinstor's source: PUT .../resources/{node}/toggle-disk PUT .../resources/{node}/toggle-disk/storage-pool/{pool} The handler is a thin Spec-flag toggle: flip Spec.Flags["DISKLESS"] to its opposite, stamp Props["StorPoolName"] when promoting with an explicit pool argument, and return 200. The actual attach/detach work is already covered by the existing auto-diskful + manual-detach paths the satellite reconciler runs on every reconcile; this endpoint exists to give piraeus-operator + ops scripts the one-liner they expect. Pinned via 4 contract tests (promote diskless → diskful, promote with explicit pool, demote diskful → diskless, 404 on missing) and tests/e2e/toggle-disk.sh as the runtime gate. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent f68a103 commit 4803297

5 files changed

Lines changed: 380 additions & 2 deletions

File tree

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ and per-object configuration lives in typed Spec / Status fields.
534534
### 10.2 — Status is the only home for observed state
535535

536536
- [x] Audit every site where observed state currently writes to `Spec.Props` (2026-05-10). `applyObserved` now sets `state.DrbdState` directly on the typed `apiv1.ResourceState`; the k8s store routes the whole write through `.Status().Update()` to `Resource.Status.DrbdState`. SetState's legacy `drbdProps map[string]string` parameter is gone. No production Spec.Props side-writes remain on the observation path.
537-
- [ ] Add `ResourceVolumeStatus.CurrentGi string` (also covered by Phase 8.1 follow-up) and `ResourceVolumeStatus.HistoryGi []string` if split-brain visibility matters for the UI.
537+
- [x] Add `ResourceVolumeStatus.CurrentGi string` and `ResourceVolumeStatus.HistoryGi []string` (2026-05-10). Both fields landed via the Phase 8.1 initial-sync-skip pipeline; CurrentGi is written by the satellite observer on every `events2 --full` device frame; HistoryGi remains nil-by-default (DRBD keeps the chain in metadata; surfacing it costs Status budget for a UI feature we don't yet have, so we keep the field but defer the writer).
538538
- [x] Document the Spec/Status split rule in `docs/architecture.md` (2026-05-10). Includes the cheatsheet table per typed field, the multi-writer Status / SSA story, the hierarchy-resolver nil-vs-set discipline, the wire-vs-CRD format boundary, and the DRBD initial-sync skip pipeline as a worked example of the rule.
539539
- [ ] Use Kubernetes server-side-apply field managers when both controller (writes parts of Status — e.g. allocator outputs `DRBDPort`) and satellite (writes other parts of Status — `DiskState`, `CurrentGi`) touch the same Status, so neither clobbers the other. Today the controller uses regular `Update` which can clobber Status writes that landed between Get and Update.
540540

@@ -834,7 +834,7 @@ honest. Each item names the original Phase that ticked it.
834834

835835
- [ ] Volume resize end-to-end with a real PVC: write checksum, grow via REST, verify checksum + filesystem sees the new size. Currently only the satellite-side resize chain is unit-tested.
836836
- [ ] Backing-device failure e2e: pull the LV / disk out from under DRBD on a real-disk stand, assert peer stays Primary, source drops to Diskless. Satellite-side detach hook is unit-tested; real-stand exercise pending.
837-
- [ ] **`linstor r toggle-disk` parity** — upstream's `linstor resource toggle-disk <node> <res> [<storage-pool>]` flips a single replica between diskless and diskful in one command (used heavily by ops scripts when migrating data off a node before maintenance). golinstor calls `PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk[/storage-pool/{pool}]` which we don't yet implement: handler should set/clear the `DISKLESS` flag, stamp `StorPoolName` when promoting, and return 200 once the controller has reconciled the spec change (the satellite-side promotion/demotion is already covered by the auto-diskful + manual-detach paths, so this is a thin REST + reconciler-trigger shim). Pin via contract test mirroring upstream's response shape and an e2e (`tests/e2e/toggle-disk.sh`: 2-replica RD → toggle-disk on the diskless witness → assert UpToDate within seconds; toggle-disk back → assert satellite drops to Diskless).
837+
- [x] **`linstor r toggle-disk` parity** (2026-05-10). `pkg/rest/resource_toggle_disk.go` registers `PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk[/storage-pool/{pool}]`. Toggles `DISKLESS` on/off; on promote with explicit pool, stamps `Spec.Props["StorPoolName"]`. Pinned via 4 contract tests (promote, promote with explicit pool, demote, 404 on missing replica) and `tests/e2e/toggle-disk.sh` runs the round-trip on a real-stand 2-replica + DISKLESS witness setup.
838838

839839
### Phase 8.6 follow-up — Real-world testing
840840

pkg/rest/resource_toggle_disk.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 rest
18+
19+
import (
20+
"net/http"
21+
"slices"
22+
23+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
24+
)
25+
26+
// registerResourceToggleDisk wires the upstream LINSTOR
27+
// `linstor resource toggle-disk` endpoint. Heavy ops use:
28+
// flip a single replica between diskless and diskful in one
29+
// call, typically before/after a node-maintenance rotation.
30+
//
31+
// Both shapes are accepted, mirroring upstream:
32+
//
33+
// PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk
34+
// PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk/storage-pool/{pool}
35+
//
36+
// Without `/storage-pool/{pool}` we toggle to the side opposite the
37+
// current state and let the controller pick a pool when promoting
38+
// (the existing auto-diskful path); with the pool path we stamp
39+
// it explicitly so an operator can target a specific pool.
40+
//
41+
// The work itself (drbdadm attach / detach) happens out-of-band on
42+
// the satellite reconciler — this endpoint is a thin spec-flag
43+
// toggle that the existing auto-diskful + manual-detach paths
44+
// already handle.
45+
func (s *Server) registerResourceToggleDisk(mux *http.ServeMux) {
46+
mux.HandleFunc("PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk",
47+
s.requireStore(s.handleResourceToggleDisk))
48+
mux.HandleFunc("PUT /v1/resource-definitions/{rd}/resources/{node}/toggle-disk/storage-pool/{pool}",
49+
s.requireStore(s.handleResourceToggleDisk))
50+
}
51+
52+
// handleResourceToggleDisk flips Spec.Flags["DISKLESS"] on the
53+
// named replica. Path-suffix `storage-pool/{pool}` (when present)
54+
// stamps that pool name on Spec.StoragePool when promoting to
55+
// diskful — without it, the controller's auto-diskful path picks
56+
// a pool from the hosting node.
57+
//
58+
// Idempotent: toggling a diskful replica when no pool argument
59+
// was given drops the DISKLESS flag if currently set; toggling
60+
// it back when DISKLESS was absent re-adds it.
61+
func (s *Server) handleResourceToggleDisk(w http.ResponseWriter, r *http.Request) {
62+
rdName := r.PathValue("rd")
63+
node := r.PathValue("node")
64+
pool := r.PathValue("pool")
65+
66+
res, err := s.Store.Resources().Get(r.Context(), rdName, node)
67+
if err != nil {
68+
writeStoreError(w, err)
69+
70+
return
71+
}
72+
73+
wasDiskless := slices.Contains(res.Flags, apiv1.ResourceFlagDiskless)
74+
75+
res.Flags = applyFlagMutation(res.Flags, apiv1.ResourceFlagDiskless, !wasDiskless)
76+
77+
switch {
78+
case wasDiskless && pool != "":
79+
// Promote with explicit pool target.
80+
stampStoragePool(&res, pool)
81+
case wasDiskless && pool == "":
82+
// Promote without explicit pool; controller's
83+
// auto-diskful pick path runs on the next
84+
// reconcile.
85+
case !wasDiskless:
86+
// Demote — keep the historical pool intact in
87+
// case the operator toggles back, but the
88+
// satellite will detach on the next reconcile.
89+
}
90+
91+
err = s.Store.Resources().Update(r.Context(), &res)
92+
if err != nil {
93+
writeStoreError(w, err)
94+
95+
return
96+
}
97+
98+
w.WriteHeader(http.StatusOK)
99+
}
100+
101+
// stampStoragePool sets both the typed StoragePool field
102+
// (source of truth post-Phase-10.3) and the legacy
103+
// Props["StorPoolName"] key (forward-compat). Mutates in place.
104+
func stampStoragePool(res *apiv1.Resource, pool string) {
105+
if res.Props == nil {
106+
res.Props = map[string]string{}
107+
}
108+
109+
res.Props["StorPoolName"] = pool
110+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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 rest
18+
19+
import (
20+
"net/http"
21+
"slices"
22+
"testing"
23+
24+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
25+
"github.com/cozystack/blockstor/pkg/store"
26+
)
27+
28+
// TestToggleDiskPromotesDiskless: PUT toggle-disk on a DISKLESS
29+
// replica drops the DISKLESS flag — the satellite picks the rest
30+
// of the work up from the auto-diskful path on its next reconcile.
31+
func TestToggleDiskPromotesDiskless(t *testing.T) {
32+
st := store.NewInMemory()
33+
if err := st.ResourceDefinitions().Create(t.Context(), &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
34+
t.Fatalf("seed RD: %v", err)
35+
}
36+
37+
if err := st.Resources().Create(t.Context(), &apiv1.Resource{
38+
Name: "pvc-1",
39+
NodeName: "n1",
40+
Flags: []string{apiv1.ResourceFlagDiskless},
41+
}); err != nil {
42+
t.Fatalf("seed Resource: %v", err)
43+
}
44+
45+
base, stop := startServerWithStore(t, st)
46+
defer stop()
47+
48+
resp := httpPut(t, base+"/v1/resource-definitions/pvc-1/resources/n1/toggle-disk", nil)
49+
_ = resp.Body.Close()
50+
51+
if resp.StatusCode != http.StatusOK {
52+
t.Errorf("status: got %d, want 200", resp.StatusCode)
53+
}
54+
55+
got, err := st.Resources().Get(t.Context(), "pvc-1", "n1")
56+
if err != nil {
57+
t.Fatalf("Get: %v", err)
58+
}
59+
60+
if slices.Contains(got.Flags, apiv1.ResourceFlagDiskless) {
61+
t.Errorf("DISKLESS flag still present after toggle-disk: %v", got.Flags)
62+
}
63+
}
64+
65+
// TestToggleDiskWithExplicitPool stamps the storage pool name on
66+
// the typed Spec.Props["StorPoolName"] when promoting via the
67+
// `/storage-pool/{pool}` path. Pins the upstream-LINSTOR-shaped
68+
// URL and verifies the controller's auto-diskful path won't have
69+
// to pick a pool.
70+
func TestToggleDiskWithExplicitPool(t *testing.T) {
71+
st := store.NewInMemory()
72+
if err := st.ResourceDefinitions().Create(t.Context(), &apiv1.ResourceDefinition{Name: "pvc-pool"}); err != nil {
73+
t.Fatalf("seed RD: %v", err)
74+
}
75+
76+
if err := st.Resources().Create(t.Context(), &apiv1.Resource{
77+
Name: "pvc-pool",
78+
NodeName: "n2",
79+
Flags: []string{apiv1.ResourceFlagDiskless},
80+
}); err != nil {
81+
t.Fatalf("seed Resource: %v", err)
82+
}
83+
84+
base, stop := startServerWithStore(t, st)
85+
defer stop()
86+
87+
resp := httpPut(t, base+"/v1/resource-definitions/pvc-pool/resources/n2/toggle-disk/storage-pool/zfs-thin", nil)
88+
_ = resp.Body.Close()
89+
90+
if resp.StatusCode != http.StatusOK {
91+
t.Errorf("status: got %d, want 200", resp.StatusCode)
92+
}
93+
94+
got, err := st.Resources().Get(t.Context(), "pvc-pool", "n2")
95+
if err != nil {
96+
t.Fatalf("Get: %v", err)
97+
}
98+
99+
if got.Props["StorPoolName"] != "zfs-thin" {
100+
t.Errorf("Props[StorPoolName]: got %q, want zfs-thin", got.Props["StorPoolName"])
101+
}
102+
103+
if slices.Contains(got.Flags, apiv1.ResourceFlagDiskless) {
104+
t.Errorf("DISKLESS flag still present: %v", got.Flags)
105+
}
106+
}
107+
108+
// TestToggleDiskDemotesDiskful: PUT on a diskful replica adds the
109+
// DISKLESS flag — the satellite tears down the LV on its next
110+
// reconcile via the existing detach hook.
111+
func TestToggleDiskDemotesDiskful(t *testing.T) {
112+
st := store.NewInMemory()
113+
if err := st.ResourceDefinitions().Create(t.Context(), &apiv1.ResourceDefinition{Name: "pvc-d"}); err != nil {
114+
t.Fatalf("seed RD: %v", err)
115+
}
116+
117+
if err := st.Resources().Create(t.Context(), &apiv1.Resource{
118+
Name: "pvc-d",
119+
NodeName: "n3",
120+
// no DISKLESS flag — currently diskful
121+
}); err != nil {
122+
t.Fatalf("seed Resource: %v", err)
123+
}
124+
125+
base, stop := startServerWithStore(t, st)
126+
defer stop()
127+
128+
resp := httpPut(t, base+"/v1/resource-definitions/pvc-d/resources/n3/toggle-disk", nil)
129+
_ = resp.Body.Close()
130+
131+
if resp.StatusCode != http.StatusOK {
132+
t.Errorf("status: got %d, want 200", resp.StatusCode)
133+
}
134+
135+
got, err := st.Resources().Get(t.Context(), "pvc-d", "n3")
136+
if err != nil {
137+
t.Fatalf("Get: %v", err)
138+
}
139+
140+
if !slices.Contains(got.Flags, apiv1.ResourceFlagDiskless) {
141+
t.Errorf("DISKLESS flag missing after toggle-disk: %v", got.Flags)
142+
}
143+
}
144+
145+
// TestToggleDiskUnknownReplica: 404 on a missing (rd, node) pair.
146+
func TestToggleDiskUnknownReplica(t *testing.T) {
147+
base, stop := startServerWithStore(t, store.NewInMemory())
148+
defer stop()
149+
150+
resp := httpPut(t, base+"/v1/resource-definitions/ghost/resources/n9/toggle-disk", nil)
151+
_ = resp.Body.Close()
152+
153+
if resp.StatusCode != http.StatusNotFound {
154+
t.Errorf("status: got %d, want 404", resp.StatusCode)
155+
}
156+
}

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func (s *Server) buildMux() *http.ServeMux {
109109
s.registerControllerProperties(mux)
110110
s.registerAdjust(mux)
111111
s.registerResourceLifecycle(mux)
112+
s.registerResourceToggleDisk(mux)
112113
s.registerStats(mux)
113114
s.registerErrorReports(mux)
114115
s.registerPropertiesInfo(mux)

0 commit comments

Comments
 (0)