Skip to content

CSI-addons volume replication: design, driver services, and operator wiring - #548

Open
geoffrey1330 wants to merge 196 commits into
mainfrom
integrate_csi_addons
Open

geoffrey1330 wants to merge 196 commits into
mainfrom
integrate_csi_addons

Conversation

@geoffrey1330

@geoffrey1330 geoffrey1330 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

CSI-addons volume replication and non-disruptive test-failover

Two DR capabilities for simplyblock, both driven from the operator/hub:

  1. CSI-addons volume replication — brings simplyblock volumes under the
    standard VolumeReplication API so Ramen can promote, demote, and resync them.
  2. TestFailover — a non-disruptive "bubble" drill that recovers a replicated
    volume or consistency group onto the DR target, proves the data is recoverable,
    and tears down, without touching the live source or its replication.

Part 1 — CSI-addons volume replication

The storage-side half of the DR story: the design, the vendored csi-addons
controller-manager, the driver's Replication and Identity gRPC services, the
lifecycle verbs, the control-plane client, and the operator's sidecar and RBAC.

Design

  • design-csi-addons-replication.md and its test plan.
  • Reworked §3 Architecture: uniform promote/demote endpoints, operator
    preflight with a one-owner role, the P0-6 test-failover read path, and metrics
    export.

Vendoring the csi-addons controller-manager (P0-5)

  • Vendored csi-addons v0.15.0 CRDs and controller-manager into the chart, so
    VolumeReplication / VolumeReplicationClass are served and reconciled
    alongside the driver.

Phase 1 — driver Replication + Identity services and sidecar wiring

  • csi-driver: a CSI controller Replication service and a csi-addons
    Identity service, wired into the driver and its gRPC server.
  • atlas-lib: a control-plane replication client (controlplane/replication.go)
    and regenerated control-plane API bindings, plus RPC error classification.
  • operator: the SimplyblockDriver CRD and driver controller grow the
    csi-addons sidecar — its node-id env, the endpoint, and the RBAC it needs.

Phase 2 — lifecycle verbs

  • Promote / Demote / Resync end to end, backed by the P0-3 demote endpoint,
    with the planned-gate force-escalation fix.
  • A shared volumeIDFrom(req) helper resolves the volume id from
    ReplicationSource.Volume.VolumeId, so every verb addresses the same volume.

Fixes

  • Clone visibility (§14.5): drtest- clones are created as internal volumes,
    the same as landing volumes.
  • RBAC: the TokenReview verb, the csi-addons controller-manager roles, and
    the simplyblockdriver controller roles.
  • Image / endpoint: support the quay.io/csiaddons image pattern and correct
    the csi-addons endpoint.

Part 2 — TestFailover (non-disruptive test-failover)

A new TestFailover CRD and controller on the hub that run a DR rehearsal
without disturbing production: recover a replicated volume — or a whole
consistency group — into an isolated bubble namespace on the DR target,
leaving the source and the running replication untouched.

Core mechanism

  • Reads the source PVC and its PV on sourceCluster from the hub through an OCM
    ManagedClusterView, learning the backend handle without a hand-extracted id.
  • Resolves the latest replicated snapshot (volume) or generation (group)
    already on the target's backend — nothing is shipped, nothing is promoted.
  • Clones the recovery point on the target's own backend and places a bound PV
    • PVC on the target via an OCM ManifestWork, driven by a non-blocking
      state machine (ResolvingSource → ResolvingPoint → Cloning → Placing → Ready),
      with a finalizer-driven teardown that reclaims the clone.

Consistency-group scope (scope: Group)

  • Recovers one PVC per member from a single crash-consistent replicated
    generation.
  • Resolves the group's source UUID and member PVCs correctly from the group
    record, and reads the group's replication policy off the group (policy_id)
    rather than a placement heuristic.

Bubble clone staging

  • Carries the source PV's volumeAttributes and fsType onto the synthesized
    bubble PV, so the node plugin stages and mounts the clone correctly.
  • Node-plugin guard against a nil VolumeContext on the static bubble PV.

Cross-cluster only

  • Test-failover recovers onto a different cluster (the DR target); same-cluster
    (in-place) recovery was removed — the bubbleCluster must differ from
    sourceCluster, and a same-cluster drill is rejected up front.

Design

  • design-test-failover.md and its test plan.

Areas touched

Area Feature What changed
csi-driver/internal/csi/controller/replication.go Replication service + lifecycle verbs
csi-driver/internal/csi/csiaddons/identity/ Replication csi-addons Identity service
csi-driver/internal/csi/node/ TestFailover nil VolumeContext guard for the static bubble PV
atlas-lib/controlplane/replication.go Replication control-plane replication client
operator/internal/controller/testfailover_controller.go TestFailover CRD controller + state machine
operator/internal/webapi/{consistency_group,group_failover}.go TestFailover group source + policy + generation reads
operator/internal/controllers/driver/ Replication sidecar wiring + RBAC
operator/api/v1alpha2/{simplyblockdriver,testfailover}_types.go both CRD fields
helm-charts/.../simplyblock-operator both vendored csi-addons controller-manager + RBAC
operator/docs/designs/ both design-csi-addons-replication.md, design-test-failover.md + test plans
shared/openapi.json Replication control-plane API additions

