Skip to content

Commit 98d7fe2

Browse files
kvapsclaude
andcommitted
feat(rest): resource-definition clone endpoint
POST /v1/resource-definitions/{rd}/clone duplicates an existing RD's metadata under a new name. Volume cloning is the satellite's job once the new RD enters reconcile. Mirrors `linstor resource-definition clone`. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 77f7c13 commit 98d7fe2

3 files changed

Lines changed: 188 additions & 0 deletions

File tree

pkg/rest/rd_clone.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
"maps"
22+
"net/http"
23+
)
24+
25+
// rdCloneRequest is the body for `resource-definition clone`. Only the
26+
// new name is required; advanced options (override props, RG override)
27+
// land when there's demand.
28+
type rdCloneRequest struct {
29+
Name string `json:"name"`
30+
}
31+
32+
// registerRDClone wires the /v1/resource-definitions/{rd}/clone endpoint.
33+
func (s *Server) registerRDClone(mux *http.ServeMux) {
34+
mux.HandleFunc("POST /v1/resource-definitions/{rd}/clone",
35+
s.requireStore(s.handleRDClone))
36+
}
37+
38+
// handleRDClone duplicates the source RD's metadata (props, RG ref)
39+
// under a new name. Volume cloning is the satellite's job once the new
40+
// RD enters the reconcile pass.
41+
func (s *Server) handleRDClone(w http.ResponseWriter, r *http.Request) {
42+
srcName := r.PathValue("rd")
43+
44+
var req rdCloneRequest
45+
46+
err := json.NewDecoder(r.Body).Decode(&req)
47+
if err != nil {
48+
writeError(w, http.StatusBadRequest, err.Error())
49+
50+
return
51+
}
52+
53+
if req.Name == "" {
54+
writeError(w, http.StatusBadRequest, "name is required")
55+
56+
return
57+
}
58+
59+
src, err := s.Store.ResourceDefinitions().Get(r.Context(), srcName)
60+
if err != nil {
61+
writeStoreError(w, err)
62+
63+
return
64+
}
65+
66+
// Shallow-copy mutable fields. We deliberately don't copy the
67+
// generated UUID — Create assigns a fresh one.
68+
clone := src
69+
clone.Name = req.Name
70+
clone.UUID = ""
71+
72+
if src.Props != nil {
73+
clone.Props = make(map[string]string, len(src.Props))
74+
maps.Copy(clone.Props, src.Props)
75+
}
76+
77+
err = s.Store.ResourceDefinitions().Create(r.Context(), &clone)
78+
if err != nil {
79+
writeStoreError(w, err)
80+
81+
return
82+
}
83+
84+
writeJSON(w, http.StatusCreated, clone)
85+
}

pkg/rest/rd_clone_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+
// TestRDCloneCreatesCopy: POST .../clone duplicates a ResourceDefinition.
29+
// Maps to `linstor resource-definition clone`.
30+
func TestRDCloneCreatesCopy(t *testing.T) {
31+
st := store.NewInMemory()
32+
ctx := t.Context()
33+
34+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{
35+
Name: "pvc-1",
36+
Props: map[string]string{"k": "v"},
37+
}); err != nil {
38+
t.Fatalf("seed: %v", err)
39+
}
40+
41+
base, stop := startServerWithStore(t, st)
42+
defer stop()
43+
44+
body, _ := json.Marshal(map[string]string{"name": "pvc-2"})
45+
46+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-1/clone", body)
47+
_ = resp.Body.Close()
48+
49+
if resp.StatusCode != http.StatusCreated {
50+
t.Fatalf("status: got %d, want 201", resp.StatusCode)
51+
}
52+
53+
got, err := st.ResourceDefinitions().Get(ctx, "pvc-2")
54+
if err != nil {
55+
t.Fatalf("expected pvc-2 to exist: %v", err)
56+
}
57+
58+
if got.Props["k"] != "v" {
59+
t.Errorf("Props not copied: %v", got.Props)
60+
}
61+
}
62+
63+
// TestRDCloneSourceMissing: 404 if the source RD doesn't exist.
64+
func TestRDCloneSourceMissing(t *testing.T) {
65+
base, stop := startServerWithStore(t, store.NewInMemory())
66+
defer stop()
67+
68+
body, _ := json.Marshal(map[string]string{"name": "pvc-2"})
69+
70+
resp := httpPost(t, base+"/v1/resource-definitions/ghost/clone", body)
71+
_ = resp.Body.Close()
72+
73+
if resp.StatusCode != http.StatusNotFound {
74+
t.Errorf("status: got %d, want 404", resp.StatusCode)
75+
}
76+
}
77+
78+
// TestRDCloneTargetExists: 409 if the target name is taken.
79+
func TestRDCloneTargetExists(t *testing.T) {
80+
st := store.NewInMemory()
81+
ctx := t.Context()
82+
83+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-1"}); err != nil {
84+
t.Fatalf("seed: %v", err)
85+
}
86+
87+
if err := st.ResourceDefinitions().Create(ctx, &apiv1.ResourceDefinition{Name: "pvc-2"}); err != nil {
88+
t.Fatalf("seed: %v", err)
89+
}
90+
91+
base, stop := startServerWithStore(t, st)
92+
defer stop()
93+
94+
body, _ := json.Marshal(map[string]string{"name": "pvc-2"})
95+
96+
resp := httpPost(t, base+"/v1/resource-definitions/pvc-1/clone", body)
97+
_ = resp.Body.Close()
98+
99+
if resp.StatusCode != http.StatusConflict {
100+
t.Errorf("status: got %d, want 409", resp.StatusCode)
101+
}
102+
}

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ func (s *Server) Start(ctx context.Context) error {
7878
s.registerDRBDProxy(mux)
7979
s.registerExternalFiles(mux)
8080
s.registerDRBDPassphrase(mux)
81+
s.registerRDClone(mux)
8182

8283
srv := &http.Server{
8384
Addr: s.Addr,

0 commit comments

Comments
 (0)