Skip to content

Commit def3e7c

Browse files
kvapsclaude
andcommitted
feat(rest): node eviction / restoration / lost-and-recover endpoints
POST /v1/nodes/{node}/{evacuate,restore,lost} toggle EVICTED / LOST flags on the Node CRD. Replica migration is the reconciler's job — the REST layer only marks intent. Idempotent flag mutation so repeated POSTs don't grow the flag list. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 1c62aa6 commit def3e7c

4 files changed

Lines changed: 239 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
265265
### Phase 7 — Cluster operations + admin
266266

267267
- [x] Cluster passphrase management (`/v1/encryption/passphrase` POST/PATCH/PUT): seeds, unlocks, rotates the cluster passphrase under ControllerProps. KDF + at-rest encryption of per-volume keys is the LUKS phase 6 work. 3 contract tests.
268-
- [ ] Satellite eviction / restoration / lost-and-recover
268+
- [x] Satellite eviction / restoration / lost-and-recover (`POST /v1/nodes/{node}/{evacuate,restore,lost}`): toggles EVICTED / LOST flags on the Node CRD; replica migration is the reconciler's job. 4 contract tests.
269269
- [x] Stats endpoint (`GET /v1/stats`): cluster-wide counters (nodes, RDs, resources, storage pools, snapshots). 2 contract tests.
270270
- [x] Error reports stub (`/v1/error-reports` LIST returns []; GET /{id} → 404). Empty-but-present so `linstor error-reports list` doesn't choke. Real persistence lands when the controller starts buffering reports.
271271
- [ ] All `/v1/view/*` aggregates

pkg/rest/node_lifecycle.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
24+
// registerNodeLifecycle wires the eviction / restore / lost endpoints.
25+
// They mark intent on the Node CRD; replica migration is the
26+
// reconciler's job (Phase 6).
27+
func (s *Server) registerNodeLifecycle(mux *http.ServeMux) {
28+
mux.HandleFunc("POST /v1/nodes/{node}/evacuate",
29+
s.requireStore(s.handleNodeEvacuate))
30+
mux.HandleFunc("POST /v1/nodes/{node}/restore",
31+
s.requireStore(s.handleNodeRestore))
32+
mux.HandleFunc("POST /v1/nodes/{node}/lost",
33+
s.requireStore(s.handleNodeLost))
34+
}
35+
36+
// handleNodeEvacuate adds the EVICTED flag — a soft "drain me" hint
37+
// the autoplacer respects (won't pick this node for new replicas) and
38+
// the migration reconciler watches for.
39+
func (s *Server) handleNodeEvacuate(w http.ResponseWriter, r *http.Request) {
40+
updateNodeFlags(w, r, s, addFlag("EVICTED"))
41+
}
42+
43+
// handleNodeRestore removes EVICTED. Lost-and-found in production: a
44+
// node we tried to drain came back online before the migration
45+
// completed.
46+
func (s *Server) handleNodeRestore(w http.ResponseWriter, r *http.Request) {
47+
updateNodeFlags(w, r, s, removeFlag("EVICTED"))
48+
}
49+
50+
// handleNodeLost is the permanent action — node is gone, replicas
51+
// must be re-created elsewhere even without local cooperation.
52+
func (s *Server) handleNodeLost(w http.ResponseWriter, r *http.Request) {
53+
updateNodeFlags(w, r, s, addFlag("LOST"), addFlag("EVICTED"))
54+
}
55+
56+
// updateNodeFlags loads the Node, applies each mutator, and persists.
57+
// Common shape across all three endpoints; lives here so the handler
58+
// bodies stay one-line.
59+
func updateNodeFlags(w http.ResponseWriter, r *http.Request, s *Server, mutators ...func([]string) []string) {
60+
name := r.PathValue("node")
61+
62+
node, err := s.Store.Nodes().Get(r.Context(), name)
63+
if err != nil {
64+
writeStoreError(w, err)
65+
66+
return
67+
}
68+
69+
for _, m := range mutators {
70+
node.Flags = m(node.Flags)
71+
}
72+
73+
err = s.Store.Nodes().Update(r.Context(), &node)
74+
if err != nil {
75+
writeStoreError(w, err)
76+
77+
return
78+
}
79+
80+
w.WriteHeader(http.StatusOK)
81+
}
82+
83+
// addFlag returns a mutator that adds flag if it's not already there.
84+
// Idempotent so repeated POSTs don't grow the flag list.
85+
func addFlag(flag string) func([]string) []string {
86+
return func(flags []string) []string {
87+
if slices.Contains(flags, flag) {
88+
return flags
89+
}
90+
91+
return append(flags, flag)
92+
}
93+
}
94+
95+
// removeFlag returns a mutator that drops every occurrence of flag.
96+
func removeFlag(flag string) func([]string) []string {
97+
return func(flags []string) []string {
98+
out := flags[:0]
99+
100+
for _, f := range flags {
101+
if f != flag {
102+
out = append(out, f)
103+
}
104+
}
105+
106+
return out
107+
}
108+
}

