Skip to content

Commit 0d59ac1

Browse files
kvapsclaude
andcommitted
feat(storage/zfs): ZFS / ZFS_THIN provider behind same Provider seam
Phase 3 sixth slice. Second real backend, same surface as LVM-thin so the satellite can pick by Config.Thin without branching. - pkg/storage/zfs/zfs.go: implements storage.Provider against the zfs/ zpool tooling. CreateVolume issues 'zfs create -V <size>M' (or '-s -V' in thin mode); DeleteVolume is 'zfs destroy -r' (recursive cleans up dependent snapshots). VolumeStatus parses 'zfs list -p' bytes into KiB. PoolStatus reads 'zpool list -p size,free'. - Snapshot is 'zfs snapshot <pool>/<rd>_00000@<snap>', delete is the inverse 'zfs destroy <full-snap-spec>'. Same volume-0 limitation as the LVM-thin slice; multi-volume snapshots come in Phase 4 with the shipping work. - Reconcile-friendly: datasetExists primitive folds command failures into 'missing', CreateVolume / DeleteVolume short-circuit accordingly. Tests (11 cases): - Kind: ZFS vs ZFS_THIN - CreateVolume thick (no -s) and thin (-s) - CreateVolume idempotent on existing dataset - DeleteVolume issues 'zfs destroy -r' - VolumeStatus parses 'zfs list -p' - PoolStatus parses 'zpool list -p size,free' bytes → KiB - Snapshot create / delete Together with the LVM-thin slice both providers needed for cozystack's file-thin and zfs storage classes are testable; ConfFileBuilder + DRBD wrappers come next. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent b819842 commit 0d59ac1

2 files changed

Lines changed: 440 additions & 0 deletions

File tree

