Skip to content

How it works

The reconcile loop

Every controller follows the same shape: watch a resource, compare desired to observed, take one safe step, record status, requeue. The operator decides; the agent performs host mutations; the agent reports; the operator rolls forward on the next pass.

sequenceDiagram
    autonumber
    participant U as nwctl / UI
    participant A as K8s API
    participant O as Operator (controller)
    participant N as Node agent
    participant H as Host / storage

    U->>A: apply CRD (intent)
    A-->>O: watch event
    O->>O: compare desired vs observed
    O->>A: patch .status (phase, next step)
    O->>N: delegate one host op
    N->>H: mutate (ip link / drbdadm / kairos)
    N-->>A: report observed state
    A-->>O: requeue
    O->>O: honor gates, then advance one step

These invariants are non-negotiable — each is a hard-won lesson from the DaemonSet's incident history:

  • Idempotence. Every pass is safe to retry; never leave partial state.
  • Honor explicit gates. Refuse to advance past a safety gate rather than racing it.
  • Backpressure. If the storage layer is unhealthy, wait — don't queue more work.
  • Fail loud, don't partially proceed. The motivating failure was one node flipping while the other didn't, and the next step firing anyway. Controllers stop instead.
  • Status reflects observed, never desired.

The coordinated bond-flip machine

The most battle-hardened workflow is the single→multi-node storage upgrade: flipping the storage bond from active-backup to balance-rr so DRBD can replicate across nodes — without disrupting the VMs running on top. In the DaemonSet this is a 21-state machine persisted in a ConfigMap. Nodewright now implements it as a first-class BondModeFlipPlan controller — the 12-state forward path plus its automated recovery ladder (rollback, bounded retry, and escalate-when-unprovable), built and exercised end-to-end against a fake DRBD/KubeVirt harness. Each step delegates host work through the HostOperation protocol and re-checks the cluster lock before it mutates anything.

stateDiagram-v2
    direction TB
    [*] --> Idle
    Idle --> PreconditionsCheck: entry gates pass
    PreconditionsCheck --> AcquireLock
    AcquireLock --> SuspendMigration: lock held + leader snapshot
    SuspendMigration --> DisconnectDRBD: VMs pinned, VMIMs aborted
    DisconnectDRBD --> DrainSatellite
    DrainSatellite --> FlipNode1
    FlipNode1 --> FlipNode2: node1 verified balance-rr
    FlipNode2 --> ResumeDRBD: both on balance-rr
    ResumeDRBD --> RestoreMigration: gates pass + two replicas UpToDate
    RestoreMigration --> ReleaseLock: evictionStrategy restored
    ReleaseLock --> Complete: lock released
    Complete --> [*]

    Idle --> Halt: any gate failure
    ResumeDRBD --> Halt: link marginal / asymmetric
    Halt --> Recover: automated recovery
    Recover --> [*]
    note right of Halt
        Freeze in place: retain the lock,
        keep VMs pinned. Automated recovery
        (retry / rollback) takes over — and
        escalates to a human where it can't
        prove safety. See "Recovery" below.
    end note

What the diagram encodes — and why each guard exists:

Stage The guard Why
PreconditionsCheck ≥2 eligible nodes; every peer live Never flip into a cluster that can't hold replicas.
SuspendMigration abort in-flight VM migrations, pin evictionStrategy=None If KubeVirt live-migrates a VM mid-flip, it can land on a node with asymmetric DRBD — data-availability risk.
DisconnectDRBD → DrainSatellite disconnect replication, move storage pods off the node Flip the bond on a quiesced storage path, not a live one.
FlipNode1 → FlipNode2 one node at a time; a failed flip halts A half-flipped cluster is the original incident; the forward path never leaves one node flipped and silently proceeds.
ResumeDRBD reconnect only after symmetry + a connectivity probe + the I6 consistency gate (see below) — and that gate runs before every reconnect, forward or recovery Never resume replication over a marginal or asymmetric link, and never let a workload attach to a replica that isn't provably consistent.
Halt → recovery any gate failure freezes in place (lock held, VMs pinned), then automated recovery retries or rolls back The forward path stops safely; recovery then drives back toward a known-good state where it can prove safety — and escalates to a human wherever it can't (see Recovery).

The six safety invariants

Every guard above rolls up to six invariants the controller will not violate. Each is enforced in code and tested against the fake harness — they are the reason this workflow can be trusted with a live cluster's storage. The first invariant sits above the rest: preserving the last consistent copy of a PVC's data outranks availability, and availability outranks automation.

