Skip to content

Commit 30e346e

Browse files
kvapsclaude
andcommitted
feat(rest): controller properties endpoints over KV store
GET/POST /v1/controller/properties surface the cluster-wide property bag that linstor CLI's `controller list-properties` / `set-property` expect. Backed by the existing KV store under instance "ControllerProps" so the read-modify-write semantic and CRD scaling story carry over. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 1ba2c9b commit 30e346e

4 files changed

Lines changed: 222 additions & 0 deletions

File tree

PLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
269269
- [ ] Stats endpoints (`/v1/stats/*`)
270270
- [ ] Error reports, SOS-report
271271
- [ ] All `/v1/view/*` aggregates
272+
- [x] Controller properties endpoints (`/v1/controller/properties` GET/POST) — backed by KV-store instance "ControllerProps". Covers `linstor controller list-properties` / `set-property`. 3 contract tests.
272273
- [ ] Property-info endpoints (`*/properties/info`)
273274
- [ ] Resource adjust / adjust-all
274275

pkg/rest/controller_props.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
"context"
21+
"encoding/json"
22+
"net/http"
23+
24+
"github.com/cockroachdb/errors"
25+
26+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
27+
"github.com/cozystack/blockstor/pkg/store"
28+
)
29+
30+
// registerControllerProperties wires the cluster-wide controller
31+
// property bag. linstor CLI's `controller list-properties` calls
32+
// /v1/controller/properties; `controller set-property` calls POST.
33+
func (s *Server) registerControllerProperties(mux *http.ServeMux) {
34+
mux.HandleFunc("GET /v1/controller/properties", s.requireStore(s.handleControllerPropsGet))
35+
mux.HandleFunc("POST /v1/controller/properties", s.requireStore(s.handleControllerPropsModify))
36+
}
37+
38+
// handleControllerPropsGet returns the controller property map. We
39+
// store it under a fixed KV instance ("ControllerProps") so it shares
40+
// the same scaling story as the per-resource KV state.
41+
func (s *Server) handleControllerPropsGet(w http.ResponseWriter, r *http.Request) {
42+
props, err := s.Store.KeyValueStore().GetInstance(r.Context(), controllerPropsInstance)
43+
if err != nil && !errors.Is(err, store.ErrNotFound) {
44+
writeError(w, http.StatusInternalServerError, err.Error())
45+
46+
return
47+
}
48+
49+
if props == nil {
50+
props = map[string]string{}
51+
}
52+
53+
writeJSON(w, http.StatusOK, props)
54+
}
55+
56+
// handleControllerPropsModify applies a GenericPropsModify in one
57+
// transaction. Set keys take precedence over delete keys when both
58+
// reference the same key (LINSTOR's behaviour).
59+
func (s *Server) handleControllerPropsModify(w http.ResponseWriter, r *http.Request) {
60+
var modify apiv1.GenericPropsModify
61+
62+
err := json.NewDecoder(r.Body).Decode(&modify)
63+
if err != nil {
64+
writeError(w, http.StatusBadRequest, err.Error())
65+
66+
return
67+
}
68+
69+
err = applyControllerProps(r.Context(), s.Store, &modify)
70+
if err != nil {
71+
writeError(w, http.StatusInternalServerError, err.Error())
72+
73+
return
74+
}
75+
76+
w.WriteHeader(http.StatusOK)
77+
}
78+
79+
// applyControllerProps delegates to the KV store's SetKeys, which
80+
// already implements the upstream "merge into existing" semantic. We
81+
// keep a wrapper for the named instance + error mapping.
82+
func applyControllerProps(ctx context.Context, st store.Store, modify *apiv1.GenericPropsModify) error {
83+
err := st.KeyValueStore().SetKeys(ctx, controllerPropsInstance, *modify)
84+
if err != nil {
85+
return errors.Wrap(err, "write controller props")
86+
}
87+
88+
return nil
89+
}
90+
91+
// controllerPropsInstance is the KV-store instance name we reuse for
92+
// the cluster-wide controller property bag. Picked to match the
93+
// upstream "ControllerProps" namespace so satellites that look it up
94+
// by name keep working.
95+
const controllerPropsInstance = "ControllerProps"

