Skip to content

Commit 6dbf303

Browse files
kvapsclaude
andcommitted
feat(satellite): Reconciler renders .res file + runs drbdadm create-md/adjust
Second slice of the apply loop. Given a DesiredResource the reconciler now also assembles a drbd.Resource from drbd_options + peers, renders the canonical .res file under StateDir, runs `drbdadm create-md` on first activation (non-DISKLESS only), and `drbdadm adjust` on every reconcile so DRBD picks up topology changes. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 0b9c339 commit 6dbf303

3 files changed

Lines changed: 324 additions & 18 deletions

File tree

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,8 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
227227
- [x] ConfFileBuilder in Go (`pkg/drbd/conffile.go`) — port from upstream Java; deterministic output, 7 contract tests green
228228
- [x] `drbdadm up/down/adjust/create-md/primary/secondary` exec wrappers behind interface (`pkg/drbd/drbdadm.go`); 7 contract tests via FakeExec
229229
- [x] `drbdsetup events2` listener (`pkg/drbd/events2.go`): line parser + Watcher streaming `Event{Action,Kind,Fields}` to a channel; 7 contract tests
230-
- [ ] Resource reconciler invokes storage provider + DRBD wrapper through
231-
satellite gRPC; updates Status from observed-state stream
230+
- [x] Resource reconciler (`pkg/satellite.Reconciler`) routes DesiredResource batches: storage provider CreateVolume per volume, ConfFileBuilder writes /etc/drbd.d/<name>.res, drbdadm create-md (first activation, non-DISKLESS) + adjust. Status writeback from events2 stream is the next slice.
231+
- [ ] Status writeback: events2 listener pushes ResourceObservedEvent stream back to controller
232232
- [ ] Resource on 2 nodes replicates and goes UpToDate (real DRBD smoke)
233233

234234
**Exit**: smoke test with two replicas, real DRBD, PVC mounted on node A then on node B (failover).

pkg/satellite/reconciler.go

Lines changed: 150 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,40 @@ package satellite
1919
import (
2020
"context"
2121
"fmt"
22+
"os"
23+
"path/filepath"
2224
"slices"
25+
"strconv"
2326

2427
"github.com/cockroachdb/errors"
2528

29+
"github.com/cozystack/blockstor/pkg/drbd"
2630
satellitepb "github.com/cozystack/blockstor/pkg/satellite/proto"
2731
"github.com/cozystack/blockstor/pkg/storage"
2832
)
2933

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+
// ReconcilerConfig parametrises a Reconciler.
35+
//
36+
// Providers maps the satellite's local storage-pool names to provisioned
37+
// `storage.Provider` instances; an unknown pool fails the per-resource
38+
// Apply with a surfaced error message.
39+
//
40+
// Adm, StateDir and NodeName drive the DRBD half: when set, Apply also
41+
// renders the `.res` file under StateDir, runs `drbdadm create-md` on
42+
// first activation, and `drbdadm adjust` on every reconcile.
3443
type ReconcilerConfig struct {
3544
Providers map[string]storage.Provider
45+
46+
// Adm is the drbdadm wrapper. nil → DRBD half is disabled (storage
47+
// only). Useful for unit tests of the storage path without DRBD.
48+
Adm *drbd.Adm
49+
50+
// StateDir is where `.res` files land. Required when Adm is set.
51+
StateDir string
52+
53+
// NodeName is this satellite's identifier; the reconciler uses it
54+
// to know which Peer entries describe local vs. remote.
55+
NodeName string
3656
}
3757

3858
// Reconciler turns a controller-pushed DesiredResource set into local
@@ -69,42 +89,150 @@ func (r *Reconciler) Apply(ctx context.Context, res []*satellitepb.DesiredResour
6989
}
7090