noctarius and others added 30 commits September 15, 2026 08:46
… rebuilt (#542)

* feat(operator): StorageNode moves to v1alpha2, and its operations are rebuilt

Both kinds shipped in a shape that predates the group's conventions, so this
adds a v1alpha2 hub, a v1alpha1 spoke, and the conversion between them, and
rewrites the operations controller against design-storagenode.md in
internal/controllers/node/.

What moves on the entity is §15.1. spec.storageNodeSetRef becomes spec.clusterRef
plus spec.nodeSet, spec.overrides becomes spec.config and stops being an override
of anything, spec.socketIndex becomes spec.slot, the failure domain becomes a
label rather than an index on both the spec and the status, and the four per-node
fields that reached nothing move to StorageCluster.spec.storageNodes. Most of
spec.config is immutable by marker, and the three fields with exactly one
legitimate writer are guarded by a webhook instead. Status gains a typed phase, a
provisioning step, observedGeneration, and a device summary that is two counts
rather than a string — which also corrects a rendering that reported total/online
against a documented online/total.

Appendix C lands with it. StorageCluster.spec.storageNodes is the workload every
storage node runs as, which storagecluster_types.go recorded as deliberately
absent while this kind was still v1alpha1.

The operation is the larger half. It gains a status.step holding a declared
statemachine graph per action, an Aborted phase and the spec.abort that reaches
it, and the seventh HostMaintenance action that retires the node-drain
coordinator. status.triggered goes with nothing replacing it: every step completes
on a predicate over current state, and every call is skipped when its target is
already at or past what the call would produce, so a step recorded without its
side effect having fired is safe to re-enter. The two drain counters regroup under
status.drain, where volumesTotal is written once and replaces a pending count that
had to be kept in step with it.

Two of the conversion's rows needed more than an assignment. spec.clusterRef is
not on the stored object and a conversion has no client, so it is read from the
controller owner reference the upgrade's reparent step puts there before the
storage version moves. And status.subPhase is not a function of status.step:
Migrating means the volume drain under Remove and the relocation restart under
Migrate, and Restarting means the wait for the node under Migrate, so the action
is an input to both directions — which is the defect §6.3 splits the value in two
to remove.

StorageNodeMetrics joins the four readings in metrics.simplyblock.io/v1alpha2,
served rather than stored for the reason they are. It sits between two of them: a
device's reading is one drive and a cluster's is the whole fleet, and a node's is
the machine, which is the unit placement is decided against and the unit a drain
moves volumes off. StorageNode.status.resources.capacity carries the same pair
with hysteresis; this is the same measurement without the damping.

One thing is not what the design specifies, and §15.4 records it. §8.4 fans the
drain out as one PersistentVolumeOps per volume, and that kind has not been
written — the StoragePool rework hit the same wall. The fan-out is the
VolumeMigration that exists and works, tracked by the same label and cleaned up by
the same cascade, and it becomes a PersistentVolumeOps when that kind lands.
Blocking a drain that works today would have been the worse trade.

One key deliberately does not move to the storage.simplyblock.io prefix. The
per-slot storage-node-uuid label on a worker Node is what external-provisioner
caches in CSINode, and it hard-errors CreateVolume when a live Node's topology
keys do not match the cached set, so §5.2's own rule that the key must never
change for a worker's lifetime outranks the prefix migration. The upgrade tool's
key rewrite moves it in step with the CSI driver that reads it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(operator): the node domain moves to controllers/node, and StorageNodeSet retires

design-crd-model.md §7.10 assigns StorageNode, StorageNodeOps, StorageDevice,
StorageDeviceOps, and the storage-node workload to one package, and only the
operation had moved. The rest follows here, and with it the retirement §15.3 was
blocked on.

The entity controller arrives against the provisioning machine of §4.2. Adding a
backend node is not idempotent and the call adds every socket of a worker at once,
so two objects for one worker that both observe an empty status.uuid would add it
twice; the claim is made in Kubernetes first, as an optimistic-lock patch on the
transition into Posting, and status.postedAt and the List over siblings that stood
in for it are gone. Adoption is a branch of the same machine rather than a second
path, reached from the host check by an upgrade Secret and from either gate by a
backend node already at the worker's address.

HostMaintenance stops being unreachable. §10 has the entity controller raise it
when it sees a worker cordoned, which is what the Node watch is for, and the
eight-phase drain coordinator in a fleet object's status goes with it.

The workload becomes the cluster's. The DaemonSet, the two Services, the
EndpointSlice, the serving certificates, the ServiceAccount and its role, and the
per-node ConfigMap are children of the StorageCluster now, driven by a second
controller on that kind which writes none of its status. Several sets per cluster
collapse to one workload, because growth is nodes rather than sets and what
differs between hardware generations is per node already. The per-node ConfigMap
loses its merge with it: a node carries its whole configuration, so an entry is a
rendering of one object rather than a resolution of two.

skipKubeletConfiguration is written out rather than substituted. It is the one
rename in the migration that also inverts, so a mechanical one would have turned
kubelet configuration on for every cluster that never mentioned it.

StorageDevice moves in the same change, because §7.10 puts it here and a device
cannot discover itself. Its cluster label stops going through the set: a node
names its own cluster, so a label on a device no longer depends on a third object
being readable.

The latency controller's reading moves onto the nodes. A fleet-wide list made
every node's measurement a write to one object shared by all of them, and a stale
snapshot of that list silently dropped entries a concurrent reconcile had written.
Its own package move belongs to controllers/volume, which design-persistentvolumeops.md
creates.

internal/controllers/testsupport is the shared test package §7.10 names. cluster
and pool each rolled a local copy of the same four helpers; the moved device
suites use this one rather than adding a third.

The storage-version guard is what found the rest. Marking v1alpha2 as stored put
StorageNode into TestTheOperatorReadsEveryKindAtItsStoredVersion's guarded set,
and every remaining v1alpha1 reader — the device mirror, the node subscription,
the validating webhook, and the deployment band's worker check — now reads at the
version a fresh install answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(operator): regenerate the installer and clear what the linter found

dist/install.yaml is generated from the same kustomize build the CRDs and the
chart are, and the node domain's move changed all three. Only the first two were
regenerated, so the installer still granted the retired StorageNodeSet and still
withheld storagenodemetrics from the aggregated view role.

The linter's five findings are the move's own loose ends. Two functions stopped
being able to fail once the StorageNodeSet lookup behind them went, so both lose
an error nothing could return. The metric labels a terminal phase reports under
get names rather than sharing a spelling with the control plane's device status,
which is a different vocabulary that happens to use one of the same words. The
conversion fixtures' cluster name becomes a constant now that a third file names
it. And the status-subresource constant goes with the device suites that moved
out of the package.

`make build`, `make lint`, and `make test` are the repository's own entry points
and are what should have been run: `go build` alone does not regenerate, and it
is the regeneration that was missing.

* feat(operator): the StorageNode admission guard, and the sizing stamp its conversion needs

§3.2 and §3.4 are the guard. It answers two different kinds of question, and the
split is which of them a request can decide on its own.

On update it holds the three fields with exactly one legitimate writer.
spec.workerNode, spec.config.pcieAllowList, and spec.config.sizing are the
operator's, so a +k8s:immutable marker would lock the operator out along with
everyone else and no marker at all would let a user invalidate a layout claim by
editing a string. The guard already existed for the worker; the other two are new,
and the allow list is guarded rather than marked because a migration merges the
drives bound on the target host into it.

On create it resolves spec.clusterRef and checks the node against the cluster it
names. The reference is immutable from creation, so a node naming a cluster that
is not there can never be corrected and the rejection asks for the delete-and-
rewrite that is the only remedy. Then the device class: a cluster is built out of
one class of backend storage, because an erasure-coding stripe placed across both
is written and rebuilt at the slower one's rate — so a list mixing PCI addresses
with paths is refused, a list of the class the cluster is not is refused, and the
PCI filters are refused on a LogicalBlock cluster rather than silently selecting
nothing. And the sizing: a user's node is held to the fleet's, while the operator
may write a node that differs, because a rolling hardware upgrade is exactly the
case where it should.

The step is what the conversion's own doc comment promised. spec.config.sizing is
required on the hub and has no v1alpha1 spelling at all, so a node stored as
v1alpha1 converts up without one and the next write of it is refused. A conversion
cannot fill it in — it has no client and runs inside the API server's request path
— so the value travels in the stash annotation ConvertTo already reads, and
stamp-storage-node-sizing is what writes it. It runs after reparent-storage-nodes,
because the cluster it reads is the owner that step establishes, and it refuses
rather than writes when the cluster states no core count: a stamp built from
nothing would be refused by the field's own minimum one step later and be harder
to attribute there.

One bug the tests found before the code shipped. The guard compared sizing blocks
with ==, and the core count is a pointer, so two separately decoded objects held
two pointers to the same number and every update read as a re-size. It refused
every edit a user is entitled to make.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… creates a cluster and its nodes (#543)

* feat(operator): the ClusterDeploymentConfig controller, which is what creates a cluster and its nodes

Retiring StorageNodeSet left nothing producing StorageNode objects: the set's
reconciler was the only one, so a deployment following the shipped chart got a
DaemonSet and no backend nodes. This is the producer the redesign puts in its
place, and it makes a whole deployment one reviewable document rather than a set
of objects somebody assembles by hand.

The document is ephemeral and everything follows from that. It owns nothing,
nothing references it, and nothing reads it after the expansion, so there is no
finalizer and deleting it deletes a document. It specifically does not own the
StorageCluster it created: an owner reference would make deleting the document
delete the cluster and every volume in it.

A draft is validated on every reconcile and expanded on none. Validation writes
what it found and nothing else, so a reviewer sees the problems before approving
rather than after, which is the whole value of the gate and why a draft naming a
worker that does not exist is storable at all. It is also the only check a device
gets, since admission does not look at devices: a config discovery wrote cannot be
wrong about them, a hand-written one can, and approving without reading is how a
device mistake becomes an immutable document.

The expansion is create-only, which is §6's decision and the one with a real cost.
A document that names an existing cluster without asking to is refused with
ClusterExists rather than merged, because merging would have the operator decide
what a difference means, and the differences that matter are of the form: this
node's device list changed, whose only correct handling is not to apply it to a
node that already has data on those devices. Growth is a second document naming
the cluster in spec.clusterRef, which keeps every config a record of one
deployment action.

Two shorthands are spent here and nowhere else. spec.environment resolves into the
distribution flags on the cluster's workload, and the cluster's sizing is copied
into every node's spec.config.sizing, so the nodes carry what they were built with
and deleting the document loses nothing. The device class is read off the groups
and stamped onto the cluster, because the device lists already say which class the
deployment uses and the document carries no field for it.

CreatingNodes is the step that must be idempotent, and it is by construction: a
node is identified by its cluster, its worker, and its slot, so the step lists what
exists and creates only the slots that do not. Three passes over one document
produce the same two nodes, which is the test that matters.

What is not here: discovery, which is OperatorOps's (§8), and the approval webhook
of §5.2. Both are next steps rather than stubs.

* feat(operator): a fresh install discovers the fleet by itself

An operator installed into a cluster with disks in it can tell the administrator
what it found, and that is a better first experience than an empty namespace and a
document to write by hand. So a fresh install raises one OperatorOps with action
Discover, whose output is a ClusterDeploymentConfig in Draft that nobody has
approved and nothing acts on.

The guard is the whole of it. A discovery run is read-only and the draft it writes
is inert, but Probing creates one Job per worker, so a run that fired on every
restart would put a Job on every node of the fleet each time the operator was
upgraded. It therefore runs only where no previous result exists, which is three
questions rather than one: whether any OperatorOps has ever run, whether any
ClusterDeploymentConfig exists, and whether any StorageCluster is deployed. A
terminal run counts, and so does a failed one — the administrator has seen the
answer either way, and this operator is not the thing to decide they want another.

It is a leader-elected Runnable rather than a reconciler, because there is no
object whose desired state it converges toward and the question has one answer per
installation. The create is idempotent by name on top of the guard, since two
replicas answering the same three questions at once would both conclude yes. A
failure at any point is logged and swallowed: the operator works without the run,
and what is lost is a draft rather than a capability.

An administrator who does not want it says so by writing an object named
initial-discovery, which the guard then finds and declines behind.

The chart's operator_customresources.yaml is rewritten to the path this creates.
It was still shipping a StorageNodeSet that nothing has reconciled since #542, and
a StorageCluster beside it that would have made the expansion refuse with
ClusterExists. Nothing in the chart applies the file — it is a hand-apply
reference — so what it documents is now a ClusterDeploymentConfig an administrator
reviews, which is what discovery writes, and the pool sample is corrected to the
v1alpha2 shape it has had since the pool's own move.

* feat(operator): discovery reads what a machine is for, and proposes the storage tier first

A discovery run knew a worker's disks, its memory, its taints, and whether it was
cordoned, and nothing about what the machine was for. So an OpenShift
infrastructure node was indistinguishable from an ordinary worker, and a draft
proposed the two as though the choice between them did not matter.

It matters in both directions, and neither is the obvious one.

An infra node is the tier a cluster's own infrastructure runs on, and simplyblock
storage is infrastructure. A fleet with disks in its infra nodes meant those disks
to be the storage, and on OpenShift those are also the nodes that do not count
against a subscription's core limit. So an infra node with disks is preferred over
a worker with disks rather than avoided, and a draft now proposes it first.

A control-plane node is the opposite: a data path on an etcd host is a placement
almost nobody intends. It is left out unless spec.discover.enableControlPlaneNodes
says otherwise, which is what a combined three-node or single-node deployment sets.
There is no field beside it for infra nodes, because those need no asking for.

The role is a label and not a taint, and that is the whole reason this was
invisible. A taint is the cluster refusing to schedule there, which the run already
honored; a label is the cluster saying what the machine is for. The two do not
coincide: Kubernetes taints its control-plane nodes, so those were excluded by
accident, and OpenShift usually does not taint its infrastructure ones, so those
passed every check the run had.

The draft carries the preference as structure rather than as a note. Each role gets
a node set of its own, the infrastructure one first, so a reviewer who wants only
that tier deletes a block instead of moving hostnames between them. The hardware
grouping inside each set is untouched: two infra nodes with identical disks are
still one group. A fleet of plain workers gets exactly the document it got before,
one set named for what it is.

Two exclusions that were silent now say why. A worker left out for a taint says
which taint, a cordoned one says it is cordoned, and a control-plane node says what
it is and which field would include it. KubeNode.Taints was recorded for exactly
this and nothing had ever read it.

* fix(operator): what the review found in the expansion

Nine findings, and they share a shape: every one of them was a way for a document
to be expanded into something it did not describe, without failing and without
saying anything.

The worst of them broke two-socket deployments outright. CreatingNodes reads the
slot count off the cluster's spec.storageNodes, and nothing put socketsToUse or
nodesPerSocket there, because ClusterTemplate had no field for either. So every
cluster a document created ran one storage node per worker on socket 0, whatever
the document said, and the two-socket path only worked when growing a cluster
somebody had configured by hand. Both fields move onto the template, which is where
its own doc comment says the layout a cluster cannot change later belongs.

Three more changed behavior silently. A growth document's nodes left
config.expand unset, so the control plane read them as part of an initial layout
rather than as an addition to rebalance onto. An OpenShift deployment left
enableKubeletConfiguration nil, which the renderer reads as skipping the kubelet
configuration, inverting what every OpenShift setting this product ships does. And
a create that lost a race to another actor swallowed AlreadyExists and reported
ClusterCreated, which is the ClusterExists case §6 exists to refuse, arriving by a
different route.

Two were about what a document can express and the expansion cannot honor. Groups
may each name their own interfaces, and one DaemonSet serves every node of a
cluster, so the first non-empty value won and the rest were discarded; a document
whose groups disagree is now a finding rather than a guess. The same shape again
for a worker listed in two groups: a StorageNode is identified by its worker and
its slot, so the first group creates the nodes and the second group's devices,
fault group, and memory settings never reach them.

The rest are smaller. The ready-to-deploy marker was an annotation, and a label
selector cannot see one, so the marker was invisible to the only thing it exists
for. An approved document held because the control plane is unavailable reported
Draft, which the API defines as a document nobody has approved. And a pass that
created a node and then failed to persist status.nodeRefs left that node out of the
record permanently, because the next pass saw it as one that already existed; the
record is rebuilt from the slots the document describes rather than accumulated.

Eleven tests, one per finding and two for the pair that have an inverse worth
pinning: a document that creates its own cluster must not ask for a rebalance onto
its own initial layout.

* fix(deployment): decline the initial run when nothing can be inspected

A fresh install raised one discovery run unconditionally, and on a cluster with
no usable worker that run could only fail. A failed run is still an object
holding operatorops-finalizer, and an uninstall deletes the operator's namespace
and its Deployment together, so the controller that would release the finalizer
can be gone before it sees the delete. The namespace then stays Terminating.
That is what hung `make undeploy` in e2e: a single-node kind cluster whose only
machine is the control-plane node.

The guard gains a fourth question, asked with the same predicate the run itself
applies. UsableWorker is extracted for that reason rather than restated, so the
bootstrap cannot drift into raising runs the run declines, and the worker loop
now reads its decision from it and keeps the events for the explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rules worked out a reason for every machine they dropped and the run
reported a count. A reviewer reading "78 refusal(s)" cannot tell a fleet with no
disks from one whose disks are bound to a userspace driver they could reclaim,
or from one whose disks carry a partition table somebody left behind. All three
were on the lab this was found on, and the run said none of them.

The refusals now go out as events, and a per-worker explanation goes into the
status message, which is what kubectl shows. Separating the reason from the
noise needed a distinction the data did not carry: a machine presents sixteen
network block devices and four disks, so device rules mark themselves as
pre-filters and Plan.Explain folds in only the refusals about disks that were
genuine candidates, deduplicated and counted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g it

A controller reported only as takenByUserspace collapsed four states into two.
The bool was derived from a driver name the report already carried, so it added
nothing, and it lost the distinction that decides what to do next: uio_pci_generic
and vfio-pci differ in what they suggest about who bound the device, and a
controller bound to nothing at all read as healthy.

It also said nothing about whether the binding is live. Bound and in use are
different states, and only the second forbids reclaiming the disks. The holder
need not be simplyblock either: a userspace binding is equally how a hypervisor
passes a disk through to a guest, so a machine whose controllers are in use may
be serving something this product knows nothing about, and a refusal reading as
"leftovers, take them" would be an instruction to break it.

So the wire carries the driver and an InUse the probe measures, pci.CheckHolders
fills it in over the process table, and the classification stays a method rather
than a field. A controller whose holders could not be read leaves InUse false and
the failure is returned, not swallowed, because dropping it turns unknown into
free, and that is the error that reclaims a running guest's disk. Collect routes
it to the report's Unreadable list.

ReportVersion goes to 2. Decode refuses an older report by design, so probes from
before this are re-run rather than misread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion table

Three changes, each of which was a way for a run to report that a fleet full of
disks has none.

A controller bound to a userspace driver with nothing using it is a disk this
fleet owns and nothing is driving, so the draft now proposes it. The kernel
presents no block device for such a controller, so it could not reach a draft
through the device reading at all -- but everything a NodeGroup needs to name an
NVMe device is its PCI address, and that is the one thing the controller has. A
held controller is still left alone, whoever holds it. What is lost with the
block device is the disk's size and its content, so the group it lands in is
named for its count rather than a capacity.

The initial run waives a partition table. A machine that has held data before
carries one on every disk, and the run meant to show a fleet what it has was
reporting that it has nothing. The waiver stays narrow on its own terms: it
admits a disk whose only refusal is the table, so a boot disk stays out on its
mount and on the kernel refusing an exclusive open, which are refusals of their
own.

The probe is pulled on every run. It ships in the operator's image, so an
operator deployed from a moving tag was replaced by a pull while its probes were
not, and a node holding the previous layer kept running the previous probe. The
report version then refused those reports and the run waited on machines that
would never answer, which is how an upgrade produced a stalled discovery rather
than a wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The approval rules guard an approved document by reading oldSelf.approved, and
the field was a bool tagged omitempty, so a document nobody had approved was
stored without the key at all. The rule then found nothing to read and failed,
which denied the first approval and every one after it:

    spec: Invalid value: "object": no such key: approved
    evaluating rule: an approved deployment config is immutable

Nothing could be deployed. The gate the whole path runs through was shut against
everyone, including the only transition it was ever meant to allow.

The field is defaulted to false and always serialized, which is the same
requirement read twice: a reviewer sees the gate they are asked to open, and the
rules find the field they read. The has() guards carry the documents already
stored without the key, which cannot be fixed by defaulting alone -- one of them
is the draft this was found on. Every other CEL rule in the group already guards
its reads this way; these two were the exception.

The rules keep their meaning: approval is one-way, and an approved spec is
frozen. They are declared on the spec rather than the object, so the controller
can still mark an approved document with the label the expansion selects on and
still report on what it did with it.

The tests are new because nothing in the tree could have caught this. A fake
client cannot tell a stored false from an absent key, so the package gains an
envtest suite and the rules are exercised against a real apiserver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DaemonSet that runs SPDK selects workers by io.simplyblock.storagenodeset,
and nothing wrote that label on the provisioning path. Writing it was the retired
StorageNodeSet's job; when provisioning was rebuilt around StorageNode the
migration path kept doing it and the new path did not, so migrate.go held the
only two calls to LabelWorker in the tree.

What that produced was a deadlock rather than a failure. A node holds at
CheckingHost waiting for its worker's storage-node API to answer, and the process
that would answer cannot be scheduled until the label it is waiting on exists.
Neither side reports anything wrong, because neither side is wrong. A deployment
config expanded into a cluster and three nodes, and the fleet then sat there: the
DaemonSet had DESIRED 0 and every node was in Provisioning.

Enrollment goes in the workload reconcile, before the DaemonSet, because that is
the order a reader wants it in: the selector is written, then the thing that
selects on it. It is per worker rather than per node, since two nodes on one
worker are two slots of one machine and LabelWorker rewrites that machine's whole
slot label set from the nodes that want it.

Two returns of a result beside a non-nil error go with it. controller-runtime
discards the RequeueAfter in that case and logs a warning about it, so the retry
they asked for was never the retry that happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ve up

A storage node added without a management interface is refused by the control
plane with "No management interface with IP found in provided interfaces", and
the draft named none: discovery read the interfaces only to rank NUMA nodes by
NIC speed, so every document it wrote left the field empty and every node_add it
led to failed. Nothing validated it either, so the refusal arrived as a task that
gave up on a document that looked complete.

The probe now reads what choosing one needs. sysfs carries no addresses, so they
come through a seam on the inventory Config whose default reads this process's
network namespace -- the host's, for a caller with host networking. Bridges are
read from the bridge directory the kernel exports rather than guessed from br0
and cni0 and docker0.

The choice is a ranking rather than a match, because a fleet's machines do not
agree on what their NICs are called. The interface holding the node's InternalIP
wins outright, since the operator addresses the worker by that everywhere else;
failing that, the fastest hardware interface that is up and holds a reachable
address. The cluster's own plumbing never wins, and a machine presenting nothing
suitable is named nothing rather than named wrongly. The interface joins the
group signature, because a NodeGroup names one for every worker it lists and two
machines that call their NICs different things cannot be described by one group.

Resolving now asks again when the add is over. It matched the backend node the
add was asked to produce and waited out its deadline when there was none, holding
the cluster's one node-add slot while it did -- so every other node waited behind
a node that was never coming. The task window is the evidence: no node_add still
in it means the add this step waits on has finished, and an add that finished
without a node is one to ask for again.

Report version goes to 3 for the two new interface fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The window publishes what is running, so a task's whole history is the one event
raised when it leaves it. That event said the task was no longer running, which
is as true of one that succeeded as of one that exhausted its retries, and it was
raised as Normal either way.

The control plane says which. A node_add that gave up came back with retry 11 and
a function_result of max retry reached, and the operator decoded both and read
neither: the event was built from the previous snapshot, and the finished task
was skipped by the loop that had it in hand.

So the finished task is kept by id rather than skipped, and the event carries the
outcome. The retry count decides the verdict, because the status says done for
both and the schema already names the count as the one number separating a task
that is slow from one that is failing. The control plane's own result goes into
the note whatever the verdict, since that is where the answer actually is.

The recorder in the tests kept the reason and dropped the message, so a test
could not have caught any of this. It keeps both now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ating a step

A step does its work and then records it, so a recording that fails leaves the
step not having happened as far as the next pass is concerned, and the next pass
does the work again. Writing's work is creating a document and telling a reviewer
about it.

The status write was a bare Update of the object the reconcile had started from,
so anything that touched the run in between lost it the race. What that produced
on a live cluster was a run that wrote one document, reported writing it twice,
and reported finding it already there -- describing to a reviewer a race that
nobody had, in the only record a finished run leaves.

It is now a patch against a fresh read with an optimistic lock, retried here
rather than paid for by the step. That is the shape the deployment config's own
status write in this package already uses, and the one reconciler in the tree
that was not using it.

The two other bare status updates are left alone and are not the same hazard: one
is a best-effort clear that logs and continues, and the other is a one-shot
upgrade step rather than a reconcile loop.

The tests reach the failure through a client interceptor, because a fake client
never produces a conflict and so could not have caught this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A StorageNode's name is a cluster name, a worker hostname, and a slot, so it
outgrows the 63 bytes a label value allows on any fleet whose machines carry
fully qualified hostnames. The mirror wrote that name into a label unchanged:

    metadata.labels: Invalid value:
    "discovered-initial-discovery-cluster-vm03.simplyblock4.localdomain-0":
    must be no more than 63 bytes

What that costs is not a truncated label. The API server refuses the whole
object, so the mirror for every device on that node fails to reconcile and none
of them is ever published -- the devices of the machines with the longest names,
which is every machine on a fleet that uses domain names.

The three values now go through the kube.Formula the package already has for
this, which leaves a value that fits exactly as it is and cuts one that does not
with a digest over the whole input. Both halves matter. The labels exist for a
person to select on, so a value nobody can type is a selector nobody can write;
and cutting without the digest would map every node of a long-named cluster to
one label, answering "which devices are in this node" with the whole cluster's.

Only these three are changed. The same raw names reach labels that a DaemonSet
and a disruption budget select on, where the writer and the selector have to move
together; these are written and read back by nothing, which is what makes them
safe to correct alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-entering a step is ordinary. A status write loses a race, approval bumps the
generation, the operator restarts mid-expansion -- and the step runs again. So a
step has to be idempotent against its own prior success, and this one read "a
StorageCluster by that name already exists" and refused, on a document whose own
status.clusterRef said it had put it there.

What that cost was the deployment. The config went Failed at AwaitingCluster
naming the cluster it had created itself, and the nodes it had not created yet
were never created:

    spec.cluster.name is discovered-initial-discovery-cluster and a
    StorageCluster by that name already exists; set spec.clusterRef to add
    nodes to it instead

status.clusterRef is the record that separates the two cases, and it was already
written by the pass that created the cluster. A document that finds its own
cluster resumes; one that finds a cluster it never recorded still refuses, which
is what the check was for.

The AlreadyExists branch gets the same treatment, because a cached read that has
not caught up with this document's own create reaches the identical state by the
other route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read that seeds the update is served from the informer cache, and the
DaemonSet controller rewrites status on every pod transition. So while pods are
rolling -- which is exactly when this reconcile runs -- the cached
resourceVersion is stale and the update loses:

    reconcile the daemon set: Operation cannot be fulfilled on daemonsets.apps
    "simplyblock-storage-node-ds-...": the object has been modified; please
    apply your changes to the latest version and try again

The conflict carries no meaning worth failing over: somebody counted a ready pod.
It failed the whole workload pass regardless, so the service, the endpoint slice,
the certificates, and the worker enrollment were all re-run on a back-off for it.

The write is retried on a fresh read, which is the shape the rest of the tree
already uses. The desired object is rebuilt per attempt so a retry does not carry
the version the previous one was refused for.

The sibling writes are the same shape and are left alone, because they are not
the same exposure: nothing but this operator writes the service, the endpoint
slice, or the roles, and two replicas racing over them is what leader election
already prevents. The DaemonSet is the one with another controller writing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The v1alpha2 port rewrote partitionsPerDevice against the new spec type instead
of carrying it across, and came out one too high in both branches: 1 where a
dedicated journal device means 0, and 2 where a journal partition per device
means 1. The comment recording what the numbers were for was dropped in the same
change, which is how they went unnoticed -- with the contract gone, 1 and 2 read
as plausibly as 0 and 1, and nothing tested it.

The numbers are not a preference. The backend compares what a device already
carries against 1 + this count, repartitions when they differ, and cannot
repartition a device whose table SPDK's gpt module has claimed:

    bdev_open: bdev nvme_5n1 already claimed: type exclusive_write by module gpt
    spdk_nbd_start: could not open bdev nvme_5n1, error=-1

So asking for one partition too many does not lay a disk out differently. On a
machine that has never run this product it silently builds the wrong layout, and
on one that has -- where the disks already carry the count the fleet was built
with -- it makes the node impossible to add at all.

The contract is restated on the function this time, and the mapping is tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every node operation held while its cluster was anything but active. That is
right for the operations the gate was written for: one that moves data while the
cluster is rebalancing or unready is either rejected by the control plane or
succeeds into a layout nobody described.

Removal is the exception, because it is the repair rather than the thing being
protected. A node is removed from an unready cluster precisely to make the
cluster ready, so holding the removal closes a loop with no way out:

    Warning ClusterNotReady  cluster ... is unready rather than active

The node could not be removed until the cluster was active, and the cluster could
not become active while the node it was stuck on was still in it. A node whose
add never finished puts a deployment there and leaves it there, with the only
operation that would fix it refusing to run.

Nothing else is exempt. The gate keeps an operation that moves data off a cluster
that cannot take it, and an exemption wider than the one case that needs it is a
gate that stops meaning anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A device scope is opened when a node resolves its backend id and closed when the
node is torn down, and the two run at different moments with different things
readable. The open has the node freshly placed in its cluster, so the cluster's
id is there to build the key from. The teardown may run with the cluster already
gone -- a cluster deleted with its nodes is the ordinary case -- and the close
needed that same id to rebuild the key it had added under.

Without it the close was skipped and the scope stayed in the set, so the manager
kept a stream open for a node the control plane no longer has:

    cpinformer stream disconnected, reconnecting
      {"subscription": "device", "scope": ".../b39b01f9-...",
       "err": "watch device ...: unexpected status 404 Not Found"}

It reconnects on a 3s-to-30s backoff for the life of the process, one leaked
stream per removed node.

The close is now by the node's own id, which is the thing the scope is about and
the one identifier a teardown always has. RemoveLeaf drops every scope ending in
it and nothing else; an empty id matches nothing rather than everything, because
a caller with no id knows nothing rather than asking for a purge.

The cluster argument that only existed to rebuild the key goes with it, and the
teardown no longer takes a cluster it does not read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing a node is a sequence of calls against a backend node: suspend it, move
its volumes, verify, delete it. The last step already reads a 404 as success,
because a node the control plane no longer knows about is the outcome the delete
asked for. The earlier steps did not, so a removal that found its node missing at
Suspending reported the 404 as a step that could not be advanced, and retried it
for as long as the operator ran:

    the step could not be advanced ... step: Suspending
      error: suspend node ...: the control plane answered 404:
             'StorageNode 7838e194-... not found'

A removal refusing to finish because the thing it is removing is gone. It also
holds the node's finalizer, so the object never goes either, and the deployment
is stuck on a node that does not exist.

The node can be missing before the step that would have removed it in more than
one way: an earlier attempt got that far and lost its response, the add being
undone never registered it, or somebody else removed it. None of them is a
failure of this operation, so the whole sequence is short-circuited rather than
each step patched.

The check asks the control plane rather than the stream's cache. A cache that has
not synced reports every node missing, and reading that as "already removed"
would finish a drain that never moved a volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A storage node takes a drive by formatting it, and a drive carrying anything
already is not usable until it is. Nothing said so: a discovered document named
the disks and left the question to a field further down that it never set, so
every deployment onto machines that had held data before failed inside the
control plane rather than at the document.

The flag goes on the document because the document is what somebody approves. It
is destructive, and the cluster's own field is immutable once the cluster exists,
so a default applied further down would format disks without the draft ever
saying so and could not be undone afterwards. A reviewer reads it in the draft
and strikes the line if any of those drives should be left alone, which the note
beside it says in as many words.

It is named for what is wanted rather than how it is done, because the how
differs by class: an NVMe device is formatted to a 4K block size and a logical
block device has its signatures wiped. One field covers both, so a document does
not have to know which class the expansion resolves it to. Only the NVMe half is
carried today -- the cluster has a field for it -- and the logical-block half has
nowhere to go yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cap exists because a node add reboots its host, and two hosts rebooting at
once costs the control plane its own fault tolerance. Each waiting node enforced
it by counting how many siblings had already claimed a slot, which works for
exactly one of them: reconciles are serialized per object and not across objects,
so every waiting node reads the count before any of them has written its claim.

The first add is therefore correctly alone, and the instant it finishes every
remaining node sees the same free slot and takes it. A cap that counts other
people's writes holds once and then means nothing -- on a three-node deployment
the second and third nodes were added together, which is the thing the cap was
written to prevent.

The decision is now made from the set that is waiting rather than from who has
written a claim: the contending workers are ordered, and a node goes on only if
its own place in that order is within the number of free slots. Two nodes reading
one set reach one answer without either having written anything, and the answer
does not change between passes, so a node told to wait is not overtaken by one
told to wait beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The document stopped once the objects existed. What that left was a cluster
serving nothing behind a document reporting Expanded, with nothing anywhere
saying that one more thing was required of anybody: the activation was a
StorageClusterOps that existed, was implemented, and was raised by nobody.

The document is the one thing that knows when the deployment it describes is
whole. status.nodeRefs names every StorageNode the expansion created, so the wait
is over what this deployment built rather than over whatever happens to name the
cluster -- a node somebody added later is not one it is waiting on, and a node it
made that never came up is one it must not pass over.

So the expansion gains a step after CreatingNodes: wait for its own nodes to
report Online, then ask. The request is a StorageClusterOps like any other,
raised by name so re-entering the step finds the one it raised rather than asking
twice, and owned by the document that raised it. What happens to the operation
afterward is that operation's business.

The step carries the longest deadline of the expansion, which is not because
activating is slow: every node this document created has to come online first,
and the node-add cap serializes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Releasing an operation's lock is a compare-and-swap on purpose: it re-reads the
cluster, checks the lock is still this operation's, and writes with an optimistic
lock, so a release that lost a race never clears a lock somebody else now holds.
Two operations running against one cluster with neither knowing is worse than a
lock held a moment too long.

The conflict was reported as a reconciler error to preserve that, and it is not
the only thing that does. The cluster's own reconciler writes its status on every
pass, so a conflict here is ordinary rather than exceptional, and what it
produced was a failed reconcile with a stack trace for an operation that had
just succeeded:

    release the lock on cluster ...: Operation cannot be fulfilled on
    storageclusters ...: the object has been modified

Retrying on a fresh read keeps the property intact, because the check is inside
the retry: every attempt re-reads and re-tests whose lock it is, so a lock taken
by somebody else between two attempts is found on the next read and cleared by
neither. What would be wrong is swallowing the conflict without the re-read --
that lets the caller reach a terminal phase and drop its finalizer while the
cluster stays locked by an object that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fault

The phases after the creation path are a reading of the status the control plane
publishes, and everything the operator had no reading for fell through to
Unavailable -- which the field documents as not serving, and not because anybody
asked. Three statuses landed there that are the opposite of that. A cluster being
expanded and a cluster being activated are not serving precisely because somebody
asked, and one that has never been activated is not serving because it is not
finished.

So a deployment reported a fault for its whole length, and an activation reported
one for as long as it ran.

unready, in_creation, and in_expansion now read as Provisioning, which is the word
StorageNode already uses for a thing being built rather than a second word for one
idea. in_activation is its own phase, because activation is not only the last step
of a deployment: an expansion ends in one and so does recovering from a
suspension, long after anything was being built.

Unavailable keeps its meaning by being what is left -- a status this operator has
no reading for, rather than the name for every cluster that is not serving.

The mapping table that lived in storagecluster_controller_test.go is replaced by
one in phase_test.go covering every status rather than six of them, because two
tables for one function drift and these two would have had to disagree first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alls it (#544)

* feat(operator): ControlPlane moves to v1alpha2, and the operator installs it

The registered ControlPlane was an observation with a spec field attached: the
chart brought the control plane up, and the object polled a readiness endpoint
it learned about from an environment variable. design-controlplane.md replaces
both halves, and this is that rework.

spec.source says which control plane the object means. A managed one is what the
operator installs, and an external one is an endpoint and a credentials Secret
the operator probes and reports on. status.endpoint publishes the resolved base
URL either way, so one object answers where the control plane is rather than
every controller resolving SIMPLYBLOCK_WEBAPI_BASE_URL for itself.

The install is a five-step machine over atlas-lib/statemachine: the FoundationDB
operator, its RBAC, and the FoundationDBCluster; a wait on that cluster reporting
itself available; the object store; the management API with the services beside
it; and a wait on the readiness probe. Every step is a server-side apply under a
stable field manager, which is what makes re-entering one a no-op and why the
machine carries no triggered flag. Steady state re-applies the same objects on
every pass, so an object somebody deleted comes back and one somebody edited is
corrected — which is why no operation exists for checking the install.

The phase is the worst verdict across the probe and eight watched components,
and only the management API and the database may produce Unavailable. That
asymmetry is the safety property: Unavailable holds every controller in the
operator, so the set of things able to cause it is a closed list, and a component
added without a decision lands in the non-essential default.

ControlPlaneOps is new, with the three operations that are not expressible as
desired state. Restart recycles a scope of workloads, draining first only when it
names something depended on. Upgrade runs Preflight, drains, writes the image
onto the entity, and verifies the reported version afterward, so a rollout that
failed back is a Failed operation rather than an Available control plane running
the old version. Backup asks FoundationDB rather than implementing one, and does
not own the FoundationDBBackup it creates. A validating webhook refuses an
operation naming an external control plane at creation, since all three act on
something the operator installed.

What the install covers is a base control plane, which is what the chart renders
with observability disabled, measured against a live deployment. The
observability half stays with the chart: none of it appears in a step of the
machine, and every component of it is non-essential.

controlplane.managedByOperator is the chart flag for §12 Q2 and defaults to the
operator. With it set the chart renders only the ControlPlane object beside the
FoundationDB CRDs, the Prometheus configuration, and the log-collector RBAC;
setting it to false renders the previous templates byte-for-byte. TLS is the one
configuration the spec cannot express, so the chart refuses the two together
rather than bringing a control plane up in plaintext.

v1alpha1 stays as the spoke. Its conversion now resolves an absent image toward
a managed source, because the hub requires exactly one member of spec.source and
every object stored at v1alpha1 is one the chart installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(operator): make the ControlPlane CRD generate the same way twice

CI failed the helm-sync and build-installer drift gates on output that varied
between generator runs rather than on anything stale. Two properties of the
ControlPlane CRD came out differently each time controller-gen ran, and both had
the same cause: two sources disagreeing about one value, resolved by map
iteration.

spec.source carried a +kubebuilder:validation:XValidation marker and
+k8s:immutable. controller-gen v0.21.0 emits a field's marker-derived rules and
its injected immutability rule into one x-kubernetes-validations list, in an
order that varied run to run — four of six one way locally, two of six the other.
It is the only field in the repository carrying both, which is why this has never
flaked before.

shortNames is resource-level and both versions of a kind feed one CRD. v1alpha2
declared shortName=cp and v1alpha1 declared a bare scope, so the merge picked one
per run and the short name appeared in five of twelve regenerations. StorageNode
already repeats its short name on both versions, and this follows it.

Fixing the first uncovered a real bug, which a CEL test written against a real
apiserver caught: +k8s:immutable on spec.source emits self == oldSelf over the
whole struct, so it froze the image inside the block along with the choice of
block. design-controlplane.md §3.2 says the block is immutable and its members
are not, and the Upgrade action's Applying step writes
spec.source.managed.image — which the apiserver rejected. The rule is now
explicit about what it freezes: which member is set cannot change, and what is
inside it can.

Both rules move onto ControlPlaneSource, where two markers of the same kind are
emitted in source order, and the field keeps only Required. cel_validation_test.go
covers all three outcomes against envtest, including the image edit that used to
be refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(operator): regenerate the CRDs after the v1alpha1 prose edit

A doc comment becomes a CRD description, so the house-style fixes to
ControlPlaneSpec.Image and ControlPlaneStatus.Message left the four derived
copies of the ControlPlane CRD carrying the old text: config/crd/bases, the
embedded copy the upgrade tool applies, dist/install.yaml, and the chart's.

No behavior changes. The regeneration is the whole of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(operator): the eight findings of the review on #544

Every one was real, and three were asserted as correct by tests written with the
code they covered.

The chart flag did not hand the install back. With managedByOperator false the
templates were gated out and the ControlPlane still named a managed source, so
the operator installed the same objects the chart had just rendered and both
owned them, which is the outcome the flag exists to avoid. It now names an
external source pointing at the in-cluster Service, which is what the operator's
side of a chart-installed control plane actually is: something that already
exists, to be resolved and probed rather than applied. That needs a source with
no static credential, so credentialsSecretRef becomes optional and an absent one
means an unauthenticated probe. The readiness endpoint takes none.

Initializing converted to a phase v1alpha2 rejects. The two enums share no
value, and only Ready was mapped, so a control plane observed while FoundationDB
was starting became an object the API server refuses on write. Both directions
are now written out, and the downward table is no longer derived by inverting
the upward one: the hub holds four phases and this version two, so Degraded and
Unavailable land on the value that tells a v1alpha1 reader the same thing.

The operation's parameters were mutable after admission. Preflight reads
spec.upgrade.image and Applying writes it several steps later, so clearing the
block between them dereferenced nil. The spec is now frozen except for abort,
which is the one field meant to be set after the operation starts, and applying
and verifying guard the block besides.

The singleton was per name and not per Kubernetes cluster. A "simplyblock"
object in a second namespace reconciled, applied the same fixed-name
cluster-scoped roles under the same managed-by label, and either one's finalizer
deleted what the other needed. The older object holds the install and the
younger reports that it does not, which is the rule SimplyblockDriver already
used for the same reason.

A Restart scoped to the FoundationDB cluster passed validation, recycled
nothing, found a healthy database on the wait that followed, and reported
success. The scope is checked against what can be rolled rather than against the
component table.

Deleting a running operation released the control plane's lock mid-rollout. The
webhook took create only, so the controller's deletion path dropped the lock and
the finalizer from any step. It now takes delete as well and refuses the four
steps that have started something nothing else would finish, which is the guard
StorageBackupOps already carried.

caBundleSecretRef was declared and wired to nothing, so an endpoint signed by a
private CA could not be reached. Resolution now builds the transport the probes
use, and a bundle that is named and unusable is an error rather than a silent
fall back to the system trust store.

status.endpoint was published and read by nothing. Both control-plane clients
now resolve it per call and keep their startup client where the object has
published none, so an external control plane is reachable and adopting this
changes nothing for a deployment that has not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(operator): rebuild the installer against the current develop

dist/install.yaml is generated from every CRD rather than from the ones a branch
touches, so it carries develop's kinds as well as this branch's and has to be
rebuilt whenever either moves. This picks up the ClusterDeploymentConfig fields
and the StorageCluster phase develop added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(operator): deployment profiles, and the source names they settle

A deployment is one of two things, and the chart now says which: standalone,
where this cluster hosts its own control plane, or managed, where a control
plane elsewhere manages this cluster's storage.

The API's two source members are renamed to match, because the word "managed"
was about to mean opposite things in the chart and in the CRD. spec.source.local
is a control plane this cluster hosts and the operator installs.
spec.source.managed is one somewhere else that this cluster registers with. The
word is about what manages the storage clusters rather than about who runs the
operator, which is the sense the hosted offering uses. Neither name has shipped
in a release, so the rename costs nothing but the edit.

controlplane.profile replaces controlplane.managedByOperator. The bool had two
states and the profiles have two, but not the same two: the bool chose who
installed locally, and the profile chooses whether anything is installed locally
at all. The chart-installs-locally path is dropped, so nothing renders the
control-plane workloads any more and the templates that only held them are
deleted. Their observability halves stay.

Three guards replace the one the bool carried. An unknown profile is refused by
name. The managed profile is refused without an endpoint, since nothing local
can stand in. TLS with standalone is still refused, and the message no longer
points at a value that no longer exists.

A fourth guard is new and is the one that matters. Helm deletes what the old
release manifest held and the new one does not, and before this the chart's
manifest held the FoundationDBCluster: upgrading a 26.2.x release in place would
have pruned the database holding every cluster definition, node registration,
and lvol record, and the operator would then have built an empty one. The chart
now looks for a control plane an earlier release installed and refuses, naming
the annotations that make the upgrade safe.

The ControlPlane object itself carries helm.sh/resource-policy: keep for the same
reason, and for a second one: Helm deletes the operator and the CR in one
uninstall, and with the operator gone first nothing remains to release the
finalizer, so the object cannot go and the release sits in `uninstalling`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(helm): deployment.profile, and drop operator.enabled

The profile moves out of the controlplane block. It selects the shape of the
whole deployment rather than only where the control plane is, and a managed one
will carry more than a source member: an edge cluster somebody else administers
differs in what it runs and in how its StorageClusters are configured, and those
settings belong beside the profile rather than repeated across the blocks they
touch.

operator.enabled goes. It was true in any deployment anybody wants: with it false
the chart renders the CRDs, a snapshot controller, RBAC, and a secret, and
neither the operator nor the CSI driver, which is not a deployment so much as
half of one. Sixteen templates lose the gate.

Helm evaluates a subchart condition as a boolean value path and cannot read a
profile string, so Prometheus and the reloader take their own. That is the
ordinary shape and truer besides: neither is implied by the operator existing,
and a managed deployment reports to the control plane that administers it rather
than to a Prometheus of its own.

NOTES.txt is rewritten. Its wait command asked for a phase called Ready, which
this branch renamed to Available, so it would have waited out its timeout and
failed on every install. It also carried a branch for the operator being
disabled, which nothing can now reach, and lost the first letter of "Wait"
somewhere before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(helm): cut the values comments to what a reader has to set

The deployment block ran to twenty-two lines of comment for one string, most of
it explaining why the value sits where it does rather than what setting it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: state the constraint rather than the system that was not built

The house style gives three shapes to avoid: counterfactuals describing a system
nobody built, rebuttals leading with what a thing is not, and notes reporting the
writer's own history. The comments on this branch carried forty-four of them.

Twenty-three are rewritten to state the constraint the code works under.
Twenty-one are kept: each names the defect a regression test guards, which is the
test's reason for existing and what the regression-test skill asks for.

Twenty-one em dashes are replaced by the mark that was meant, which is a colon, a
comma, parentheses, or a full stop in every case.

The rename in an earlier commit moved symbols and struct tags, so eight comments
were left describing the members by their old names. The conversion's said it
writes the managed member where it writes the local one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(helm): restore the admission configurations this branch dropped

templates/simplyblock-operator-webhook.yaml opened with a guard on
.Values.operator.enabled. Removing that value in f0b72bc left the guard
reading nothing, so the file rendered empty and the upgrade pruned the
webhook Service together with both admission configurations.

The operator went on registering thirteen handlers and serving them on
:9443 with nothing in the cluster routing to them, which is every
validating and mutating webhook it has, not only the ones this branch
adds. cert-rotation reported it twice, once per configuration, as a
certificate it could not update.

The guard is dropped at its source in sync-from-operator.sh and the
template regenerated.

check-rendered-objects.sh is the test. helm template exits zero for a
template that renders to nothing, which is why neither the chart lint in
CI nor a render check caught this, so it asserts the objects each profile
must produce rather than that the render succeeded. Run against the
unchanged chart it names the three missing objects in both profiles, and
against the pre-fix template it names .Values.operator.enabled too.

Verified on a cluster: both configurations present, the CA injected into
all ten validator entries, and a ControlPlaneOps naming an absent
ControlPlane refused by vcontrolplaneops.simplyblock.io.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(helm): drop the HAJMCOUNT env var, which reads a value nothing defines

daad350 added storagenode.haJMCount to values.yaml with no value and the
HAJMCOUNT env var that reads it. a50e7d1 renamed the key to
storagenode.journalManager inside a 255-line rewrite of values.yaml and
left the template on the old name, so the reference has been dangling
since April. Because the key was empty from the day it was added, the
rename changed nothing observable: the variable rendered as the empty
string before it and after it.

The setting itself survived the rename on a different path. The operator
reads journalManager from the cluster deployment config and sends it as
ha_jm_count, so nothing is lost by removing the env var; no chart
template reads journalManager at all.

Both charts carry the same template, and charts/README.md documented the
key as a supported value.

check-values-references.sh is the test: it reports every .Values path a
template reads that values.yaml does not define. haJMCount in both charts
was its only finding across 510 defined paths, and it would equally have
caught the .Values.operator.enabled guard fixed in the previous commit.

Removing the variable means the container sees it unset rather than set
to empty. That is unobservable here, since the whole template is gated on
storagenode.create, which defaults to false, but the consumer is an
external image and this notes the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(operator): name the reconcile paths after the members they serve

The rename to local and managed moved the API and the predicates and left
the names around them on the old vocabulary, so the dispatch read as its
own inverse: isManaged called reconcileExternal, and isLocal called
reconcileManaged. The behavior was right and only the names were not,
which is the shape that survives review and produces a wrong edit later.

The two entry points swap, so the local one moves out of the way first.
Eight locals and parameters that bind a LocalControlPlane were named
managed, and one that binds a ManagedControlPlane was named external. All
of it through gopls rename rather than a search and replace.

Two of these are shipped API documentation. A field's doc comment becomes
its CRD description, so kubectl explain answered the local member with
"Managed is a control plane the operator installs" and the managed member
with "External is a control plane that already exists". status.endpoint
carried the same inversion. Regenerated into config/crd/bases, the
chart's crds, the copy the upgrade tool embeds, and dist/install.yaml.

The hold message on an object that names neither member said "neither a
managed nor a remote control plane" and now names the two that exist.

A rename has no failing test to write first: the compiler and the suite
are the guard, and both are green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cluster

A ClusterDeploymentConfig is a draft until spec.approved is set, and setting it
is what makes the document immutable. A draft naming a worker that does not
exist, or a cluster that already exists, was therefore admitted and frozen, and
the expansion refused it afterward with nothing left to edit.

The two kinds of edit are now answered differently. A draft is admitted on its
structure alone, however wrong it is about the world, because that is what a
draft is for. The edit that sets spec.approved is answered against the live
cluster: every worker named by every group exists as a Node, spec.clusterRef
resolves when it is set and the cluster does not already exist when it is not, a
growth document's device class matches the cluster's, and no other approved
document already creates the same cluster. Every problem is reported at once
rather than one per apply, because approval is what freezes the document and an
approver fixing one mistake at a time is the same failure in slow motion.

A fifth check the design's list does not name is here for the same reason: a
document naming neither a cluster nor a clusterRef is structurally valid and
describes no deployment, and the expansion refuses it once it can no longer be
edited.

The device class and the target cluster name are read through the deployment
package rather than derived a second time, so admission and the expansion cannot
disagree about what a document says.

design-clusterdeploymentconfig.md 5.1 and 5.2 are the specification. 5.2's
premise is not quite right and the file says so: the API server validates CEL
before it calls a validating webhook, so on a current CRD the schema's
immutability rejection arrives first and the webhook's restatement answers only
for a cluster whose CRD predates those rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ithdrawn

Deleting an Ops object withdraws the record of an operation, which is a
different thing from asking the operation to stop. design-crd-model.md 3.1 has
both channels ask one question of one graph: a step declaring an abort edge may
be withdrawn, and a step declaring none may not. ControlPlaneOps and
StorageBackupOps carried that guard. StorageClusterOps and StorageNodeOps
carried only their finalizers, which a delete with --force --grace-period=0
skips, so a Promoting migrate could be withdrawn and take the topology re-point
it still owed with it.

Both guards read which steps refuse from the controller's own graph, through the
new UnabortableSteps in each package, and state only the prose: what the step is
in the middle of, which a bool cannot say. A test holds each table equal to the
graph's answer in both directions, so an entry for a step the graph can abort
from, or a missing entry for one it cannot, is a failure rather than a drift.

OperatorOps and StoragePoolOps deliberately get no guard. 3.1 derives the
webhook from the graph, and both of those abort from every step: discovery
changes nothing, and the pool's only action is declared and unimplemented. A
fail-closed webhook that can never deny would buy nothing and make those two
kinds undeletable whenever the operator is down. It is worth revisiting when
StoragePoolOps.Migrate is built on PersistentVolumeOps, or when OperatorOps
gains a second action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stop from

Four packages kept a map of abortable steps beside their graphs, plus a
predicate over it and a test asserting the map only named steps some graph
declared. StateDef.Abortable puts the property where the state is, so the map
key is the state and that class of drift stops existing.

The sharper gain is precision. Abortability is a property of (action, step), but
a table keyed on the step alone flattens every action of a MultiConfig together.
Nothing collides today, and nothing could express it if it did. Inside StateDef
the question cannot be asked wrongly, and the reconcilers now ask
Machine.CanAbort, which answers for the graph the machine was built from.

The callers that hold a step and no machine — the DELETE guards reading a step
out of a status — ask UnabortableStates or UnabortableMultiStates instead. The
multi form unions across actions, because that is what a per-step table can
hold, and it takes the union rather than the intersection so a guard reading it
refuses where two actions disagree.

StorageClusterOps, StorageNodeOps, and now ControlPlaneOps hold their refusal
tables equal to that answer in both directions. StorageBackupOps deliberately
does not: its guard refuses Restoring, which the graph can abort from, because a
delete leaves nothing behind where an abort leaves an object the controller
cleans up from. Binding a test there would force the weaker answer onto the
stronger channel, and the file now says so.

Also fixes the missing trailing newlines and the two linter findings the
previous two commits left in the webhook package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dist/install.yaml is a generated artifact that is committed, and the three
commits that added the ClusterDeploymentConfig, StorageClusterOps, and
StorageNodeOps guards regenerated config/webhook/manifests.yaml and the chart
without it. An install from dist/install.yaml would have created none of the
three webhooks, and the drift check in operator_manifests.yaml fails on it.

Produced by the root `make build`, which is the target that covers every
generated artifact at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fleet kinds embed the operator's ClusterDeploymentConfig spec, so an edit to
that API invalidates them too. Several landed without a regeneration here —
spec.approved gaining a default and its explanation, and enableDriveFormat among
them (2895275) — so the schema the fleet CRDs describe has been behind for a
while. No fleet workflow checks for the drift, which is why nothing said so.

Pure generator output, from the root `make build`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#583)

* docs(operator): design non-disruptive test failover (TestFailover CRD)

* docs(operator): updated design non-disruptive test failover (TestFailover CRD)

* feat(operator): TestFailover CRD and controller for non-disruptive test failover

* ran make operator-build-installer

* fixed linter issue

* fix(testfailover): stage the bubble clone with a real VolumeContext

* ran make operator-build-installer

* fix(testfailover): stage the bubble clone with a complete PV

* feat(testfailover): recover a consistency group (scope=Group)

* fix linter issue

* fix(testfailover): resolve group source UUID and member PVCs correctly

* fix(testfailover): read group policy from the group, not a placement heuristic

* fix(testfailover): recover in-place CG drills from a fresh source generation, not the DR target

* reverted test failover within the same cluster

* fixed failing operator manifest CI
@geoffrey1330 geoffrey1330 changed the title added design doc design-csi-addons-replication.md CSI-addons volume replication: design, driver services, and operator wiring Oct 1, 2026
geoffrey1330 and others added 26 commits October 1, 2026 17:22
…PI is absent

The operator crashed at startup on every cluster outside the hub. The
TestFailover controller unconditionally watched OCM ManifestWork
(.Owns(&workv1.ManifestWork{})), but ManifestWork is served only on the
hub -- managed clusters read it from the hub and never serve it locally.
Without the CRD the controller's cache never syncs and the manager exits
("failed to wait for testfailover caches to sync ... *v1.ManifestWork"),
taking every other controller (replication, storagecluster, ...) down
with it. That blocked DR failback, since the managed cluster's operator
reconciles the reverse ReplicationPair.

Gate the registration on the work.open-cluster-management.io API group
actually being served (serverHasAPIGroup, via the existing discovery
client pattern). On the hub it registers as before; off the hub it logs
and skips. Installing the ManifestWork CRD on managed clusters is the
wrong workaround -- it belongs only on the hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s API group

The previous gate checked the work.open-cluster-management.io API *group*,
but a managed cluster serves that same group/version for
AppliedManifestWork (the work-agent's local record) while never serving
ManifestWork. So the group-level check saw the group as served, registered
the hub-only TestFailover controller anyway, and the manager still crashed
at startup when the ManifestWork watch could not sync (confirmed live
2026-10-01: operator on the managed cluster Error at 2m32s).

Check the ManifestWork resource specifically via
ServerResourcesForGroupVersion. The regression test now covers the
AppliedManifestWork-only managed cluster, which is the case the group-level
check got wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tasks-runner-backup-merge container ran
simplyblock_core/services/backup_merge_service.py, which does not exist in
the control-plane image -- the service is tasks_runner_backup_merge.py,
following the same tasks_runner_* convention as every sibling runner. The
container crash-looped ("python3: can't open file ... backup_merge_service.py")
and pinned the whole tasks pod in CrashLoopBackOff (120+ restarts observed
live 2026-10-01).

The existing TestTheServicePoolsRunWhatTheyDeclare only checks the command
is a .py module, so the wrong name passed it. Add a regression test pinning
the real module name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t its end

A volume's PV keeps the handle it was created with across fail-overs, and
the chain of replication relationships behind that handle alternates
between the sites: every fail-over adds a hop to the other side.
resolveToLocalReplica walked the chain to its active end for every
csi-addons Replication RPC. After an unplanned fail-over A->B, Ramen makes
the old primary on A secondary: DemoteVolume (and DisableVolumeReplication
on VR deletion) on site A resolved to the chain's end -- the NEW primary on
site B -- and demoted it (live 2026-10-02, realbed WordPress: the demote
fenced the live primary's paths and took demote snapshots of it; the VRG on
A never became secondary, so the fail-back never got PeerReady).

The driver needs to know which clusters are its own. The operator marks
the clusters it manages `local` in the CSI secret's entries (an entry
another site registered for cross-cluster handle resolution is not), and
the resolver returns the chain's last member on a local cluster. A secret
that marks no cluster local -- an operator predating the flag -- keeps the
previous behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The old primary of an unplanned fail-over is reaped by the control plane
once its fail-over completed; when the site returns Ramen still demotes it
and deletes its VolumeReplication. Resolved to that member, the demote and
the detach got a 404 and the VR stayed Degraded (live 2026-10-02, site A,
80e3e748). A 404 on a member of a chain the backend records is nothing left
to demote or detach: success. A 404 on a handle without any relationship
stays NotFound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the chain's active end

After an unplanned fail-over the recovered site's old primary is reaped once
its fail-over completed. Ramen still resyncs that site's secondary VR (and
reads its status). Addressed to the reaped member both got a 404 and the
VR stayed Degraded (live 2026-10-02, site A). sbcli's replication_failback
is addressed to the failed-over clone and, given the original source
cluster, re-aims the clone's replication at that site's node so only the
delta ships: Resync falls back to the chain's active end with the local
cluster as the fail-back source, and the status read follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iver

A volume replicated to another site is promoted there under a PV that
keeps the handle it was created with, which names the cluster the volume
came from: that site's driver must be able to reach the control plane for
that cluster too. Until now each operator wrote only its own cluster into
simplyblock-csi-secret-v2, and the cross-registration was a manual merge
of the sites' secrets (realbed deploy.sh csi).

Each operator now writes, next to its own cluster (marked local, the flag
the driver's replication-chain resolver keys on), one entry per other
cluster the control plane lists, with the secret the list carries -- empty
when the control plane withholds it, and the driver then authenticates
with its API token. Entries another operator on the same Kubernetes
cluster marked local are left alone; a foreign entry whose cluster the
control plane no longer lists is dropped. The own entry never waits on
the list: when the list cannot be read, peers are registered on the next
sync.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ocal member

The control plane's failover endpoint takes the volume that holds the data
(the pairing's source) and creates the clone on the replication target. The
local member on the promoting site is the volume being replaced -- demoted,
or already reaped: on 2026-10-02 the fail-back promote on site A hit the
reaped 80e3e748 and 404ed while the live primary on B held the data.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
sbcli's replication_failback takes the volume that holds the data (the
failed-over clone) and, given the recovered site as source cluster, re-aims
its replication at that site's node. Addressed to the local member -- the
demoted or reaped old primary -- it configured nothing for the live clone:
after the unplanned fail-over of Gitea the clones on A had no replication
and lastGroupSyncTime stayed empty (2026-10-02). The pairing's status is
the active end's for the same reason. Demote and Disable keep the local
member; Promote already uses the active end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…volume carries

A PersistentVolume without fsType -- a static PV adopting an existing
volume (the storage operator's test fail-over clone), or one Ramen restored
without the field -- gave the node plugin an empty fsType, which it turned
into "ext4" before the device was looked at; the filesystem layer then
refused every such XFS volume ("the volume carries xfs and this plan asks
for ext4"), and the bubble VM never started (realbed 2026-10-03).

An empty FsType now expresses no opinion: the layer mounts the filesystem
the device carries, formats a blank device as DefaultFsType (ext4), records
what it found as its params, and the node plugin remembers that type for
the volume. A named filesystem keeps refusing a device carrying another.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…umeMode

A VM's disk is a Block claim. The drill's bubble PV and PVC omitted the
mode, defaulted to Filesystem, and the kubelet asked the node plugin to
mount the raw guest disk (mount: exit status 32); the bubble VM never
started (realbed 2026-10-03). The source PV's volumeMode is recorded on
the clone and set on both objects.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The console templates and their values block of feature/control-center-ui
(PR #512), copied as they are: self-contained (sbcc.* helpers only), so the
chart deploys the UI with the control plane on this line too. One addition:
controlCenter.service.nodePort pins the node port of a NodePort Service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… by 8 hops

The PV keeps the original handle while every relocate and fail-over appends
a clone, so the chain behind a volume grows by one per move and is never
compacted. Both walks (the node plugin's redirect to the active volume and
the controller's resolveChain) stopped after 8 hops; the ninth move of a
volume -- an unplanned fail-over on 2026-10-03 -- left its clone one hop
out of reach, the node fell back to the stashed context and attached the
original on the partitioned site, and the VM never started.

The walks now track the members visited and stop on a loop, with a bound of
256 as a guard far above any real chain. Tests: a 12-move chain alternating
clusters, and a looping pair.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hand-written sourceVolumeMode entry differed from the generated one in
wrapping; the Manifests check keeps the three copies identical to the
generator's output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erated TestFailover CRD

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om the hub

A site's storage cluster is built from objects on the site's API server (the
OperatorOps discovery, the ClusterDeploymentConfig draft it writes, the
StorageCluster the approved draft expands into), which a hub managing the
site through OCM does not reach. StorageSiteDeployment is the hub-side
request: the site, the discovery filters, the sizing, and the one-way
approval. Its controller (hub-only, registered with TestFailover when the
ManifestWork API is served) carries the request through one ManifestWork in
the site's hub namespace -- the discovery first, then a server-side apply of
the sizing and the approval onto the draft once it names nodes -- and
projects the draft, the StorageCluster and its nodes back through
ManagedClusterViews. Phases Pending, Discovering, Drafted, Deploying,
Online, Failed; conditions Delivered, Discovered, Approved, Ready; the
cluster's uuid and pool are in the status, which is what a StorageClass
names. Deleting the request orphans the work's resources: the storage
cluster is never torn down by withdrawing the request.

The console's ClusterRole may manage the kind and read ManagedClusters.
The TestFailover CRD copies are the generator's rendering (as on #618).

Design: simplyblock-dr docs/design/control-center-managed-discovery.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/install.yaml

Lint (lll) on the chain-walk change and its tests; the installer bundle
carries the TestFailover CRD's sourceVolumeMode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… CRD

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion

csi-driver: replication RPCs resolve the right chain member; a plan without fsType mounts what the volume carries
operator: StorageSiteDeployment — deploy a managed site's storage from the hub
…/simplyblock-operator into feat/csi-auto-cross-registration

# Conflicts:
#	operator/internal/controllers/cluster/storagecluster_controller.go
…tion

operator: register every cluster of the control plane with the CSI driver
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants