Skip to content

Commit 0b9c339

Browse files
kvapsclaude
andcommitted
feat(satellite): Reconciler routes DesiredResource volumes to providers
First slice of the apply loop: maps a DesiredResource batch onto the configured storage providers, returns a per-resource ResourceApplyResult so the controller can react to individual replica failures. DISKLESS-flagged resources skip the storage path entirely. The DRBD .res / drbdadm half follows in the next slice. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 1d9e915 commit 0b9c339

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

.golangci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ linters:
8989
- rw
9090
- ev
9191
- ch
92+
- dr
9293
exclusions:
9394
generated: lax
9495
presets:

pkg/satellite/reconciler.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
"slices"
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+
// ReconcilerConfig parametrises a Reconciler. Providers maps the
31+
// satellite's local storage-pool names to provisioned `storage.Provider`
32+
// instances; an unknown pool fails the per-resource Apply with a
33+
// surfaced error message.
34+
type ReconcilerConfig struct {
35+
Providers map[string]storage.Provider
36+
}
37+
38+
// Reconciler turns a controller-pushed DesiredResource set into local
39+
// state. Phase-3 cut: storage provisioning only — Build/.res / drbdadm
40+
// follow in the next slice.
41+
type Reconciler struct {
42+
cfg ReconcilerConfig
43+
}
44+
45+
// NewReconciler constructs a Reconciler from cfg.
46+
func NewReconciler(cfg ReconcilerConfig) *Reconciler {
47+
return &Reconciler{cfg: cfg}
48+
}
49+
50+
// Apply walks res and brings local storage in line with each item.
51+
// Each input gets a ResourceApplyResult — partial success is the norm
52+
// (one missing pool shouldn't sink the rest of a batch).
53+
//
54+
// The signature returns an error too, but reserves it for context
55+
// cancellation — per-resource failures land in the Result entries.
56+
func (r *Reconciler) Apply(ctx context.Context, res []*satellitepb.DesiredResource) ([]*satellitepb.ResourceApplyResult, error) {
57+
results := make([]*satellitepb.ResourceApplyResult, 0, len(res))
58+
59+
for _, dr := range res {
60+
err := ctx.Err()
61+
if err != nil {
62+
return results, errors.Wrap(err, "apply: context cancelled")
63+
}
64+
65+
results = append(results, r.applyOne(ctx, dr))
66+
}
67+
68+
return results, nil
69+
}
70+
71+
// applyOne reconciles a single DesiredResource. Diskless replicas skip
72+
// storage entirely (they're memory-backed by the DRBD stack); everything
73+
// else routes one CreateVolume per DesiredVolume.
74+
func (r *Reconciler) applyOne(ctx context.Context, dr *satellitepb.DesiredResource) *satellitepb.ResourceApplyResult {
75+
res := &satellitepb.ResourceApplyResult{
76+
Name: dr.GetName(),
77+
NodeName: dr.GetNodeName(),
78+
Ok: true,
79+
}
80+
81+
if isDiskless(dr.GetFlags()) {
82+
return res
83+
}
84+
85+
for _, vol := range dr.GetVolumes() {
86+
provider, ok := r.cfg.Providers[vol.GetStoragePool()]
87+
if !ok {
88+
res.Ok = false
89+
res.Message = fmt.Sprintf("unknown storage pool %q", vol.GetStoragePool())
90+
91+
return res
92+
}
93+
94+
err := provider.CreateVolume(ctx, storage.Volume{
95+
ResourceName: dr.GetName(),
96+
VolumeNumber: vol.GetVolumeNumber(),
97+
SizeKib: vol.GetSizeKib(),
98+
})
99+
if err != nil {
100+
res.Ok = false
101+
res.Message = err.Error()
102+
103+
return res
104+
}
105+
}
106+
107+
return res
108+
}
109+
110+
// isDiskless returns true when the DRBD-layer "DISKLESS" flag is set.
111+
// Diskless replicas live entirely in DRBD memory and have no backing
112+
// storage, so the reconciler must skip the storage path for them.
113+
func isDiskless(flags []string) bool {
114+
return slices.Contains(flags, "DISKLESS")
115+
}

