Skip to content

Commit a923baf

Browse files
kvapsclaude
andcommitted
feat(satellite): attach executor for PhysicalDevice (Phase 10.7)
Pure-function attach primitive the (eventual) PhysicalDevice reconciler will consume. Given a `PhysicalDevice` with `Spec.AttachTo` set, runs the kind-specific create sequence: LVM: pvcreate → vgcreate LVM_THIN: pvcreate → vgcreate → lvcreate --type thin-pool 100%FREE ZFS: zpool create -f -O compression=off -O atime=off FILE: no shell-out (host-mounted directory) Every LVM command goes through `lvm.Args(...)` so the upstream defensive `--config 'devices { filter=...}'` filter stays applied (Phase 8.2 contract). The wipe step is consent-gated on `AttachTo.Wipe=true` and runs BEFORE the kind-specific create (running wipefs after vgcreate would corrupt the freshly-created pool — pinned by ordering test). Returns `AttachResult{PoolName, ProviderKind, Props}` ready for `Reconciler.RegisterProvider` and the StoragePool CRD reconciler. Pinned by 8 unit tests covering each kind branch + happy path, the wipefs ordering invariant, and three precondition rejects (nil device, missing DevicePath, missing required field). Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 87e363b commit a923baf

2 files changed

Lines changed: 515 additions & 0 deletions

File tree

