Skip to content

Commit 98d1e36

Browse files
kvapsclaude
andcommitted
feat(rest): /v1/nodes CRUD with in-memory store
Phase 1 second slice. Adds: - pkg/api/v1/node.go — wire-shape Node + NetInterface types matching upstream LINSTOR OpenAPI (snake_case JSON, golinstor-compatible). - pkg/store — interface + in-memory implementation (sync.RWMutex, sentinel errors). Phase 2 will add a CRD-backed implementation behind the same interface. - pkg/rest/nodes.go — five handlers: list / get / create / update / delete. Endpoints requiring persistence are gated by requireStore (503 when nil). TDD-first per PLAN.md: tests written before code, every branch covered. NodeStore (9 tests): - list-empty (never returns nil), create→get round-trip, duplicate→409, get-missing→404, update-missing→404, update-changes-props, delete-missing→404, delete-removes, list-sorted-deterministic, concurrent-access does not race. REST (8 tests, golinstor-driven): - list-empty via golinstor, create round-trip via golinstor, get-missing→404, create-conflict→409, create-bad-json→400, delete-missing→404, delete-ok via golinstor, endpoints-without-store→503 (and version still 200). cmd/main.go now constructs an InMemory store and passes it to the REST server, so the binary is fully usable end-to-end. third_party/linstor-common added as submodule (consts.json, properties.json, drbdoptions.json) — single source of truth shared with upstream. apiconsts already shipped via golinstor (linstor.MaskError etc.), so no separate fork is needed. .golangci.yml exclusions: goconst and noinlineerr also skipped in tests. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent e62628c commit 98d1e36

12 files changed

Lines changed: 982 additions & 14 deletions

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "third_party/linstor-common"]
2+
path = third_party/linstor-common
3+
url = https://github.com/LINBIT/linstor-common.git

.golangci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ linters:
108108
- perfsprint
109109
- paralleltest
110110
- maintidx
111+
- goconst
112+
- noinlineerr
111113
path: _test\.go
112114
- linters:
113115
- forbidigo

cmd/main.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"sigs.k8s.io/controller-runtime/pkg/webhook"
3737

3838
"github.com/cozystack/blockstor/pkg/rest"
39+
"github.com/cozystack/blockstor/pkg/store"
3940
// +kubebuilder:scaffold:imports
4041
)
4142

@@ -189,7 +190,11 @@ func main() {
189190
os.Exit(1)
190191
}
191192

