Skip to content

Commit 053e6c1

Browse files
kvapsclaude
andcommitted
feat(storage): provider interface + Exec abstraction (RealExec / FakeExec)
Phase 3 fourth slice. Lays the foundation for the LVM/ZFS/file backends without committing to any one of them yet. - pkg/storage/storage.go: Provider interface (CreateVolume, DeleteVolume, VolumeStatus, CreateSnapshot, DeleteSnapshot, PoolStatus, Kind) plus Volume/Snapshot/VolumeStatus/PoolStatus value types and ErrNotFound / ErrAlreadyExists sentinels. The contract is reconcile-friendly: CreateVolume is idempotent, DeleteVolume swallows ErrNotFound. - pkg/storage/exec.go: Exec interface + RealExec (production, wraps os/exec.CommandContext) + FakeExec (test, records calls, canned responses). Stderr folds into the wrapped error so the original chain survives; tests can ContainsAll-assert against CommandLines(). - pkg/storage/exec_test.go: 6 cases — records, canned-stdout, canned-err, empty-default, joined CommandLines, Reset clears Calls but keeps Responses. Next slice plugs the first real provider (LVM-thin) into this seam. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent c87eb48 commit 053e6c1

3 files changed

Lines changed: 390 additions & 0 deletions

File tree

pkg/storage/exec.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 storage
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"os/exec"
23+
"strings"
24+
"sync"
25+
26+
"github.com/cockroachdb/errors"
27+
)
28+
29+
// Exec is a context-aware shell-out abstraction. Production uses
30+
// RealExec which wraps os/exec; tests substitute FakeExec to assert what
31+
// was called and inject canned output without root.
32+
type Exec interface {
33+
// Run invokes name with the given args under the supplied context
34+
// and returns combined stdout+stderr. Non-zero exit codes turn into
35+
// non-nil errors that wrap the original exec error.
36+
Run(ctx context.Context, name string, args ...string) ([]byte, error)
37+
}
38+
39+
// RealExec is the production implementation backed by os/exec.
40+
type RealExec struct{}
41+
42+
// Run executes name with args, capturing stdout. Stderr is folded into
43+
// the returned error so callers can include it in surfaced diagnostics
44+
// without losing the original error chain.
45+
func (RealExec) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
46+
cmd := exec.CommandContext(ctx, name, args...)
47+
48+
var (
49+
stdout bytes.Buffer
50+
stderr bytes.Buffer
51+
)
52+
53+
cmd.Stdout = &stdout
54+
cmd.Stderr = &stderr
55+
56+
err := cmd.Run()
57+
if err != nil {
58+
return stdout.Bytes(), errors.Wrapf(err, "%s %s: %s",
59+
name, strings.Join(args, " "), strings.TrimSpace(stderr.String()))
60+
}
61+
62+
return stdout.Bytes(), nil
63+
}
64+
65+
// FakeExec is the test implementation. Programmes can pre-register
66+
// canned responses via Expect and inspect Calls afterwards.
67+
type FakeExec struct {
68+
mu sync.Mutex
69+
70+
// Calls records every Run invocation in order.
71+
Calls []FakeCall
72+
73+
// Responses maps "name arg1 arg2 …" → canned output.
74+
// Falling back to empty stdout + nil error if absent.
75+
Responses map[string]FakeResponse
76+
}
77+
78+
// FakeCall records one invocation of FakeExec.Run.
79+
type FakeCall struct {
80+
Name string
81+
Args []string
82+
}
83+
84+
// FakeResponse is the canned output for a Responses entry.
85+
type FakeResponse struct {
86+
Stdout []byte
87+
Err error
88+
}
89+
90+
// NewFakeExec returns a FakeExec ready for use.
91+
func NewFakeExec() *FakeExec {
92+
return &FakeExec{Responses: map[string]FakeResponse{}}
93+
}
94+
95+
// Run looks up a pre-registered response (full command line) and falls
96+
// back to an empty success if none was registered.
97+
func (f *FakeExec) Run(_ context.Context, name string, args ...string) ([]byte, error) {
98+
f.mu.Lock()
99+
defer f.mu.Unlock()
100+
101+
f.Calls = append(f.Calls, FakeCall{Name: name, Args: append([]string(nil), args...)})
102+
103+
key := name
104+
if len(args) > 0 {
105+
key += " " + strings.Join(args, " ")
106+
}
107+
108+
if resp, ok := f.Responses[key]; ok {
109+
return resp.Stdout, resp.Err
110+
}
111+
112+
return nil, nil
113+
}
114+
115+
// Expect registers a canned response. Match is exact on the command line.
116+
func (f *FakeExec) Expect(cmdline string, resp FakeResponse) {
117+
f.mu.Lock()
118+
defer f.mu.Unlock()
119+
120+
f.Responses[cmdline] = resp
121+
}
122+
123+
// CommandLines returns the recorded calls as space-joined command lines —
124+
// convenient for ContainsAll-style assertions in tests.
125+
func (f *FakeExec) CommandLines() []string {
126+
f.mu.Lock()
127+
defer f.mu.Unlock()
128+
129+
out := make([]string, 0, len(f.Calls))
130+
for i := range f.Calls {
131+
line := f.Calls[i].Name
132+
if len(f.Calls[i].Args) > 0 {
133+
line += " " + strings.Join(f.Calls[i].Args, " ")
134+
}
135+
136+
out = append(out, line)
137+
}
138+
139+
return out
140+
}
141+
142+
// Reset clears recorded calls but keeps registered responses. Useful in
143+
// table-driven tests where each row reuses the same FakeExec.
144+
func (f *FakeExec) Reset() {
145+
f.mu.Lock()
146+
defer f.mu.Unlock()
147+
148+
f.Calls = nil
149+
}

pkg/storage/exec_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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 storage_test
18+
19+
import (
20+
"errors"
21+
"strings"
22+
"testing"
23+
24+
"github.com/cozystack/blockstor/pkg/storage"
25+
)
26+
27+
// TestFakeExecRecordsCalls: every Run invocation appears in Calls in order.
28+
func TestFakeExecRecordsCalls(t *testing.T) {
29+
fx := storage.NewFakeExec()
30+
31+
_, _ = fx.Run(t.Context(), "lvs")
32+
_, _ = fx.Run(t.Context(), "lvcreate", "-T", "vg/pool", "-V", "1G", "-n", "vol")
33+
34+
if len(fx.Calls) != 2 {
35+
t.Fatalf("Calls len: got %d, want 2", len(fx.Calls))
36+
}
37+
38+
if fx.Calls[1].Name != "lvcreate" {
39+
t.Errorf("Calls[1].Name: got %q, want lvcreate", fx.Calls[1].Name)
40+
}
41+
42+
want := []string{"-T", "vg/pool", "-V", "1G", "-n", "vol"}
43+
if strings.Join(fx.Calls[1].Args, ",") != strings.Join(want, ",") {
44+
t.Errorf("Calls[1].Args: got %v, want %v", fx.Calls[1].Args, want)
45+
}
46+
}
47+
48+
// TestFakeExecCannedResponse: Expect returns the registered stdout/error.
49+
func TestFakeExecCannedResponse(t *testing.T) {
50+
fx := storage.NewFakeExec()
51+
fx.Expect("lvs --noheadings -o lv_name vg", storage.FakeResponse{
52+
Stdout: []byte("vol1\nvol2\n"),
53+
})
54+
55+
out, err := fx.Run(t.Context(), "lvs", "--noheadings", "-o", "lv_name", "vg")
56+
if err != nil {
57+
t.Fatalf("Run: %v", err)
58+
}
59+
60+
if !strings.Contains(string(out), "vol1") {
61+
t.Errorf("stdout: got %q, want to contain vol1", out)
62+
}
63+
}
64+
65+
// errFakeVGMissing is a static sentinel; err113 forbids ad-hoc errors.New
66+
// in tests so we declare it once here.
67+
var errFakeVGMissing = errors.New("volume group \"vg\" not found")
68+
69+
// TestFakeExecCannedError: Expect can return an error, callers must
70+
// surface it.
71+
func TestFakeExecCannedError(t *testing.T) {
72+
fx := storage.NewFakeExec()
73+
fx.Expect("vgs vg", storage.FakeResponse{Err: errFakeVGMissing})
74+
75+
_, err := fx.Run(t.Context(), "vgs", "vg")
76+
if !errors.Is(err, errFakeVGMissing) {
77+
t.Errorf("Run err: got %v, want %v", err, errFakeVGMissing)
78+
}
79+
}
80+
81+
// TestFakeExecEmptyDefault: no Expect → empty stdout, nil error.
82+
func TestFakeExecEmptyDefault(t *testing.T) {
83+
fx := storage.NewFakeExec()
84+
85+
out, err := fx.Run(t.Context(), "anything")
86+
if err != nil {
87+
t.Errorf("default err: got %v, want nil", err)
88+
}
89+
90+
if len(out) != 0 {
91+
t.Errorf("default stdout: got %q, want empty", out)
92+
}
93+
}
94+
95+
// TestFakeExecCommandLinesJoinsArgs: convenience accessor returns joined
96+
// command lines for ContainsAll-style assertions.
97+
func TestFakeExecCommandLinesJoinsArgs(t *testing.T) {
98+
fx := storage.NewFakeExec()
99+
100+
_, _ = fx.Run(t.Context(), "lvremove", "-f", "vg/vol")
101+
_, _ = fx.Run(t.Context(), "vgs")
102+
103+
got := fx.CommandLines()
104+
if len(got) != 2 {
105+
t.Fatalf("len: got %d", len(got))
106+
}
107+
108+
if got[0] != "lvremove -f vg/vol" {
109+
t.Errorf("[0]: got %q", got[0])
110+
}
111+
112+
if got[1] != "vgs" {
113+
t.Errorf("[1]: got %q", got[1])
114+
}
115+
}
116+
117+
// TestFakeExecReset clears recorded calls but keeps Responses.
118+
func TestFakeExecReset(t *testing.T) {
119+
fx := storage.NewFakeExec()
120+
fx.Expect("lvs", storage.FakeResponse{Stdout: []byte("ok")})
121+
122+
_, _ = fx.Run(t.Context(), "lvs")
123+
124+
fx.Reset()
125+
126+
if len(fx.Calls) != 0 {
127+
t.Errorf("Calls: got %d, want 0 after Reset", len(fx.Calls))
128+
}
129+
130+
out, _ := fx.Run(t.Context(), "lvs")
131+
if string(out) != "ok" {
132+
t.Errorf("Responses lost across Reset: got %q", out)
133+
}
134+
}