# Invariant How it's enforced
I1 Never flip both nodes at once The two flip targets are snapshotted at AcquireLockbefore drain empties eligibility — and the operator keeps at most one outstanding HostOperation per plan.
I2 Never resume DRBD over a marginal link Two gates before resume — bond symmetry, then a single connectivity probe — must pass and stamp a resumeGatesPassedAt token; every later step refuses to act without it. No retry loop that could paper over a bad link.
I3 Never evict a VM mid-flip Pin-first: every VM is set to evictionStrategy=None and in-flight migrations aborted before any host change; protection is re-asserted at the entry of every mutating step, and any breach halts.
I4 One writer only An instance-fenced Lease, re-checked (renew-or-fail) immediately before every mutation. A lost lock halts — it never races another writer.
I5 Two replicas in sync before finishing Both RestoreMigration and ReleaseLock independently re-run the two-replica sync gate; a negative result defers forever, never bypasses.
I6 Never trust "UpToDate" alone — prove consistency The sync gate no longer believes DRBD's UpToDate string. It additionally requires zero out-of-sync sectors + matching generation-UUIDs + no split-brain, evaluated from both nodes and failing closed on anything unknown. This gate runs before every replication reconnect — forward and recovery. It closes the real incident where both replicas reported UpToDate while replication was only partial.

Data preservation is the number-one invariant

Everything else on this page serves one rule: never lose, and never corrupt, the last consistent copy of a PVC. If the controller cannot prove a replica is consistent, it treats it as unsafe — it will keep the workload pinned to the copy it can trust (even if that leaves only one copy) rather than let a VM attach to a possibly-divergent replica. Availability and automation both yield to this.

Recovery — automated, but bounded by proof

A halt is not the end of the story. When a gate fails, the plan freezes safely (lock held, VMs pinned) and an automated recovery ladder takes over. Its job is to get the cluster back to a known-good state without a human wherever it can — but only ever within what it can prove is safe.

stateDiagram-v2
    direction TB
    [*] --> Halted: gate failed, frozen safely
    Halted --> Dwell: let transients settle
    Dwell --> Retry: same rung, under the retry cap
    Retry --> Resumed: I6 gate proves consistency
    Dwell --> Rollback: forward not provable
    Rollback --> RevertedSafe: bond restored, I6-gated reconnect
    Retry --> Escalated: retry cap exhausted
    Rollback --> Escalated: rollback not provable
    Halted --> Escalated: split-brain / unknown state
    Resumed --> [*]
    RevertedSafe --> [*]
    Escalated --> [*]
    note right of Escalated
        Freeze and page a human.
        Lock retained, VMs stay pinned
        (eviction disabled, fail-over ignored),
        substrate untouched. No single-copy
        landing is forced here — that's B5/B6.
    end note

How the ladder behaves — and what it will not do:

Rung What it does The bound
Dwell Waits out transient link/sync noise before acting Cheap, reversible, no mutation.
Retry (same rung) Re-attempts the failed step, up to a tunable cap Every reconnect still passes the I6 gate; a retry can never paper over a bad link.
Rollback Reverts the bond toward its pre-flip mode to restore the known-good topology The reconnect on the way back is I6-gated too — rollback is not a trusted escape hatch.
Escalate Freezes and pages a human Reached whenever forward and backward are unprovable, the retry cap is exhausted, or a split-brain / unknown state appears.

Two design choices make this safe rather than merely convenient:

  • Escalate-when-unprovable. Automation is bounded by proof, not by effort. The moment the controller can't prove a safe next step — either direction — it stops and escalates rather than guessing. Split-brain and unknown consistency escalate immediately, with no retry window.
  • A frozen escalation is genuinely frozen. Escalation changes no phase, touches no lease, and mutates no host or storage. The cluster lock stays held and every VM stays pinned (eviction disabled, fail-over ignored) so nothing can migrate onto an unverified replica while a human investigates. Human recovery is an explicit, fingerprinted response — it is a reply to an escalation, not a trigger the controller can fire on its own.

What's deliberately deferred

Today, if a two-replica resume can't be proven, the plan halts and escalates — it does not yet automatically quarantine down to a single known-good copy and land there. That enforced single-copy landing is intentionally deferred to the hardware-lab tranches (B5/B6), where it can be exercised against real DRBD before it's trusted to run unattended.

This is why it's gated, not autonomous

Every step here mutates host networking or storage on a live cluster — high blast radius. The controller is over-specified with these guards as testable acceptance criteria; it ships default-off, installs no CRDs, and its dangerous paths are designed to run supervised with human sign-off, never as an unattended loop. The read-only observation bridge came first precisely because it carries none of that risk.