Skip to content

Commit 9a5fe31

Browse files
kvapsclaude
andcommitted
feat(satellite): ShipSnapshot routes zfs send|recv vs thin-send-recv
Reconciler.ShipSnapshot picks the mechanism from the source pool's provider Kind: zfs send | ssh peer zfs recv for ZFS / ZFS_THIN, thin-send-recv for LVM_THIN. The exec used for the ship pipeline is injectable via ReconcilerConfig.ShipExec so unit tests assert command lines without spinning up the real zfs / ssh / thin tools. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent b28cbaa commit 9a5fe31

4 files changed

Lines changed: 304 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
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.
240240
- [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.
241-
- [ ] Intra-cluster snapshot shipping for clone/replica-expansion:
241+
- [x] Intra-cluster snapshot shipping for clone/replica-expansion: `Reconciler.ShipSnapshot` picks `zfs send | ssh peer zfs recv` for ZFS / ZFS_THIN and `thin-send-recv` for LVM_THIN, dispatched via an injectable ShipExec so unit tests assert command lines without spinning up the real tools. 3 contract tests.
242242
- ZFS pools: `zfs send | ssh | zfs recv` over satellite-to-satellite
243243
- LVM-thin: `thin-send-recv` (LINBIT)
244244
- [ ] csi-sanity passes against our server

pkg/satellite/reconciler.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ type ReconcilerConfig struct {
5555
// NodeName is this satellite's identifier; the reconciler uses it
5656
// to know which Peer entries describe local vs. remote.
5757
NodeName string
58+
59+
// ShipExec runs the snapshot-ship subprocess (zfs send|recv,
60+
// thin-send-recv, …). Production wires storage.RealExec; tests
61+
// inject FakeExec to assert the command line without spinning up
62+
// a real ssh / zfs / thin tool.
63+
ShipExec storage.Exec
5864
}
5965

6066
// Reconciler turns a controller-pushed DesiredResource set into local

pkg/satellite/ship.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 satellite
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strings"
23+
24+
"github.com/cockroachdb/errors"
25+
26+
satellitepb "github.com/cozystack/blockstor/pkg/satellite/proto"
27+
"github.com/cozystack/blockstor/pkg/storage"
28+
)
29+
30+
// shipExec is the storage.Exec the Reconciler uses when actually
31+
// shipping. We expose it as a separate field on the Reconciler so the
32+
// snapshot-ship path can use a different exec from the storage CRUD
33+
// path (e.g. for nested ssh exec). Today we always reuse the storage
34+
// providers' exec, but it pays for itself in tests.
35+
36+
// ShipSnapshot copies a snapshot from this satellite to a peer. The
37+
// mechanism is picked by the source pool's Kind:
38+
// - ZFS / ZFS_THIN → `zfs send <snap> | ssh peer zfs recv <dataset>`
39+
// - LVM_THIN → `thin-send-recv` (Linbit's tool)
40+
// - everything else → not supported
41+
//
42+
// The actual data flow happens out-of-process (a single piped shell
43+
// command); we record the chosen mechanism in the response so the
44+
// controller can plumb stats back to the caller.
45+
func (r *Reconciler) ShipSnapshot(ctx context.Context, req *satellitepb.ShipSnapshotRequest) (*satellitepb.ShipSnapshotResponse, error) {
46+
provider, err := r.providerForResource(req.GetResourceName())
47+
if err != nil {
48+
//nolint:nilerr // surfaced as ok=false; gRPC error is for transport faults
49+
return &satellitepb.ShipSnapshotResponse{Ok: false, Message: err.Error()}, nil
50+
}
51+
52+
mechanism := req.GetMechanism()
53+
if mechanism == "" {
54+
mechanism = pickMechanism(provider.Kind())
55+
}
56+
57+
err = r.runShip(ctx, mechanism, req)
58+
if err != nil {
59+
//nolint:nilerr // per-RPC error path
60+
return &satellitepb.ShipSnapshotResponse{Ok: false, Message: err.Error()}, nil
61+
}
62+
63+
return &satellitepb.ShipSnapshotResponse{Ok: true}, nil
64+
}
65+
66+
// runShip dispatches to the right shell pipeline given the chosen
67+
// mechanism. Errors from the exec layer surface to the caller.
68+
func (r *Reconciler) runShip(ctx context.Context, mechanism string, req *satellitepb.ShipSnapshotRequest) error {
69+
exec := r.shipExec()
70+
71+
switch strings.ToLower(mechanism) {
72+
case mechanismZFS, "zfs-send":
73+
return runZfsShip(ctx, exec, req)
74+
case mechanismThin, "lvm-thin", "thin-send-recv":
75+
return runThinShip(ctx, exec, req)
76+
default:
77+
return errors.Errorf("unsupported snapshot-ship mechanism %q", mechanism)
78+
}
79+
}
80+
81+
// shipExec returns the storage.Exec the ship pipeline runs under. In
82+
// production this is `storage.RealExec`; tests inject a `FakeExec` via
83+
// `ReconcilerConfig.ShipExec` so they can assert command lines
84+
// without spinning up the real zfs / ssh / thin-send-recv tools.
85+
func (r *Reconciler) shipExec() storage.Exec {
86+
if r.cfg.ShipExec != nil {
87+
return r.cfg.ShipExec
88+
}
89+
90+
return storage.RealExec{}
91+
}
92+
93+
// runZfsShip pipes `zfs send` into `ssh <target> zfs recv`. We don't
94+
// shell out via /bin/sh -c here — the storage.Exec abstraction runs
95+
// one binary, so we wrap the pipe ourselves through `sh -c`.
96+
func runZfsShip(ctx context.Context, exec storage.Exec, req *satellitepb.ShipSnapshotRequest) error {
97+
pipeline := fmt.Sprintf("zfs send %s | ssh %s zfs recv -F %s",
98+
req.GetSnapshotName(), req.GetTargetNode(), req.GetResourceName())
99+
100+
_, err := exec.Run(ctx, "sh", "-c", pipeline)
101+
if err != nil {
102+
return errors.Wrap(err, "zfs send|recv")
103+
}
104+
105+
return nil
106+
}
107+
108+
// runThinShip uses Linbit's `thin-send-recv` helper, which understands
109+
// the LVM-thin metadata block format and drives a similar pipe over
110+
// ssh under the hood.
111+
func runThinShip(ctx context.Context, exec storage.Exec, req *satellitepb.ShipSnapshotRequest) error {
112+
_, err := exec.Run(ctx, "thin-send-recv",
113+
"--source", req.GetResourceName()+"_"+req.GetSnapshotName()+"_00000",
114+
"--target", req.GetTargetNode())
115+
if err != nil {
116+
return errors.Wrap(err, "thin-send-recv")
117+
}
118+
119+
return nil
120+
}
121+
122+
// pickMechanism maps a provider Kind onto the default shipping
123+
// mechanism string. Kinds we don't know about return "" — the caller
124+
// surfaces that as an unsupported-mechanism error.
125+
func pickMechanism(kind string) string {
126+
switch kind {
127+
case kindZFS, kindZFSThin:
128+
return mechanismZFS
129+
case kindLVMThin:
130+
return mechanismThin
131+
default:
132+
return ""
133+
}
134+
}
135+
136+
const (
137+
mechanismZFS = "zfs"
138+
mechanismThin = "thin"
139+
140+
kindZFS = "ZFS"
141+
kindZFSThin = "ZFS_THIN"
142+
kindLVMThin = "LVM_THIN"
143+
)