pkg/rest/controller_props_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
// TestControllerPropertiesEmptyOnFreshCluster: GET on a brand-new
29+
// controller returns 200 with an empty props map.
30+
func TestControllerPropertiesEmptyOnFreshCluster(t *testing.T) {
31+
base, stop := startServerWithStore(t, store.NewInMemory())
32+
defer stop()
33+
34+
resp := httpGet(t, base+"/v1/controller/properties")
35+
defer func() { _ = resp.Body.Close() }()
36+
37+
if resp.StatusCode != http.StatusOK {
38+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
39+
}
40+
41+
var got map[string]string
42+
43+
err := json.NewDecoder(resp.Body).Decode(&got)
44+
if err != nil {
45+
t.Fatalf("decode: %v", err)
46+
}
47+
48+
if len(got) != 0 {
49+
t.Errorf("expected empty props; got %v", got)
50+
}
51+
}
52+
53+
// TestControllerPropertiesSetAndGet: PUT writes, GET reads back.
54+
func TestControllerPropertiesSetAndGet(t *testing.T) {
55+
base, stop := startServerWithStore(t, store.NewInMemory())
56+
defer stop()
57+
58+
body, _ := json.Marshal(apiv1.GenericPropsModify{
59+
OverrideProps: map[string]string{"DefaultDebugSslConnector": "DebugSslConnector"},
60+
})
61+
62+
resp := httpPost(t, base+"/v1/controller/properties", body)
63+
_ = resp.Body.Close()
64+
65+
if resp.StatusCode != http.StatusOK {
66+
t.Fatalf("PUT status: got %d, want 200", resp.StatusCode)
67+
}
68+
69+
getResp := httpGet(t, base+"/v1/controller/properties")
70+
defer func() { _ = getResp.Body.Close() }()
71+
72+
if getResp.StatusCode != http.StatusOK {
73+
t.Fatalf("GET status: got %d", getResp.StatusCode)
74+
}
75+
76+
var got map[string]string
77+
78+
err := json.NewDecoder(getResp.Body).Decode(&got)
79+
if err != nil {
80+
t.Fatalf("decode: %v", err)
81+
}
82+
83+
if got["DefaultDebugSslConnector"] != "DebugSslConnector" {
84+
t.Errorf("expected DefaultDebugSslConnector=DebugSslConnector; got %v", got)
85+
}
86+
}
87+
88+
// TestControllerPropertiesDelete: PUT with delete_props removes a key.
89+
func TestControllerPropertiesDelete(t *testing.T) {
90+
base, stop := startServerWithStore(t, store.NewInMemory())
91+
defer stop()
92+
93+
// Seed two keys.
94+
body, _ := json.Marshal(apiv1.GenericPropsModify{
95+
OverrideProps: map[string]string{"KeepMe": "1", "RemoveMe": "2"},
96+
})
97+
resp := httpPost(t, base+"/v1/controller/properties", body)
98+
_ = resp.Body.Close()
99+
100+
// Delete one.
101+
delBody, _ := json.Marshal(apiv1.GenericPropsModify{
102+
DeleteProps: []string{"RemoveMe"},
103+
})
104+
105+
delResp := httpPost(t, base+"/v1/controller/properties", delBody)
106+
_ = delResp.Body.Close()
107+
108+
if delResp.StatusCode != http.StatusOK {
109+
t.Fatalf("delete status: got %d", delResp.StatusCode)
110+
}
111+
112+
getResp := httpGet(t, base+"/v1/controller/properties")
113+
defer func() { _ = getResp.Body.Close() }()
114+
115+
var got map[string]string
116+
_ = json.NewDecoder(getResp.Body).Decode(&got)
117+
118+
if _, present := got["RemoveMe"]; present {
119+
t.Errorf("RemoveMe still in props: %v", got)
120+
}
121+
122+
if got["KeepMe"] != "1" {
123+
t.Errorf("KeepMe lost: got %v", got)
124+
}
125+
}

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func (s *Server) Start(ctx context.Context) error {
6767
s.registerSnapshots(mux)
6868
s.registerSpawn(mux)
6969
s.registerAutoplace(mux)
70+
s.registerControllerProperties(mux)
7071

7172
srv := &http.Server{
7273
Addr: s.Addr,

0 commit comments

Comments
 (0)