Internals
This page is about the invariants whose violation would quietly corrupt cluster state. For the user-level picture, see Architecture. For a tour of the code, follow the call graph from cmd/controller/main.go and cmd/satellite/main.go.
Spec and Status
Every blockstor CRD has two halves:
- Spec is desired state. Users, REST handlers and the controller’s placement write it. The satellite writes Spec in two places:
Resource.Spec.Flags(andToggleDiskCancel) while it carries out a toggle-disk, and the discovered volume groups and ZFS pools inNode.Spec.Props. - Status is observed state. The satellite writes what it reads from the kernel (
drbdsetup events2,drbdadm status,lvs), and the controller’s allocators write values derived from Spec. The REST API writes Status in one place: the resource activate endpoint with?reallocate-port=trueclearsResource.Status.DRBDPortthrough a Status patch. The allocator only fills an emptySpec.DRBDPort, and the controller copies the spec value back intoStatus, so the port does not change.
A whole-object Update is unsafe whenever both halves have different writers: a Spec write in flight would overwrite a concurrent Status write, and the other way round. Status goes through the Status subresource. Fields with more than one writer, such as Resource.Status.DRBDPort from the controller next to DiskState and CurrentGi from the satellite, go through Server-Side Apply with separate field managers, blockstor-controller and blockstor-satellite, so each side owns only its own fields.
| Field | Half | Why |
|---|---|---|
Resource.Spec.NodeName | Spec | Placement target. |
Resource.Spec.Flags | Spec | DISKLESS and TIE_BREAKER are requested, not observed. |
Resource.Spec.StoragePool | Spec | Written by the allocator. |
Resource.Spec.Volumes[i].SeedFromGi | Spec | DRBD generation ID to stamp on first activation. |
Resource.Status.DrbdState, InUse | Status | Kernel state from events2. |
Resource.Status.Volumes[i].DiskState, CurrentGi | Status | Per-volume kernel state. |
Node.Status.ConnectionStatus | Status | Whether the satellite is alive. |
A field that fits neither half, typically a transient hint, goes into an annotation.
Writes are patches
Every mutating path, in the REST handlers and in the CLI, goes through the store’s Patch* entry points. They fetch the current object, apply the caller’s change to it, and retry on conflict. A caller hands over a change, never a whole object, so a key another writer added in between is kept. Checks that depend on current state, such as refusing a shrink, run inside the patch against the state the write lands on.
Invariants that must hold for every writer live in the CRD, not in a client: size bounds are Minimum and Maximum on the field, and the controller-allocated identities (drbdPort, drbdNodeID, skipInitialSync) are settable-once CEL rules. A check that lives in one client is not a check the data is subject to.
Option inheritance
DRBD configuration resolves from the broadest scope to the narrowest:
ControllerConfig -> ResourceGroup -> ResourceDefinition -> Resource
The typed resolver is ResolveDRBDOptions in pkg/drbd/typed_resolver.go. Pointer fields carry the nil-versus-set distinction: nil inherits, and any non-nil value, including false and zero, overrides. A merge written as if *src.X { out.X = src.X } would drop an explicit false; pkg/drbd/typed_resolver_test.go pins this.
Wire shape and CRD shape
Two shapes coexist:
- The wire shape in
pkg/api/v1, identical to upstream LINSTOR’s REST API, with property bags asmap[string]string. - The CRD shape in
api/v1alpha1, with typedSpec.DRBDOptionsandSpec.ExtraPropsfor keys not typed yet.
pkg/store/k8s/ is the boundary. drbd_transcode.go parses wire properties into typed fields on write and emits them back on read, so golinstor sees the shape it expects. Unknown DrbdOptions/* keys round-trip through ExtraProps.
Skipping the initial sync
Adding a third replica to a two-replica volume would otherwise copy the whole device. The skip pipeline:
- The satellite’s
events2observer parsescurrent-uuidand publishes it asResource.Status.Volumes[i].CurrentGi. - When it allocates a new replica, the controller picks the lowest-named
UpToDatepeer’sCurrentGiand stamps it on the new replica’sSpec.Volumes[i].SeedFromGi. - On first activation the satellite runs
drbdmeta ... set-gibetweendrbdadm create-mdanddrbdadm adjust, so the first handshake sees the new replica as already in sync.
tests/e2e/replica-add-no-resync.sh gates it end to end. Promoting a tiebreaker to a replica with data deliberately does a full sync instead; see row 84 of the parity deltas.
Reconcilers
Controller side, in internal/controller/:
ResourceReconcilerallocates the DRBD node ID, port and minor, picksSeedFromGi, and promotes a diskless replica that is in active use.ResourceDefinitionReconcileradds a diskless tiebreaker when a definition has an even number of replicas with data, and sets the quorum policy.NodeReconciler,ResourceGroupReconciler,StoragePoolReconcilerandSnapshotReconcilerown the invariants of their kinds.- Cross-cutting controllers: the node heartbeat watchdog, node label sync, auto-diskful, auto-evict, auto-snapshot, group rebalancing and replica migration.
Satellite side, in pkg/satellite/controllers/: each satellite runs controller-runtime reconcilers that watch only the objects whose spec.nodeName is its own node, drive the DRBD, LUKS and storage layers, and write observed state back through Status. pkg/dispatcher/ translates the CRDs into the desired state they apply.
There is no satellite to controller connection. Both sides talk only to the Kubernetes API.