pkg/storage/zfs/zfs.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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 zfs is the ZFS-pool storage backend. It supports both the
18+
// LINSTOR `ZFS` (thick) and `ZFS_THIN` (sparse zvols) provider kinds via
19+
// the same Provider; Config.Thin toggles between them.
20+
package zfs
21+
22+
import (
23+
"context"
24+
"fmt"
25+
"strconv"
26+
"strings"
27+
28+
"github.com/cockroachdb/errors"
29+
30+
"github.com/cozystack/blockstor/pkg/storage"
31+
)
32+
33+
// Config parametrises a Provider with the ZFS pool name and whether it
34+
// runs in thin (sparse zvol) mode.
35+
type Config struct {
36+
Pool string
37+
Thin bool
38+
}
39+
40+
// Provider implements storage.Provider against ZFS on the host.
41+
type Provider struct {
42+
cfg Config
43+
exec storage.Exec
44+
}
45+
46+
// NewProvider constructs a Provider. The Exec is injected so unit tests
47+
// can drive it without ZFS installed.
48+
func NewProvider(cfg Config, ex storage.Exec) *Provider {
49+
return &Provider{cfg: cfg, exec: ex}
50+
}
51+
52+
// Kind returns "ZFS" or "ZFS_THIN" depending on Config.Thin.
53+
func (p *Provider) Kind() string {
54+
if p.cfg.Thin {
55+
return "ZFS_THIN"
56+
}
57+
58+
return "ZFS"
59+
}
60+
61+
// CreateVolume creates a zvol. Idempotent: existing dataset → no-op.
62+
func (p *Provider) CreateVolume(ctx context.Context, vol storage.Volume) error {
63+
if p.datasetExists(ctx, p.volumeDataset(vol)) {
64+
return nil
65+
}
66+
67+
sizeMiB := max(vol.SizeKib/mibPerKib, 1)
68+
69+
args := []string{"create"}
70+
if p.cfg.Thin {
71+
args = append(args, "-s")
72+
}
73+
74+
args = append(args, "-V", strconv.FormatInt(sizeMiB, 10)+"M", p.volumeDataset(vol))
75+
76+
_, err := p.exec.Run(ctx, "zfs", args...)
77+
if err != nil {
78+
return errors.Wrapf(err, "zfs create %s", p.volumeDataset(vol))
79+
}
80+
81+
return nil
82+
}
83+
84+
// DeleteVolume `zfs destroy -r`s the zvol (recursive to clean up any
85+
// dependent snapshots automatically). Missing → no-op.
86+
func (p *Provider) DeleteVolume(ctx context.Context, vol storage.Volume) error {
87+
if !p.datasetExists(ctx, p.volumeDataset(vol)) {
88+
return nil
89+
}
90+
91+
_, err := p.exec.Run(ctx, "zfs", "destroy", "-r", p.volumeDataset(vol))
92+
if err != nil {
93+
return errors.Wrapf(err, "zfs destroy %s", p.volumeDataset(vol))
94+
}
95+
96+
return nil
97+
}
98+
99+
// VolumeStatus parses `zfs list -p` output (bytes, no suffixes).
100+
func (p *Provider) VolumeStatus(ctx context.Context, vol storage.Volume) (storage.VolumeStatus, error) {
101+
out, err := p.exec.Run(ctx, "zfs",
102+
"list", "-H", "-p",
103+
"-o", "name,volsize,used",
104+
p.volumeDataset(vol))
105+
if err != nil {
106+
return storage.VolumeStatus{State: stateNotProvisioned}, nil //nolint:nilerr // missing == not provisioned
107+
}
108+
109+
line := strings.TrimSpace(string(out))
110+
if line == "" {
111+
return storage.VolumeStatus{State: stateNotProvisioned}, nil
112+
}
113+
114+
parts := strings.Split(line, "\t")
115+
if len(parts) != zfsListCols {
116+
return storage.VolumeStatus{}, errors.Errorf("zfs list: unexpected line %q", line)
117+
}
118+
119+
volSizeBytes, err := strconv.ParseInt(parts[1], 10, 64)
120+
if err != nil {
121+
return storage.VolumeStatus{}, errors.Wrap(err, "parse volsize")
122+
}
123+
124+
usedBytes, err := strconv.ParseInt(parts[2], 10, 64)
125+
if err != nil {
126+
return storage.VolumeStatus{}, errors.Wrap(err, "parse used")
127+
}
128+
129+
return storage.VolumeStatus{
130+
DevicePath: "/dev/zvol/" + p.volumeDataset(vol),
131+
UsableKib: volSizeBytes / bytesPerKib,
132+
AllocatedKib: usedBytes / bytesPerKib,
133+
State: "PROVISIONED",
134+
}, nil
135+
}
136+
137+
// PoolStatus reads `zpool list -p` for free/total bytes.
138+
func (p *Provider) PoolStatus(ctx context.Context) (storage.PoolStatus, error) {
139+
out, err := p.exec.Run(ctx, "zpool", "list", "-H", "-p", "-o", "size,free", p.cfg.Pool)
140+
if err != nil {
141+
return storage.PoolStatus{}, errors.Wrapf(err, "zpool list %s", p.cfg.Pool)
142+
}
143+
144+
parts := strings.Split(strings.TrimSpace(string(out)), "\t")
145+
if len(parts) != zpoolListCols {
146+
return storage.PoolStatus{}, errors.Errorf("zpool list: unexpected output %q", out)
147+
}
148+
149+
sizeBytes, err := strconv.ParseInt(parts[0], 10, 64)
150+
if err != nil {
151+
return storage.PoolStatus{}, errors.Wrap(err, "parse size")
152+
}
153+
154+
freeBytes, err := strconv.ParseInt(parts[1], 10, 64)
155+
if err != nil {
156+
return storage.PoolStatus{}, errors.Wrap(err, "parse free")
157+
}
158+
159+
return storage.PoolStatus{
160+
FreeCapacityKib: freeBytes / bytesPerKib,
161+
TotalCapacityKib: sizeBytes / bytesPerKib,
162+
SupportsSnapshots: true,
163+
}, nil
164+
}
165+
166+
// CreateSnapshot is `zfs snapshot <pool>/<rd>_00000@<snap>`.
167+
func (p *Provider) CreateSnapshot(ctx context.Context, snap storage.Snapshot) error {
168+
_, err := p.exec.Run(ctx, "zfs", "snapshot", p.snapshotDataset(snap))
169+
if err != nil {
170+
return errors.Wrapf(err, "zfs snapshot %s", p.snapshotDataset(snap))
171+
}
172+
173+
return nil
174+
}
175+
176+
// DeleteSnapshot is the inverse `zfs destroy <pool>/<rd>_00000@<snap>`.
177+
func (p *Provider) DeleteSnapshot(ctx context.Context, snap storage.Snapshot) error {
178+
_, err := p.exec.Run(ctx, "zfs", "destroy", p.snapshotDataset(snap))
179+
if err != nil {
180+
return errors.Wrapf(err, "zfs destroy %s", p.snapshotDataset(snap))
181+
}
182+
183+
return nil
184+
}
185+
186+
// datasetExists is the idempotency primitive — analogous to lvExists.
187+
func (p *Provider) datasetExists(ctx context.Context, ds string) bool {
188+
out, err := p.exec.Run(ctx, "zfs", "list", "-H", "-o", "name", ds)
189+
if err != nil {
190+
return false
191+
}
192+
193+
return strings.TrimSpace(string(out)) != ""
194+
}
195+
196+
// volumeDataset is `<pool>/<resource>_<vol5digits>`.
197+
func (p *Provider) volumeDataset(vol storage.Volume) string {
198+
return fmt.Sprintf("%s/%s_%05d", p.cfg.Pool, vol.ResourceName, vol.VolumeNumber)
199+
}
200+
201+
// snapshotDataset is `<pool>/<resource>_00000@<snap>`. ZFS snapshots are
202+
// per-dataset, so they're addressed via the parent zvol; we always
203+
// snapshot volume 0 (multi-volume support lands in Phase 4).
204+
func (p *Provider) snapshotDataset(snap storage.Snapshot) string {
205+
return fmt.Sprintf("%s/%s_00000@%s", p.cfg.Pool, snap.ResourceName, snap.SnapshotName)
206+
}
207+
208+
const (
209+
stateNotProvisioned = "NOT_PROVISIONED"
210+
mibPerKib = 1024
211+
bytesPerKib = 1024
212+
zfsListCols = 3
213+
zpoolListCols = 2
214+
)

0 commit comments

Comments
 (0)