pkg/storage/storage.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 storage defines the satellite-side storage provider contract.
18+
// Implementations live under pkg/storage/{lvm,zfs,file} and translate
19+
// Volume / Snapshot intent into shell-out calls (lvcreate, zfs create,
20+
// dd, ...) wrapped behind the Exec interface so unit tests can drive
21+
// them without root or real block devices.
22+
package storage
23+
24+
import (
25+
"context"
26+
27+
"github.com/cockroachdb/errors"
28+
)
29+
30+
// Sentinel errors returned by Provider implementations. REST handlers and
31+
// reconcilers map these to HTTP statuses / Status conditions.
32+
var (
33+
// ErrNotFound — the named volume/snapshot/pool does not exist on
34+
// this node.
35+
ErrNotFound = errors.New("storage object not found")
36+
// ErrAlreadyExists — Create called on an object that already exists.
37+
ErrAlreadyExists = errors.New("storage object already exists")
38+
)
39+
40+
// Volume identifies a block-level volume on a satellite. The triple
41+
// (PoolName, ResourceName, VolumeNumber) uniquely names it; the
42+
// implementation maps that to a backend-specific path (LVM LV, ZFS
43+
// dataset, file).
44+
type Volume struct {
45+
PoolName string
46+
ResourceName string
47+
VolumeNumber int32
48+
SizeKib int64
49+
StoragePoolDir string // for FILE providers
50+
}
51+
52+
// Snapshot is a captured-at-a-point-in-time copy of a Volume on the same
53+
// node. Snapshot shipping (intra-cluster clone / replica expansion) lives
54+
// on the Provider too because zfs/thin tooling differ per provider.
55+
type Snapshot struct {
56+
PoolName string
57+
ResourceName string
58+
SnapshotName string
59+
}
60+
61+
// VolumeStatus is the observed state the satellite reports back to the
62+
// controller. Empty DevicePath means the volume is not yet provisioned.
63+
type VolumeStatus struct {
64+
DevicePath string
65+
AllocatedKib int64
66+
UsableKib int64
67+
State string // PROVISIONED / ERROR / NOT_PROVISIONED
68+
}
69+
70+
// PoolStatus mirrors `linstor sp l` output for one pool on this node.
71+
type PoolStatus struct {
72+
FreeCapacityKib int64
73+
TotalCapacityKib int64
74+
SupportsSnapshots bool
75+
}
76+
77+
// Provider is the per-storage-kind interface every backend implements.
78+
// Implementations are constructed once per pool by the satellite agent
79+
// and re-used across reconcile cycles.
80+
type Provider interface {
81+
// Kind returns the LINSTOR provider kind string (e.g. "LVM_THIN").
82+
Kind() string
83+
84+
// PoolStatus reports free/total capacity and snapshot capability.
85+
// Used by the satellite's per-pool reporter.
86+
PoolStatus(ctx context.Context) (PoolStatus, error)
87+
88+
// CreateVolume materialises the volume on disk. Idempotent: if the
89+
// volume already exists with the same size, returns nil.
90+
CreateVolume(ctx context.Context, vol Volume) error
91+
92+
// DeleteVolume removes the volume. Idempotent: ErrNotFound is
93+
// silently swallowed so repeated reconciles converge.
94+
DeleteVolume(ctx context.Context, vol Volume) error
95+
96+
// VolumeStatus reports observed state. DevicePath empty + State
97+
// NOT_PROVISIONED means the volume hasn't been created yet.
98+
VolumeStatus(ctx context.Context, vol Volume) (VolumeStatus, error)
99+
100+
// CreateSnapshot captures the volume at a point in time. The
101+
// implementation chooses the on-disk representation (LV snapshot,
102+
// zfs snapshot, COW copy).
103+
CreateSnapshot(ctx context.Context, snap Snapshot) error
104+
105+
// DeleteSnapshot is the inverse of CreateSnapshot.
106+
DeleteSnapshot(ctx context.Context, snap Snapshot) error
107+
}

0 commit comments

Comments
 (0)