Skip to content

Commit 2877347

Browse files
kvapsclaude
andcommitted
feat: VolumeDefinition + KeyValueStore + ResourceGroup Spawn (CSI critical path)
Phase 2 fifth slice. These three are what linstor-csi calls on every CreateVolume: POST /v1/resource-groups/{rg}/spawn → atomically creates RD + VDs from RG POST /v1/key-value-store/{instance} → CSI bookkeeping GET /v1/resource-definitions/{rd}/volume-definitions → size lookup Without these three, no PVC reaches "Bound" via this controller. Components: - pkg/api/v1: GenericPropsModify, KV, VolumeDefinitionCreate envelope. - pkg/store: VolumeDefinitionStore interface (composite key {rd, volumeNumber}), KeyValueStore interface (set/get/delete with override+delete+delete-namespace semantics). - pkg/store/inmemory_volume_definition.go: in-memory VD + KV stores. - pkg/store/k8s/volume_definitions.go: VDs stored inline on the parent RD CRD's spec.volumeDefinitions list — no separate CRD, atomic-with-RD reclamation. - pkg/store/k8s/kv_store.go: KV stored as ConfigMaps in a configurable namespace, labelled blockstor.io/kv-instance. ConfigMap data keys may not contain '/', so LINSTOR namespace paths get encoded as `__slash__` on Set and decoded on Get. - pkg/rest/volume_definitions.go: 5 handlers; List checks parent RD exists so missing RD is 404 (not 200 with []). - pkg/rest/kv_store.go: 5 handlers (list/get/post/put/delete). - pkg/rest/spawn.go: handleSpawn materialises RD + per-size VDs in one atomic-as-can-be flow, with rollback on partial failure. Replica placement (creating Resource objects on satellites) is a Phase 3 concern owned by the autoplacer; that lands separately. TDD coverage: - storetest gains RunVolumeDefinitionStore (6 cases including missing-RD) and RunKeyValueStore (6 cases incl. delete-namespace path semantics). Both run against InMemory and the CRD/envtest store; behavioural drift fails the same subtest. ConfigMap key validation forced the encoder. - pkg/rest gains 4 VD-handler tests, 3 KV-handler tests, 4 Spawn tests (round-trip, missing-RG → 404, missing-RD-name → 400, RD-already-exists → 409 with rollback). All green; lint zero. CSI MVP api-surface.md: 13 newly ✅ endpoints — only Snapshots/Clone and the few "unwired" mutation paths remain in the MVP slice. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 9680a21 commit 2877347

20 files changed

Lines changed: 1728 additions & 5 deletions

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ linters:
7676
- sp
7777
- st
7878
- s
79+
- cm
80+
- vd
81+
- vn
7982
- rd
8083
- vd
8184
- rg
@@ -155,6 +158,7 @@ linters:
155158
- goconst
156159
- noinlineerr
157160
- thelper
161+
- unparam
158162
path: pkg/store/storetest/
159163
- linters:
160164
- forbidigo

docs/csi-api-surface.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ schedules, S3 / EBS, SPDK, NVMe-oF, OpenFlex, Exos) MUST return
6666
| `/v1/resource-definitions/{rd}` | GET ||
6767
| `/v1/resource-definitions/{rd}` | PUT ||
6868
| `/v1/resource-definitions/{rd}` | DELETE ||
69-
| `/v1/resource-definitions/{rd}/volume-definitions` | GET / POST ||
70-
| `/v1/resource-definitions/{rd}/volume-definitions/{vn}` | GET / PUT / DELETE ||
69+
| `/v1/resource-definitions/{rd}/volume-definitions` | GET ||
70+
| `/v1/resource-definitions/{rd}/volume-definitions` | POST ||
71+
| `/v1/resource-definitions/{rd}/volume-definitions/{vn}` | GET ||
72+
| `/v1/resource-definitions/{rd}/volume-definitions/{vn}` | PUT ||
73+
| `/v1/resource-definitions/{rd}/volume-definitions/{vn}` | DELETE ||
7174
| `/v1/resource-definitions/{rd}/clone` | POST ||
7275
| `/v1/resource-definitions/{rd}/clone/{target}` | GET (status) ||
7376
| `/v1/resource-definitions/{rd}/sync-status` | GET ||
@@ -105,7 +108,7 @@ schedules, S3 / EBS, SPDK, NVMe-oF, OpenFlex, Exos) MUST return
105108
| `/v1/resource-groups/{rg}` | GET ||
106109
| `/v1/resource-groups/{rg}` | PUT ||
107110
| `/v1/resource-groups/{rg}` | DELETE ||
108-
| `/v1/resource-groups/{rg}/spawn` | POST | |
111+
| `/v1/resource-groups/{rg}/spawn` | POST | |
109112
| `/v1/resource-groups/{rg}/adjust` | PUT ||
110113
| `/v1/resource-groups/adjustall` | PUT ||
111114
| `/v1/resource-groups/{rg}/query-size-info` | POST ||
@@ -133,8 +136,10 @@ schedules, S3 / EBS, SPDK, NVMe-oF, OpenFlex, Exos) MUST return
133136