pkg/satellite/attach.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
22+
"github.com/cockroachdb/errors"
23+
24+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
25+
"github.com/cozystack/blockstor/pkg/storage"
26+
"github.com/cozystack/blockstor/pkg/storage/lvm"
27+
)
28+
29+
// AttachResult is the output of `Attach`: the resulting pool name
30+
// + provider-kind-specific props the caller can hand to
31+
// `NewProviderFromKind` + `Reconciler.RegisterProvider`. Phase 10.7.
32+
type AttachResult struct {
33+
PoolName string
34+
ProviderKind string
35+
Props map[string]string
36+
}
37+
38+
// Attach materialises a `PhysicalDevice.Spec.AttachTo` request:
39+
// optionally wipes the device, runs the kind-specific
40+
// pool-create command(s), and returns the resulting
41+
// `AttachResult` ready to register with the satellite's
42+
// `Reconciler`. Phase 10.7.
43+
//
44+
// Caller protocol:
45+
// 1. Reconciler picks up the CRD via watch event.
46+
// 2. Sets Status.Phase=Attaching via SSA.
47+
// 3. Calls `Attach(ctx, exec, dev)`.
48+
// 4. On success: registers the new provider, ensures the
49+
// `StoragePool` CRD exists, deletes the PhysicalDevice CRD
50+
// (delete-as-completion).
51+
// 5. On failure: sets Status.Phase=Failed + a Condition
52+
// describing the cause; leaves the CRD present for operator
53+
// triage.
54+
//
55+
// The wipe step is gated by `Spec.AttachTo.Wipe` — without
56+
// explicit operator consent, a device with on-disk signatures
57+
// returns an error, surfacing on Status as a `WipeRequired`
58+
// condition.
59+
func Attach(ctx context.Context, exec storage.Exec, dev *apiv1.PhysicalDevice) (AttachResult, error) {
60+
if dev == nil || dev.AttachTo == nil {
61+
return AttachResult{}, errors.New("Attach: nil device or AttachTo")
62+
}
63+
64+
// FILE / FILE_THIN attach only needs a directory — the host's
65+
// already-mounted filesystem; no block device path required.
66+
if dev.AttachTo.ProviderKind == ProviderKindFile || dev.AttachTo.ProviderKind == ProviderKindFileThin {
67+
return attachFile(dev)
68+
}
69+
70+
devicePath := attachDevicePath(dev)
71+
if devicePath == "" {
72+
return AttachResult{}, errors.New("Attach: device has no DevicePath/CurrentDevPath")
73+
}
74+
75+
if dev.AttachTo.Wipe {
76+
err := wipeDevice(ctx, exec, devicePath)
77+
if err != nil {
78+
return AttachResult{}, errors.Wrap(err, "wipefs")
79+
}
80+
}
81+
82+
switch dev.AttachTo.ProviderKind {
83+
case ProviderKindLVM:
84+
return attachLVMThick(ctx, exec, dev, devicePath)
85+
case ProviderKindLVMThin:
86+
return attachLVMThin(ctx, exec, dev, devicePath)
87+
case ProviderKindZFS, ProviderKindZFSThin:
88+
return attachZFS(ctx, exec, dev, devicePath)
89+
}
90+
91+
return AttachResult{}, errors.Errorf("Attach: unsupported provider kind %q", dev.AttachTo.ProviderKind)
92+
}
93+
94+
// attachDevicePath picks the most stable device path the
95+
// satellite can operate on. Prefers the by-id symlink (stable
96+
// across reboots / re-cabling) and falls back to the volatile
97+
// `/dev/sdN` only as a last resort.
98+
func attachDevicePath(dev *apiv1.PhysicalDevice) string {
99+
if dev.DevicePath != "" {
100+
return dev.DevicePath
101+
}
102+
103+
return dev.CurrentDevPath
104+
}
105+
106+
// wipeDevice runs `wipefs --all --force <device>` to clear
107+
// every detected on-disk signature. Operators must opt in via
108+
// `AttachTo.Wipe=true` — without it, a device carrying any
109+
// signature would otherwise fail the kind-specific create
110+
// command (`vgcreate` refuses on existing PV signature, etc).
111+
func wipeDevice(ctx context.Context, exec storage.Exec, devicePath string) error {
112+
_, err := exec.Run(ctx, "wipefs", "--all", "--force", devicePath)
113+
if err != nil {
114+
return errors.Wrap(err, "wipefs")
115+
}
116+
117+
return nil
118+
}
119+
120+
// attachLVMThick: pvcreate + vgcreate. Returns the
121+
// `LVM` provider kind config the satellite then registers via
122+
// `RegisterProvider` to make the pool available for
123+
// `ApplyResources`.
124+
func attachLVMThick(ctx context.Context, exec storage.Exec, dev *apiv1.PhysicalDevice, devicePath string) (AttachResult, error) {
125+
vg := dev.AttachTo.VGName
126+
if vg == "" {
127+
return AttachResult{}, errors.New("LVM attach requires VGName")
128+
}
129+
130+
_, err := exec.Run(ctx, "pvcreate", lvm.Args("--force", "--yes", devicePath)...)
131+
if err != nil {
132+
return AttachResult{}, errors.Wrap(err, "pvcreate")
133+
}
134+
135+
_, err = exec.Run(ctx, "vgcreate", lvm.Args("--force", "--yes", vg, devicePath)...)
136+
if err != nil {
137+
return AttachResult{}, errors.Wrap(err, "vgcreate")
138+
}
139+
140+
return AttachResult{
141+
PoolName: dev.AttachTo.StoragePoolName,
142+
ProviderKind: ProviderKindLVM,
143+
Props: map[string]string{
144+
propLvmVG: vg,
145+
},
146+
}, nil
147+
}
148+
149+
// attachLVMThin: pvcreate + vgcreate + lvcreate --thinpool.
150+
// The thin-pool LV consumes the entire VG (extents=100%FREE)
151+
// since this is the dedicated pool for replicas — leaving free
152+
// extents would only confuse capacity accounting.
153+
func attachLVMThin(ctx context.Context, exec storage.Exec, dev *apiv1.PhysicalDevice, devicePath string) (AttachResult, error) {
154+
vg := dev.AttachTo.VGName
155+
thin := dev.AttachTo.ThinPoolName
156+
157+
if vg == "" || thin == "" {
158+
return AttachResult{}, errors.New("LVM_THIN attach requires both VGName and ThinPoolName")
159+
}
160+
161+
_, err := exec.Run(ctx, "pvcreate", lvm.Args("--force", "--yes", devicePath)...)
162+
if err != nil {
163+
return AttachResult{}, errors.Wrap(err, "pvcreate")
164+
}
165+
166+
_, err = exec.Run(ctx, "vgcreate", lvm.Args("--force", "--yes", vg, devicePath)...)
167+
if err != nil {
168+
return AttachResult{}, errors.Wrap(err, "vgcreate")
169+
}
170+
171+
_, err = exec.Run(ctx, "lvcreate", lvm.Args(
172+
"--type", "thin-pool",
173+
"--extents", "100%FREE",
174+
"--name", thin,
175+
vg,
176+
)...)
177+
if err != nil {
178+
return AttachResult{}, errors.Wrap(err, "lvcreate --thinpool")
179+
}
180+
181+
return AttachResult{
182+
PoolName: dev.AttachTo.StoragePoolName,
183+
ProviderKind: ProviderKindLVMThin,
184+
Props: map[string]string{
185+
propLvmVG: vg,
186+
propThinPool: thin,
187+
},
188+
}, nil
189+
}
190+
191+
// attachZFS: zpool create. The pool name on disk matches the
192+
// LINSTOR pool name to keep cross-host import predictable; the
193+
// PhysicalDevice's StableID-derived path is the single vdev.
194+
func attachZFS(ctx context.Context, exec storage.Exec, dev *apiv1.PhysicalDevice, devicePath string) (AttachResult, error) {
195+
pool := dev.AttachTo.ZPoolName
196+
if pool == "" {
197+
return AttachResult{}, errors.New("ZFS attach requires ZPoolName")
198+
}
199+
200+
_, err := exec.Run(ctx, "zpool", "create", "-f",
201+
"-O", "compression=off",
202+
"-O", "atime=off",
203+
pool, devicePath)
204+
if err != nil {
205+
return AttachResult{}, errors.Wrap(err, "zpool create")
206+
}
207+
208+
return AttachResult{
209+
PoolName: dev.AttachTo.StoragePoolName,
210+
ProviderKind: dev.AttachTo.ProviderKind,
211+
Props: map[string]string{
212+
propZPool: pool,
213+
},
214+
}, nil
215+
}
216+
217+
// attachFile: directory-backed pool — no on-disk format runs
218+
// satellite-side. The directory is expected to already be
219+
// mounted by the host (Talos extension / kubelet). Returns the
220+
// kind-specific Provider config without touching the disk.
221+
func attachFile(dev *apiv1.PhysicalDevice) (AttachResult, error) {
222+
dir := dev.AttachTo.Directory
223+
if dir == "" {
224+
return AttachResult{}, errors.New("FILE attach requires Directory")
225+
}
226+
227+
return AttachResult{
228+
PoolName: dev.AttachTo.StoragePoolName,
229+
ProviderKind: dev.AttachTo.ProviderKind,
230+
Props: map[string]string{
231+
propFileDir: dir,
232+
},
233+
}, nil
234+
}

0 commit comments

Comments
 (0)