pkg/satellite/ship_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 satellite_test
18+
19+
import (
20+
"strings"
21+
"testing"
22+
23+
"github.com/cozystack/blockstor/pkg/satellite"
24+
satellitepb "github.com/cozystack/blockstor/pkg/satellite/proto"
25+
"github.com/cozystack/blockstor/pkg/storage"
26+
"github.com/cozystack/blockstor/pkg/storage/lvm"
27+
"github.com/cozystack/blockstor/pkg/storage/zfs"
28+
)
29+
30+
// TestShipSnapshotZFSUsesZfsSendRecv: when the source pool is ZFS,
31+
// ShipSnapshot dispatches `zfs send | zfs recv` over SSH.
32+
func TestShipSnapshotZFSUsesZfsSendRecv(t *testing.T) {
33+
fx := storage.NewFakeExec()
34+
35+
zfsPool := zfs.NewProvider(zfs.Config{Pool: "tank"}, fx)
36+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
37+
Providers: map[string]storage.Provider{"zpool1": zfsPool},
38+
ShipExec: fx,
39+
})
40+
41+
// Apply seeds the resource→pool mapping so ShipSnapshot can
42+
// route. We don't need an LV to exist for the routing test.
43+
fx.Expect("zfs list -H -o name tank/pvc-1_00000",
44+
storage.FakeResponse{Stdout: []byte("")})
45+
46+
_, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
47+
{
48+
Name: "pvc-1", NodeName: "n1",
49+
Volumes: []*satellitepb.DesiredVolume{
50+
{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "zpool1"},
51+
},
52+
},
53+
})
54+
if err != nil {
55+
t.Fatalf("Apply: %v", err)
56+
}
57+
58+
resp, err := rec.ShipSnapshot(t.Context(), &satellitepb.ShipSnapshotRequest{
59+
ResourceName: "pvc-1",
60+
SnapshotName: "snap-1",
61+
TargetNode: "n2",
62+
})
63+
if err != nil {
64+
t.Fatalf("ShipSnapshot: %v", err)
65+
}
66+
67+
if !resp.GetOk() {
68+
t.Fatalf("expected ok; got %s", resp.GetMessage())
69+
}
70+
71+
found := false
72+
73+
for _, line := range fx.CommandLines() {
74+
if strings.Contains(line, "zfs send") {
75+
found = true
76+
}
77+
}
78+
79+
if !found {
80+
t.Errorf("expected `zfs send` in calls; got %v", fx.CommandLines())
81+
}
82+
}
83+
84+
// TestShipSnapshotLVMThinUsesThinSendRecv: LVM-thin source picks the
85+
// thin-send-recv mechanism.
86+
func TestShipSnapshotLVMThinUsesThinSendRecv(t *testing.T) {
87+
fx := storage.NewFakeExec()
88+
fx.Expect("lvs --noheadings -o lv_name vg/pvc-1_00000",
89+
storage.FakeResponse{Stdout: []byte("")})
90+
91+
thin := lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)
92+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
93+
Providers: map[string]storage.Provider{"thin1": thin},
94+
ShipExec: fx,
95+
})
96+
97+
_, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
98+
{
99+
Name: "pvc-1", NodeName: "n1",
100+
Volumes: []*satellitepb.DesiredVolume{
101+
{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "thin1"},
102+
},
103+
},
104+
})
105+
if err != nil {
106+
t.Fatalf("Apply: %v", err)
107+
}
108+
109+
resp, err := rec.ShipSnapshot(t.Context(), &satellitepb.ShipSnapshotRequest{
110+
ResourceName: "pvc-1",
111+
SnapshotName: "snap-1",
112+
TargetNode: "n2",
113+
})
114+
if err != nil {
115+
t.Fatalf("ShipSnapshot: %v", err)
116+
}
117+
118+
if !resp.GetOk() {
119+
t.Fatalf("expected ok; got %s", resp.GetMessage())
120+
}
121+
122+
found := false
123+
124+
for _, line := range fx.CommandLines() {
125+
if strings.Contains(line, "thin-send-recv") || strings.Contains(line, "thin_send") {
126+
found = true
127+
}
128+
}
129+
130+
if !found {
131+
t.Errorf("expected thin-send-recv in calls; got %v", fx.CommandLines())
132+
}
133+
}
134+
135+
// TestShipSnapshotUnknownResource → ok=false with message.
136+
func TestShipSnapshotUnknownResource(t *testing.T) {
137+
fx := storage.NewFakeExec()
138+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
139+
Providers: map[string]storage.Provider{"thin1": lvm.NewThin(lvm.ThinConfig{}, fx)},
140+
})
141+
142+
resp, err := rec.ShipSnapshot(t.Context(), &satellitepb.ShipSnapshotRequest{
143+
ResourceName: "ghost",
144+
SnapshotName: "snap-1",
145+
TargetNode: "n2",
146+
})
147+
if err != nil {
148+
t.Fatalf("ShipSnapshot: %v", err)
149+
}
150+
151+
if resp.GetOk() {
152+
t.Errorf("expected !ok for unknown resource")
153+
}
154+
}

0 commit comments

Comments
 (0)