pkg/satellite/reconciler_test.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
"slices"
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+
)
28+
29+
// TestApplyCreatesVolumeViaProvider: a single DesiredResource with one
30+
// volume on the registered LVM-thin pool ends up calling lvcreate.
31+
func TestApplyCreatesVolumeViaProvider(t *testing.T) {
32+
fx := storage.NewFakeExec()
33+
fx.Expect("lvs --noheadings -o lv_name vg/pvc-1_00000",
34+
storage.FakeResponse{Stdout: []byte("")})
35+
36+
thin := lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)
37+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
38+
Providers: map[string]storage.Provider{"thin1": thin},
39+
})
40+
41+
results, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
42+
{
43+
Name: "pvc-1",
44+
NodeName: "n1",
45+
Volumes: []*satellitepb.DesiredVolume{
46+
{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "thin1"},
47+
},
48+
},
49+
})
50+
if err != nil {
51+
t.Fatalf("Apply: %v", err)
52+
}
53+
54+
if len(results) != 1 || !results[0].GetOk() {
55+
t.Fatalf("results: %+v", results)
56+
}
57+
58+
if !slices.Contains(fx.CommandLines(),
59+
"lvcreate --thin --virtualsize 1024MiB --name pvc-1_00000 vg/tp") {
60+
t.Errorf("expected lvcreate; got %v", fx.CommandLines())
61+
}
62+
}
63+
64+
// TestApplyUnknownPoolFails: requesting a pool the satellite doesn't
65+
// know about → per-resource OK=false; the batch keeps going.
66+
func TestApplyUnknownPoolFails(t *testing.T) {
67+
fx := storage.NewFakeExec()
68+
thin := lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)
69+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
70+
Providers: map[string]storage.Provider{"thin1": thin},
71+
})
72+
73+
results, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
74+
{
75+
Name: "pvc-1",
76+
NodeName: "n1",
77+
Volumes: []*satellitepb.DesiredVolume{
78+
{VolumeNumber: 0, SizeKib: 1024, StoragePool: "missing"},
79+
},
80+
},
81+
})
82+
if err != nil {
83+
t.Fatalf("Apply: %v", err)
84+
}
85+
86+
if len(results) != 1 || results[0].GetOk() {
87+
t.Fatalf("expected !ok; got %+v", results[0])
88+
}
89+
90+
if results[0].GetMessage() == "" {
91+
t.Errorf("expected non-empty message")
92+
}
93+
}
94+
95+
// TestApplyDisklessSkipsStorage: a resource flagged DISKLESS has no
96+
// local backing storage; the reconciler must not call CreateVolume.
97+
func TestApplyDisklessSkipsStorage(t *testing.T) {
98+
fx := storage.NewFakeExec()
99+
thin := lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)
100+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
101+
Providers: map[string]storage.Provider{"thin1": thin},
102+
})
103+
104+
results, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
105+
{
106+
Name: "pvc-1",
107+
NodeName: "n1",
108+
Flags: []string{"DISKLESS"},
109+
Volumes: []*satellitepb.DesiredVolume{
110+
{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "thin1"},
111+
},
112+
},
113+
})
114+
if err != nil {
115+
t.Fatalf("Apply: %v", err)
116+
}
117+
118+
if len(results) != 1 || !results[0].GetOk() {
119+
t.Fatalf("results: %+v", results)
120+
}
121+
122+
for _, line := range fx.CommandLines() {
123+
if len(line) >= 8 && line[:8] == "lvcreate" {
124+
t.Errorf("DISKLESS resource issued lvcreate: %s", line)
125+
}
126+
}
127+
}
128+
129+
// TestApplyHandlesMultipleResources: all-or-nothing batch processing —
130+
// every input gets a result, regardless of individual outcome.
131+
func TestApplyHandlesMultipleResources(t *testing.T) {
132+
fx := storage.NewFakeExec()
133+
fx.Expect("lvs --noheadings -o lv_name vg/pvc-1_00000",
134+
storage.FakeResponse{Stdout: []byte("")})
135+
fx.Expect("lvs --noheadings -o lv_name vg/pvc-2_00000",
136+
storage.FakeResponse{Stdout: []byte("")})
137+
138+
thin := lvm.NewThin(lvm.ThinConfig{VolumeGroup: "vg", ThinPool: "tp"}, fx)
139+
rec := satellite.NewReconciler(satellite.ReconcilerConfig{
140+
Providers: map[string]storage.Provider{"thin1": thin},
141+
})
142+
143+
results, err := rec.Apply(t.Context(), []*satellitepb.DesiredResource{
144+
{
145+
Name: "pvc-1", NodeName: "n1",
146+
Volumes: []*satellitepb.DesiredVolume{
147+
{VolumeNumber: 0, SizeKib: 1024 * 1024, StoragePool: "thin1"},
148+
},
149+
},
150+
{
151+
Name: "pvc-2", NodeName: "n1",
152+
Volumes: []*satellitepb.DesiredVolume{
153+
{VolumeNumber: 0, SizeKib: 2048 * 1024, StoragePool: "thin1"},
154+
},
155+
},
156+
})
157+
if err != nil {
158+
t.Fatalf("Apply: %v", err)
159+
}
160+
161+
if len(results) != 2 {
162+
t.Fatalf("len(results): got %d, want 2", len(results))
163+
}
164+
165+
for _, r := range results {
166+
if !r.GetOk() {
167+
t.Errorf("expected ok for %s; got %s", r.GetName(), r.GetMessage())
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)