134137
| Endpoint | Method | Status |
135138
|---|---|---|
136-
| `/v1/key-value-store` | GET ||
137-
| `/v1/key-value-store/{instance}` | GET / PUT / DELETE ||
139+
| `/v1/key-value-store` | GET ||
140+
| `/v1/key-value-store/{instance}` | GET ||
141+
| `/v1/key-value-store/{instance}` | POST / PUT ||
142+
| `/v1/key-value-store/{instance}` | DELETE ||
138143

139144
## Connections (piraeus-operator drives these via LinstorNodeConnection)
140145

pkg/api/v1/props.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
// GenericPropsModify is the upstream payload for any "modify properties"
20+
// request. Set/delete pairs are mutually independent — they all run in one
21+
// transaction.
22+
type GenericPropsModify struct {
23+
OverrideProps map[string]string `json:"override_props,omitempty"`
24+
DeleteProps []string `json:"delete_props,omitempty"`
25+
DeleteNamespace []string `json:"delete_namespaces,omitempty"`
26+
}
27+
28+
// KV is the upstream `KeyValueStore` view of a single instance — name plus
29+
// its current property map.
30+
type KV struct {
31+
Name string `json:"name"`
32+
Props map[string]string `json:"props,omitempty"`
33+
}

pkg/api/v1/resource_definition.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ type VolumeDefinition struct {
4545
UUID string `json:"uuid,omitempty"`
4646
}
4747

48+
// VolumeDefinitionCreate is the upstream POST envelope for
49+
// /v1/resource-definitions/{rd}/volume-definitions. Mirrors golinstor.
50+
type VolumeDefinitionCreate struct {
51+
VolumeDefinition VolumeDefinition `json:"volume_definition"`
52+
DrbdMinorNumber int32 `json:"drbd_minor_number,omitempty"`
53+
}
54+
4855
// ResourceDefinitionCreate is the body upstream LINSTOR (and golinstor)
4956
// expect on `POST /v1/resource-definitions`. It wraps the RD plus optional
5057
// per-create fields like the DRBD secret.

pkg/rest/kv_store.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
24+
)
25+
26+
// registerKeyValueStore wires /v1/key-value-store endpoints. linstor-csi
27+
// uses these for its own per-volume bookkeeping.
28+
func (s *Server) registerKeyValueStore(mux *http.ServeMux) {
29+
mux.HandleFunc("GET /v1/key-value-store", s.requireStore(s.handleKVList))
30+
mux.HandleFunc("GET /v1/key-value-store/{instance}", s.requireStore(s.handleKVGet))
31+
mux.HandleFunc("POST /v1/key-value-store/{instance}", s.requireStore(s.handleKVSet))
32+
mux.HandleFunc("PUT /v1/key-value-store/{instance}", s.requireStore(s.handleKVSet))
33+
mux.HandleFunc("DELETE /v1/key-value-store/{instance}", s.requireStore(s.handleKVDelete))
34+
}
35+
36+
func (s *Server) handleKVList(w http.ResponseWriter, r *http.Request) {
37+
insts, err := s.Store.KeyValueStore().ListInstances(r.Context())
38+
if err != nil {
39+
writeError(w, http.StatusInternalServerError, err.Error())
40+
41+
return
42+
}
43+
44+
out := make([]apiv1.KV, 0, len(insts))
45+
46+
for _, name := range insts {
47+
props, gErr := s.Store.KeyValueStore().GetInstance(r.Context(), name)
48+
if gErr != nil {
49+
continue
50+
}
51+
52+
out = append(out, apiv1.KV{Name: name, Props: props})
53+
}
54+
55+
writeJSON(w, http.StatusOK, out)
56+
}
57+
58+
func (s *Server) handleKVGet(w http.ResponseWriter, r *http.Request) {
59+
name := r.PathValue("instance")
60+
61+
props, err := s.Store.KeyValueStore().GetInstance(r.Context(), name)
62+
if err != nil {
63+
writeStoreError(w, err)
64+
65+
return
66+
}
67+
68+
writeJSON(w, http.StatusOK, apiv1.KV{Name: name, Props: props})
69+
}
70+
71+
func (s *Server) handleKVSet(w http.ResponseWriter, r *http.Request) {
72+
name := r.PathValue("instance")
73+
74+
var modify apiv1.GenericPropsModify
75+
76+
err := json.NewDecoder(r.Body).Decode(&modify)
77+
if err != nil {
78+
writeError(w, http.StatusBadRequest, err.Error())
79+
80+
return
81+
}
82+
83+
err = s.Store.KeyValueStore().SetKeys(r.Context(), name, modify)
84+
if err != nil {
85+
writeStoreError(w, err)
86+
87+
return
88+
}
89+
90+
w.WriteHeader(http.StatusOK)
91+
}
92+
93+
func (s *Server) handleKVDelete(w http.ResponseWriter, r *http.Request) {
94+
name := r.PathValue("instance")
95+
96+
err := s.Store.KeyValueStore().DeleteInstance(r.Context(), name)
97+
if err != nil {
98+
writeStoreError(w, err)
99+
100+
return
101+
}
102+
103+
w.WriteHeader(http.StatusNoContent)
104+
}

