Skip to content

Commit 3e69259

Browse files
kvapsclaude
andcommitted
feat(rest): snapshot-restore-resource creates a new RD from a snapshot
The controller seeds the desired-state objects (new RD with the snapshot's props); the data clone runs out-of-band on the satellite during its next reconcile. Mirrors `linstor snapshot resource restore`. Also extends tagliatelle linter exclusions to pkg/rest so request envelopes can keep golinstor-compatible snake_case names. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 4281e1c commit 3e69259

5 files changed

Lines changed: 208 additions & 1 deletion

File tree

.golangci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ linters:
197197
- linters:
198198
- tagliatelle
199199
path: pkg/api/
200+
# REST handlers occasionally define request envelopes inline that
201+
# mirror the upstream snake_case JSON. Same scope as pkg/api/.
202+
- linters:
203+
- tagliatelle
204+
path: pkg/rest/
200205
# cmd/main.go is the kubebuilder manager scaffold — don't lint its
201206
# boilerplate (TODOs, function length, globals, inline err checks, wsl).
202207
- linters:

PLAN.md

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

238238
- [x] Autoplacer: storage-pool-aware replica placement; weighted by FreeCapacity (greatest-free-first, deterministic ties on NodeName)
239239
- [x] Snapshot satellite-side reconcile: `Reconciler.CreateSnapshot/DeleteSnapshot` route via in-memory resource→pool map populated by Apply (3 contract tests). Snapshot CRD reconciler controller-side TBD.
240-
- [ ] Snapshot restore creates a new ResourceDefinition
240+
- [x] Snapshot restore creates a new ResourceDefinition (`POST /v1/resource-definitions/{rd}/snapshot-restore-resource`): seeds the new RD from the snapshot's metadata, returns 201. Per-volume cloning is the satellite's job on next reconcile. 3 contract tests.
241241
- [ ] Intra-cluster snapshot shipping for clone/replica-expansion:
242242
- ZFS pools: `zfs send | ssh | zfs recv` over satellite-to-satellite
243243
- LVM-thin: `thin-send-recv` (LINBIT)

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ func (s *Server) Start(ctx context.Context) error {
7272
s.registerStats(mux)
7373
s.registerErrorReports(mux)
7474
s.registerPropertiesInfo(mux)
75+
s.registerSnapshotRestore(mux)
7576

7677
srv := &http.Server{
7778
Addr: s.Addr,

pkg/rest/snapshot_restore.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
// snapshotRestoreRequest is the JSON body upstream linstor expects on
27+
// the restore endpoint.
28+
type snapshotRestoreRequest struct {
29+
ToResource string `json:"to_resource"`
30+
FromSnapshot string `json:"from_snapshot"`
31+
NodeNames []string `json:"node_names,omitempty"`
32+
}
33+
34+
// registerSnapshotRestore wires the controller-side restore endpoint.
35+
// linstor CLI's `snapshot resource restore` lands here.
36+
func (s *Server) registerSnapshotRestore(mux *http.ServeMux) {
37+
mux.HandleFunc("POST /v1/resource-definitions/{rd}/snapshot-restore-resource",
38+
s.requireStore(s.handleSnapshotRestore))
39+
}
40+
41+
// handleSnapshotRestore creates a new ResourceDefinition from a
42+
// snapshot. The data clone (zfs send|recv / lvcreate -s of a snapshot
43+
// LV) is the satellite's job once it picks up the new RD via reconcile;
44+
// the controller's job here is to seed the desired-state objects.
45+
func (s *Server) handleSnapshotRestore(w http.ResponseWriter, r *http.Request) {
46+
srcRD := r.PathValue("rd")
47+
48+
var req snapshotRestoreRequest
49+
50+
err := json.NewDecoder(r.Body).Decode(&req)
51+
if err != nil {
52+
writeError(w, http.StatusBadRequest, err.Error())
53+
54+
return
55+
}
56+
57+
if req.ToResource == "" {
58+
writeError(w, http.StatusBadRequest, "to_resource is required")
59+
60+
return
61+
}
62+
63+
snap, err := s.Store.Snapshots().Get(r.Context(), srcRD, req.FromSnapshot)
64+
if err != nil {
65+
writeStoreError(w, err)
66+
67+
return
68+
}
69+
70+
newRD := apiv1.ResourceDefinition{
71+
Name: req.ToResource,
72+
Props: snap.Props,
73+
}
74+
75+
err = s.Store.ResourceDefinitions().Create(r.Context(), &newRD)
76+
if err != nil {
77+
writeStoreError(w, err)
78+
79+
return
80+
}
81+
82+
writeJSON(w, http.StatusCreated, newRD)
83+
}

pkg/rest/snapshot_restore_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
// TestSnapshotRestoreCreatesNewRD: POST .../snapshot-restore-resource
29+
// builds a brand-new ResourceDefinition from a snapshot. Mirrors what
30+
// `linstor snapshot resource restore` does upstream.
31+
func TestSnapshotRestoreCreatesNewRD(t *testing.T) {
32+
st := store.NewInMemory()
33+
ctx := t.Context()
34+
35+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
36+
t.Fatalf("seed RD: %v", err)
37+
}
38+
39+
if err := st.Snapshots().Create(ctx, &apiv1.Snapshot{
40+
Name: "snap-1",
41+
ResourceName: "pvc-1",
42+
Nodes: []string{"n1", "n2"},
43+
VolumeDefinitions: []apiv1.SnapshotVolumeDef{
44+
{VolumeNumber: 0, SizeKib: 1024 * 1024},
45+
},
46+
}); err != nil {
47+
t.Fatalf("seed snap: %v", err)
48+
}
49+
50+
base, stop := startServerWithStore(t, st)
51+
defer stop()
52+
53+
body, _ := json.Marshal(map[string]string{
54+
"to_resource": "pvc-2",
55+
"from_snapshot": "snap-1",
56+
})
57+
58+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-1/snapshot-restore-resource", body)
59+
_ = resp.Body.Close()
60+
61+
if resp.StatusCode != http.StatusCreated {
62+
t.Fatalf("status: got %d, want 201", resp.StatusCode)
63+
}
64+
65+
got, err := st.ResourceDefinitions().Get(ctx, "pvc-2")
66+
if err != nil {
67+
t.Fatalf("expected pvc-2 to exist: %v", err)
68+
}
69+
70+
if got.Name != "pvc-2" {
71+
t.Errorf("Name: got %q", got.Name)
72+
}
73+
}
74+
75+
// TestSnapshotRestoreUnknownSnapshot: 404 if the snapshot doesn't exist.
76+
func TestSnapshotRestoreUnknownSnapshot(t *testing.T) {
77+
st := store.NewInMemory()
78+
if err := st.ResourceDefinitions().Create(t.Context(), &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
79+
t.Fatalf("seed: %v", err)
80+
}
81+
82+
base, stop := startServerWithStore(t, st)
83+
defer stop()
84+
85+
body, _ := json.Marshal(map[string]string{
86+
"to_resource": "pvc-2",
87+
"from_snapshot": "ghost",
88+
})
89+
90+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-1/snapshot-restore-resource", body)
91+
_ = resp.Body.Close()
92+
93+
if resp.StatusCode != http.StatusNotFound {
94+
t.Errorf("status: got %d, want 404", resp.StatusCode)
95+
}
96+
}
97+
98+
// TestSnapshotRestoreMissingFields: empty `to_resource` → 400.
99+
func TestSnapshotRestoreMissingFields(t *testing.T) {
100+
st := store.NewInMemory()
101+
if err := st.ResourceDefinitions().Create(t.Context(), &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
102+
t.Fatalf("seed: %v", err)
103+
}
104+
105+
base, stop := startServerWithStore(t, st)
106+
defer stop()
107+
108+
body, _ := json.Marshal(map[string]string{
109+
"from_snapshot": "snap-1",
110+
})
111+
112+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-1/snapshot-restore-resource", body)
113+
_ = resp.Body.Close()
114+
115+
if resp.StatusCode != http.StatusBadRequest {
116+
t.Errorf("status: got %d, want 400", resp.StatusCode)
117+
}
118+
}

0 commit comments

Comments
 (0)