7191
// 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.
92+
// the storage path (they're memory-backed by the DRBD stack); everything
93+
// else routes one CreateVolume per DesiredVolume. When the DRBD half is
94+
// enabled (cfg.Adm != nil), also renders the `.res` file and runs
95+
// drbdadm create-md / adjust.
7496
func (r *Reconciler) applyOne(ctx context.Context, dr *satellitepb.DesiredResource) *satellitepb.ResourceApplyResult {
7597
res := &satellitepb.ResourceApplyResult{
7698
Name: dr.GetName(),
7799
NodeName: dr.GetNodeName(),
78100
Ok: true,
79101
}
80102

81-
if isDiskless(dr.GetFlags()) {
82-
return res
103+
diskless := isDiskless(dr.GetFlags())
104+
105+
if !diskless {
106+
err := r.applyStorage(ctx, dr)
107+
if err != nil {
108+
res.Ok = false
109+
res.Message = err.Error()
110+
111+
return res
112+
}
83113
}
84114

85-
for _, vol := range dr.GetVolumes() {
86-
provider, ok := r.cfg.Providers[vol.GetStoragePool()]
87-
if !ok {
115+
if r.cfg.Adm != nil {
116+
err := r.applyDRBD(ctx, dr, diskless)
117+
if err != nil {
88118
res.Ok = false
89-
res.Message = fmt.Sprintf("unknown storage pool %q", vol.GetStoragePool())
119+
res.Message = err.Error()
90120

91121
return res
92122
}
123+
}
124+
125+
return res
126+
}
127+
128+
// applyStorage walks dr.Volumes and ensures each LV/zvol exists.
129+
// Idempotent — providers handle "already there" themselves.
130+
func (r *Reconciler) applyStorage(ctx context.Context, dr *satellitepb.DesiredResource) error {
131+
for _, vol := range dr.GetVolumes() {
132+
provider, ok := r.cfg.Providers[vol.GetStoragePool()]
133+
if !ok {
134+
return errors.Errorf("unknown storage pool %q", vol.GetStoragePool())
135+
}
93136

94137
err := provider.CreateVolume(ctx, storage.Volume{
95138
ResourceName: dr.GetName(),
96139
VolumeNumber: vol.GetVolumeNumber(),
97140
SizeKib: vol.GetSizeKib(),
98141
})
99142
if err != nil {
100-
res.Ok = false
101-
res.Message = err.Error()
143+
return errors.Wrapf(err, "create volume %s/%d", dr.GetName(), vol.GetVolumeNumber())
144+
}
145+
}
102146

103-
return res
147+
return nil
148+
}
149+
150+
// applyDRBD renders the .res file from dr's metadata and (re)applies
151+
// it via drbdadm. create-md runs only on first activation (we detect
152+
// "first" by absence of the .res file before this run); diskless
153+
// replicas skip create-md entirely.
154+
func (r *Reconciler) applyDRBD(ctx context.Context, dr *satellitepb.DesiredResource, diskless bool) error {
155+
resPath := filepath.Join(r.cfg.StateDir, dr.GetName()+".res")
156+
_, statErr := os.Stat(resPath)
157+
firstActivation := os.IsNotExist(statErr)
158+
159+
body, err := buildResFile(dr, r.cfg.NodeName)
160+
if err != nil {
161+
return errors.Wrapf(err, "build .res for %s", dr.GetName())
162+
}
163+
164+
err = os.WriteFile(resPath, []byte(body), resFilePerm)
165+
if err != nil {
166+
return errors.Wrapf(err, "write %s", resPath)
167+
}
168+
169+
if firstActivation && !diskless {
170+
err = r.cfg.Adm.CreateMD(ctx, dr.GetName())
171+
if err != nil {
172+
return errors.Wrapf(err, "create-md %s", dr.GetName())
104173
}
105174
}
106175

107-
return res
176+
err = r.cfg.Adm.Adjust(ctx, dr.GetName())
177+
if err != nil {
178+
return errors.Wrapf(err, "adjust %s", dr.GetName())
179+
}
180+
181+
return nil
182+
}
183+
184+
// buildResFile assembles a drbd.Resource from dr's flat option map.
185+
// The proto carries DRBD config as a string→string map for now (the
186+
// schema solidifies once the controller-side autoplacer feeds it); we
187+
// honour the documented keys: `port`, `node-id`, `address`, `minor` for
188+
// the local node, and `peer.<name>.{port,node-id,address}` per peer.
189+
func buildResFile(dr *satellitepb.DesiredResource, localNode string) (string, error) {
190+
opts := dr.GetDrbdOptions()
191+
port, _ := strconv.Atoi(opts["port"])
192+
nodeID, _ := strconv.Atoi(opts["node-id"])
193+
minor, _ := strconv.Atoi(opts["minor"])
194+
195+
hosts := make([]drbd.Host, 0, 1+len(dr.GetPeers()))
196+
hosts = append(hosts, drbd.Host{
197+
NodeName: localNode,
198+
Address: opts["address"],
199+
Port: port,
200+
NodeID: nodeID,
201+
})
202+
203+
for _, peer := range dr.GetPeers() {
204+
peerPort, _ := strconv.Atoi(opts["peer."+peer+".port"])
205+
peerNodeID, _ := strconv.Atoi(opts["peer."+peer+".node-id"])
206+
207+
hosts = append(hosts, drbd.Host{
208+
NodeName: peer,
209+
Address: opts["peer."+peer+".address"],
210+
Port: peerPort,
211+
NodeID: peerNodeID,
212+
})
213+
}
214+
215+
vols := make([]drbd.Volume, 0, len(dr.GetVolumes()))
216+
for _, v := range dr.GetVolumes() {
217+
vols = append(vols, drbd.Volume{
218+
Number: int(v.GetVolumeNumber()),
219+
Device: fmt.Sprintf("/dev/drbd%d", minor+int(v.GetVolumeNumber())),
220+
Disk: fmt.Sprintf("/dev/%s/%s_%05d", v.GetStoragePool(), dr.GetName(), v.GetVolumeNumber()),
221+
Minor: minor + int(v.GetVolumeNumber()),
222+
})
223+
}
224+
225+
out, err := drbd.Build(drbd.Resource{
226+
Name: dr.GetName(),
227+
Net: drbd.Net{ProtocolC: true},
228+
Hosts: hosts,
229+
Volumes: vols,
230+
})
231+
if err != nil {
232+
return "", errors.Wrap(err, "drbd.Build")
233+
}
234+
235+
return out, nil
108236
}
109237

110238
// isDiskless returns true when the DRBD-layer "DISKLESS" flag is set.
@@ -113,3 +241,9 @@ func (r *Reconciler) applyOne(ctx context.Context, dr *satellitepb.DesiredResour
113241
func isDiskless(flags []string) bool {
114242
return slices.Contains(flags, "DISKLESS")
115243
}
244+
245+
// resFilePerm is the on-disk mode for /etc/drbd.d/<name>.res. drbd is
246+
// happy with 0o644; the file does not contain secrets the way auth-keys
247+
// would (shared-secret is in /etc/drbd.d/global_common.conf, written
248+
// once at install time).
249+
const resFilePerm = 0o644

0 commit comments

Comments
 (0)