@@ -19,20 +19,40 @@ package satellite
1919import (
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.
3443type 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.
7496func (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
113241func 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