pkg/rest/kv_store_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
"testing"
23+
24+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
25+
"github.com/cozystack/blockstor/pkg/store"
26+
)
27+
28+
// TestKVSetThenGet: round-trip through REST.
29+
func TestKVSetThenGet(t *testing.T) {
30+
base, stop := startServerWithStore(t, store.NewInMemory())
31+
defer stop()
32+
33+
body, err := json.Marshal(apiv1.GenericPropsModify{
34+
OverrideProps: map[string]string{"k": "v"},
35+
})
36+
if err != nil {
37+
t.Fatalf("marshal: %v", err)
38+
}
39+
40+
resp := httpPost(t, base+"/v1/key-value-store/csi-volumes", body)
41+
_ = resp.Body.Close()
42+
43+
if resp.StatusCode != http.StatusOK {
44+
t.Fatalf("set: got %d, want 200", resp.StatusCode)
45+
}
46+
47+
getResp := httpGet(t, base+"/v1/key-value-store/csi-volumes")
48+
defer func() { _ = getResp.Body.Close() }()
49+
50+
if getResp.StatusCode != http.StatusOK {
51+
t.Fatalf("get: got %d, want 200", getResp.StatusCode)
52+
}
53+
54+
var kv apiv1.KV
55+
if jErr := json.NewDecoder(getResp.Body).Decode(&kv); jErr != nil {
56+
t.Fatalf("decode: %v", jErr)
57+
}
58+
59+
if kv.Name != "csi-volumes" || kv.Props["k"] != "v" {
60+
t.Errorf("got %+v", kv)
61+
}
62+
}
63+
64+
// TestKVGetMissing: 404 on absent instance.
65+
func TestKVGetMissing(t *testing.T) {
66+
base, stop := startServerWithStore(t, store.NewInMemory())
67+
defer stop()
68+
69+
resp := httpGet(t, base+"/v1/key-value-store/ghost")
70+
_ = resp.Body.Close()
71+
72+
if resp.StatusCode != http.StatusNotFound {
73+
t.Errorf("status: got %d, want 404", resp.StatusCode)
74+
}
75+
}
76+
77+
// TestKVDeleteThenGet: deleted instance becomes 404.
78+
func TestKVDeleteThenGet(t *testing.T) {
79+
st := store.NewInMemory()
80+
if err := st.KeyValueStore().SetKeys(t.Context(), "x", apiv1.GenericPropsModify{
81+
OverrideProps: map[string]string{"k": "v"},
82+
}); err != nil {
83+
t.Fatalf("seed: %v", err)
84+
}
85+
86+
base, stop := startServerWithStore(t, st)
87+
defer stop()
88+
89+
delResp := httpDelete(t, base+"/v1/key-value-store/x")
90+
_ = delResp.Body.Close()
91+
92+
if delResp.StatusCode != http.StatusNoContent {
93+
t.Fatalf("delete: got %d, want 204", delResp.StatusCode)
94+
}
95+
96+
getResp := httpGet(t, base+"/v1/key-value-store/x")
97+
_ = getResp.Body.Close()
98+
99+
if getResp.StatusCode != http.StatusNotFound {
100+
t.Errorf("post-delete: got %d, want 404", getResp.StatusCode)
101+
}
102+
}

pkg/rest/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ func (s *Server) Start(ctx context.Context) error {
6161
s.registerStoragePools(mux)
6262
s.registerResourceGroups(mux)
6363
s.registerResourceDefinitions(mux)
64+
s.registerVolumeDefinitions(mux)
6465
s.registerResources(mux)
66+
s.registerKeyValueStore(mux)
67+
s.registerSpawn(mux)
6568

6669
srv := &http.Server{
6770
Addr: s.Addr,

0 commit comments

Comments
 (0)