pkg/rest/node_lifecycle_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
// TestNodeEvacuateMarksFlag: POST /v1/nodes/{node}/evacuate adds the
29+
// EVICTED flag to the Node. Replica migration is the reconciler's job;
30+
// the REST endpoint only marks intent.
31+
func TestNodeEvacuateMarksFlag(t *testing.T) {
32+
st := store.NewInMemory()
33+
if err := st.Nodes().Create(t.Context(), &apiv1.Node{Name: "n1"}); err != nil {
34+
t.Fatalf("seed: %v", err)
35+
}
36+
37+
base, stop := startServerWithStore(t, st)
38+
defer stop()
39+
40+
resp := httpPost(t, base+"/v1/nodes/n1/evacuate", nil)
41+
_ = resp.Body.Close()
42+
43+
if resp.StatusCode != http.StatusOK {
44+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
45+
}
46+
47+
got, err := st.Nodes().Get(t.Context(), "n1")
48+
if err != nil {
49+
t.Fatalf("get: %v", err)
50+
}
51+
52+
if !slices.Contains(got.Flags, "EVICTED") {
53+
t.Errorf("expected EVICTED flag; got %v", got.Flags)
54+
}
55+
}
56+
57+
// TestNodeRestoreClearsFlag: POST /v1/nodes/{node}/restore removes
58+
// the EVICTED flag.
59+
func TestNodeRestoreClearsFlag(t *testing.T) {
60+
st := store.NewInMemory()
61+
if err := st.Nodes().Create(t.Context(), &apiv1.Node{
62+
Name: "n1",
63+
Flags: []string{"EVICTED"},
64+
}); err != nil {
65+
t.Fatalf("seed: %v", err)
66+
}
67+
68+
base, stop := startServerWithStore(t, st)
69+
defer stop()
70+
71+
resp := httpPost(t, base+"/v1/nodes/n1/restore", nil)
72+
_ = resp.Body.Close()
73+
74+
if resp.StatusCode != http.StatusOK {
75+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
76+
}
77+
78+
got, err := st.Nodes().Get(t.Context(), "n1")
79+
if err != nil {
80+
t.Fatalf("get: %v", err)
81+
}
82+
83+
if slices.Contains(got.Flags, "EVICTED") {
84+
t.Errorf("EVICTED still present: %v", got.Flags)
85+
}
86+
}
87+
88+
// TestNodeLostMarksFlag: POST /v1/nodes/{node}/lost adds LOST and
89+
// EVICTED — `lost` is a permanent action.
90+
func TestNodeLostMarksFlag(t *testing.T) {
91+
st := store.NewInMemory()
92+
if err := st.Nodes().Create(t.Context(), &apiv1.Node{Name: "n1"}); err != nil {
93+
t.Fatalf("seed: %v", err)
94+
}
95+
96+
base, stop := startServerWithStore(t, st)
97+
defer stop()
98+
99+
resp := httpPost(t, base+"/v1/nodes/n1/lost", nil)
100+
_ = resp.Body.Close()
101+
102+
if resp.StatusCode != http.StatusOK {
103+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
104+
}
105+
106+
got, err := st.Nodes().Get(t.Context(), "n1")
107+
if err != nil {
108+
t.Fatalf("get: %v", err)
109+
}
110+
111+
for _, want := range []string{"LOST", "EVICTED"} {
112+
if !slices.Contains(got.Flags, want) {
113+
t.Errorf("expected %s flag; got %v", want, got.Flags)
114+
}
115+
}
116+
}
117+
118+
// TestNodeEvacuateUnknown: 404 if the node doesn't exist.
119+
func TestNodeEvacuateUnknown(t *testing.T) {
120+
base, stop := startServerWithStore(t, store.NewInMemory())
121+
defer stop()
122+
123+
resp := httpPost(t, base+"/v1/nodes/ghost/evacuate", nil)
124+
_ = resp.Body.Close()
125+
126+
if resp.StatusCode != http.StatusNotFound {
127+
t.Errorf("status: got %d, want 404", resp.StatusCode)
128+
}
129+
}

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func (s *Server) Start(ctx context.Context) error {
7474
s.registerPropertiesInfo(mux)
7575
s.registerSnapshotRestore(mux)
7676
s.registerEncryption(mux)
77+
s.registerNodeLifecycle(mux)
7778

7879
srv := &http.Server{
7980
Addr: s.Addr,

0 commit comments

Comments
 (0)