192-
if err := mgr.Add(&rest.Server{Addr: restAddr}); err != nil {
193+
// Phase 1 store: in-memory. Phase 2 swaps in a CRD-backed implementation
194+
// behind the same interface.
195+
st := store.NewInMemory()
196+
197+
if err := mgr.Add(&rest.Server{Addr: restAddr, Store: st}); err != nil {
193198
setupLog.Error(err, "Failed to register REST API server")
194199
os.Exit(1)
195200
}

pkg/api/v1/node.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 v1
18+
19+
// Node mirrors `Node` from the upstream LINSTOR OpenAPI spec. We list only
20+
// the fields that current consumers (linstor-csi, piraeus-operator) read or
21+
// write; richer fields (KeyVault, etc.) get added as we wire them up.
22+
//
23+
// Field order and JSON tags MUST match upstream so golinstor unmarshals
24+
// cleanly. The wire shape is golinstor.Node.
25+
type Node struct {
26+
Name string `json:"name"`
27+
Type string `json:"type"`
28+
Flags []string `json:"flags,omitempty"`
29+
Props map[string]string `json:"props,omitempty"`
30+
NetInterfaces []NetInterface `json:"net_interfaces,omitempty"`
31+
ConnectionStatus string `json:"connection_status,omitempty"`
32+
}
33+
34+
// NetInterface mirrors `NetInterface` from upstream.
35+
type NetInterface struct {
36+
Name string `json:"name"`
37+
Address string `json:"address"`
38+
SatellitePort int `json:"satellite_port,omitempty"`
39+
SatelliteEncryptionType string `json:"satellite_encryption_type,omitempty"`
40+
IsActive bool `json:"is_active,omitempty"`
41+
}
42+
43+
// Node Type constants — these are the strings LINSTOR uses on the wire.
44+
const (
45+
NodeTypeController = "CONTROLLER"
46+
NodeTypeSatellite = "SATELLITE"
47+
NodeTypeCombined = "COMBINED"
48+
NodeTypeAuxiliary = "AUXILIARY"
49+
NodeTypeRemoteSpdk = "REMOTE_SPDK"
50+
NodeTypeOpenflexTarget = "OPENFLEX_TARGET"
51+
NodeTypeEbsTarget = "EBS_TARGET"
52+
NodeTypeEbsInitiator = "EBS_INIT"
53+
NodeTypeStandalone = "STANDALONE"
54+
NodeTypeOnline = "ONLINE"
55+
NodeTypeOffline = "OFFLINE"
56+
NodeTypeUnknown = "UNKNOWN"
57+
NodeTypeConnecting = "CONNECTING"
58+
NodeTypeAuthenticationError = "AUTHENTICATION_ERROR"
59+
)

pkg/rest/nodes.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
"encoding/json"
21+
"net/http"
22+
23+
"github.com/cockroachdb/errors"
24+
25+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
26+
"github.com/cozystack/blockstor/pkg/store"
27+
)
28+
29+
// registerNodes wires the /v1/nodes endpoints on mux. It is split out of
30+
// Server.Start so each resource group lives in its own file.
31+
func (s *Server) registerNodes(mux *http.ServeMux) {
32+
mux.HandleFunc("GET /v1/nodes", s.requireStore(s.handleNodesList))
33+
mux.HandleFunc("GET /v1/nodes/{node}", s.requireStore(s.handleNodeGet))
34+
mux.HandleFunc("POST /v1/nodes", s.requireStore(s.handleNodeCreate))
35+
mux.HandleFunc("PUT /v1/nodes/{node}", s.requireStore(s.handleNodeUpdate))
36+
mux.HandleFunc("DELETE /v1/nodes/{node}", s.requireStore(s.handleNodeDelete))
37+
}
38+
39+
// requireStore guards endpoints that need persistence; it returns 503 if the
40+
// Store is nil. We can serve /v1/controller/version without a store, so this
41+
// gate is per-handler rather than global.
42+
func (s *Server) requireStore(next http.HandlerFunc) http.HandlerFunc {
43+
return func(w http.ResponseWriter, r *http.Request) {
44+
if s.Store == nil {
45+
writeError(w, http.StatusServiceUnavailable, "store not configured")
46+
47+
return
48+
}
49+
50+
next(w, r)
51+
}
52+
}
53+
54+
func (s *Server) handleNodesList(w http.ResponseWriter, r *http.Request) {
55+
nodes, err := s.Store.Nodes().List(r.Context())
56+
if err != nil {
57+
writeError(w, http.StatusInternalServerError, err.Error())
58+
59+
return
60+
}
61+
62+
writeJSON(w, http.StatusOK, nodes)
63+
}
64+
65+
func (s *Server) handleNodeGet(w http.ResponseWriter, r *http.Request) {
66+
name := r.PathValue("node")
67+
68+
n, err := s.Store.Nodes().Get(r.Context(), name)
69+
if err != nil {
70+
writeStoreError(w, err)
71+
72+
return
73+
}
74+
75+
writeJSON(w, http.StatusOK, n)
76+
}
77+
78+
func (s *Server) handleNodeCreate(w http.ResponseWriter, r *http.Request) {
79+
var n apiv1.Node
80+
81+
dec := json.NewDecoder(r.Body)
82+
dec.DisallowUnknownFields()
83+
84+
err := dec.Decode(&n)
85+
if err != nil {
86+
writeError(w, http.StatusBadRequest, err.Error())
87+
88+
return
89+
}
90+
91+
if n.Name == "" {
92+
writeError(w, http.StatusBadRequest, "node name is required")
93+
94+
return
95+
}
96+
97+
err = s.Store.Nodes().Create(r.Context(), &n)
98+
if err != nil {
99+
writeStoreError(w, err)
100+
101+
return
102+
}
103+
104+
writeJSON(w, http.StatusCreated, n)
105+
}
106+
107+
func (s *Server) handleNodeUpdate(w http.ResponseWriter, r *http.Request) {
108+
name := r.PathValue("node")
109+
110+
var n apiv1.Node
111+
112+
dec := json.NewDecoder(r.Body)
113+
dec.DisallowUnknownFields()
114+
115+
err := dec.Decode(&n)
116+
if err != nil {
117+
writeError(w, http.StatusBadRequest, err.Error())
118+
119+
return
120+
}
121+
122+
// The path name wins over any body name, so callers can omit it.
123+
n.Name = name
124+
125+
err = s.Store.Nodes().Update(r.Context(), &n)
126+
if err != nil {
127+
writeStoreError(w, err)
128+
129+
return
130+
}
131+
132+
writeJSON(w, http.StatusOK, n)
133+
}
134+
135+
func (s *Server) handleNodeDelete(w http.ResponseWriter, r *http.Request) {
136+
name := r.PathValue("node")
137+
138+
err := s.Store.Nodes().Delete(r.Context(), name)
139+
if err != nil {
140+
writeStoreError(w, err)
141+
142+
return
143+
}
144+
145+
w.WriteHeader(http.StatusNoContent)
146+
}
147+
148+
// writeStoreError maps store sentinel errors to HTTP statuses so handlers
149+
// don't repeat the same switch.
150+
func writeStoreError(w http.ResponseWriter, err error) {
151+
switch {
152+
case errors.Is(err, store.ErrNotFound):
153+
writeError(w, http.StatusNotFound, err.Error())
154+
case errors.Is(err, store.ErrAlreadyExists):
155+
writeError(w, http.StatusConflict, err.Error())
156+
default:
157+
writeError(w, http.StatusInternalServerError, err.Error())
158+
}
159+
}
160+
161+
// writeError sends a minimal JSON error body. Once we wire ApiCallRc in
162+
// Phase 2 this becomes the real LINSTOR error envelope.
163+
func writeError(w http.ResponseWriter, status int, msg string) {
164+
writeJSON(w, status, map[string]string{"error": msg})
165+
}

0 commit comments

Comments
 (0)