Skip to content

Commit c724779

Browse files
kvapsclaude
andcommitted
feat(rest): cluster-wide stats endpoint
GET /v1/stats returns counts of nodes, resource definitions, resources, storage pools, and snapshots. Errors degrade gracefully (per-section zero rather than 500-ing the whole endpoint) since stats is supposed to be cheap and resilient. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 991b994 commit c724779

4 files changed

Lines changed: 184 additions & 1 deletion

File tree

PLAN.md

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

267267
- [ ] Cluster passphrase management
268268
- [ ] Satellite eviction / restoration / lost-and-recover
269-
- [ ] Stats endpoints (`/v1/stats/*`)
269+
- [x] Stats endpoint (`GET /v1/stats`): cluster-wide counters (nodes, RDs, resources, storage pools, snapshots). 2 contract tests.
270270
- [ ] Error reports, SOS-report
271271
- [ ] All `/v1/view/*` aggregates
272272
- [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.

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func (s *Server) Start(ctx context.Context) error {
6969
s.registerAutoplace(mux)
7070
s.registerControllerProperties(mux)
7171
s.registerAdjust(mux)
72+
s.registerStats(mux)
7273

7374
srv := &http.Server{
7475
Addr: s.Addr,

pkg/rest/stats.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
)
22+
23+
// registerStats wires the cluster-wide counter endpoint. linstor CLI
24+
// uses /v1/stats for `linstor controller list` summaries; monitoring
25+
// stacks scrape it for high-level cluster gauges.
26+
func (s *Server) registerStats(mux *http.ServeMux) {
27+
mux.HandleFunc("GET /v1/stats", s.requireStore(s.handleStats))
28+
}
29+
30+
// handleStats counts top-level objects from the store. Errors at any
31+
// step degrade gracefully — we surface zeros for whatever failed
32+
// rather than 500-ing the whole endpoint, since stats is supposed to
33+
// be cheap and resilient.
34+
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
35+
ctx := r.Context()
36+
37+
out := map[string]int{
38+
statKeyNodes: 0,
39+
statKeyResourceDefinitions: 0,
40+
statKeyResources: 0,
41+
statKeyStoragePools: 0,
42+
statKeySnapshots: 0,
43+
}
44+
45+
nodes, err := s.Store.Nodes().List(ctx)
46+
if err == nil {
47+
out[statKeyNodes] = len(nodes)
48+
}
49+
50+
rds, err := s.Store.ResourceDefinitions().List(ctx)
51+
if err == nil {
52+
out[statKeyResourceDefinitions] = len(rds)
53+
}
54+
55+
res, err := s.Store.Resources().List(ctx)
56+
if err == nil {
57+
out[statKeyResources] = len(res)
58+
}
59+
60+
sps, err := s.Store.StoragePools().List(ctx)
61+
if err == nil {
62+
out[statKeyStoragePools] = len(sps)
63+
}
64+
65+
snaps, err := s.Store.Snapshots().List(ctx)
66+
if err == nil {
67+
out[statKeySnapshots] = len(snaps)
68+
}
69+
70+
writeJSON(w, http.StatusOK, out)
71+
}
72+
73+
const (
74+
statKeyNodes = "nodes"
75+
statKeyResourceDefinitions = "resource_definitions"
76+
statKeyResources = "resources"
77+
statKeyStoragePools = "storage_pools"
78+
statKeySnapshots = "snapshots"
79+
)

pkg/rest/stats_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
// TestStatsAggregates: count nodes, RDs, resources, storage pools,
29+
// snapshots — should match what's in the store.
30+
func TestStatsAggregates(t *testing.T) {
31+
st := store.NewInMemory()
32+
ctx := t.Context()
33+
34+
for _, n := range []string{"n1", "n2", "n3"} {
35+
if err := st.Nodes().Create(ctx, &apiv1.Node{Name: n}); err != nil {
36+
t.Fatalf("seed node %s: %v", n, err)
37+
}
38+
}
39+
40+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
41+
t.Fatalf("seed RD: %v", err)
42+
}
43+
44+
if err := st.Resources().Create(ctx, &apiv1.Resource{Name: "pvc-1", NodeName: "n1"}); err != nil {
45+
t.Fatalf("seed Resource: %v", err)
46+
}
47+
48+
if err := st.StoragePools().Create(ctx, &apiv1.StoragePool{
49+
StoragePoolName: "pool", NodeName: "n1", ProviderKind: apiv1.StoragePoolKindLVMThin,
50+
}); err != nil {
51+
t.Fatalf("seed pool: %v", err)
52+
}
53+
54+
base, stop := startServerWithStore(t, st)
55+
defer stop()
56+
57+
resp := httpGet(t, base+"/v1/stats")
58+
defer func() { _ = resp.Body.Close() }()
59+
60+
if resp.StatusCode != http.StatusOK {
61+
t.Fatalf("status: got %d, want 200", resp.StatusCode)
62+
}
63+
64+
var got map[string]int
65+
66+
err := json.NewDecoder(resp.Body).Decode(&got)
67+
if err != nil {
68+
t.Fatalf("decode: %v", err)
69+
}
70+
71+
for k, want := range map[string]int{
72+
"nodes": 3,
73+
"resource_definitions": 1,
74+
"resources": 1,
75+
"storage_pools": 1,
76+
} {
77+
if got[k] != want {
78+
t.Errorf("%s: got %d, want %d", k, got[k], want)
79+
}
80+
}
81+
}
82+
83+
// TestStatsEmptyStore: zero counts on a fresh cluster.
84+
func TestStatsEmptyStore(t *testing.T) {
85+
base, stop := startServerWithStore(t, store.NewInMemory())
86+
defer stop()
87+
88+
resp := httpGet(t, base+"/v1/stats")
89+
defer func() { _ = resp.Body.Close() }()
90+
91+
if resp.StatusCode != http.StatusOK {
92+
t.Fatalf("status: got %d", resp.StatusCode)
93+
}
94+
95+
var got map[string]int
96+
_ = json.NewDecoder(resp.Body).Decode(&got)
97+
98+
for _, k := range []string{"nodes", "resource_definitions", "resources", "storage_pools", "snapshots"} {
99+
if got[k] != 0 {
100+
t.Errorf("%s on empty store: got %d, want 0", k, got[k])
101+
}
102+
}
103+
}

0 commit comments

Comments
 (0)