Upgrading
Version-to-version migration steps, newest first. Only releases that need you to do something appear here; any version not listed upgrades by bumping the chart.
miroir is pre-1.0, so breaking changes land in minor versions (0.9.0, 0.10.0, …). Read the section for every version you are crossing, oldest first. Upgrading 0.8.x → 0.10.x means doing 0.9.0 and then 0.10.0.
Every upgrade: keep the CRDs in step
Section titled “Every upgrade: keep the CRDs in step”The chart ships its CRDs in crds/, and Helm applies that directory only on
install, never on upgrade. An upgraded chart running against last release’s
CRDs fails in a quiet way: the API server prunes spec fields the old schema
does not know, the apply succeeds, and the controller or agent then complains
about configuration you can plainly see in your values. So every upgrade
starts with the CRDs.
Flux can do it from the chart automatically, but not by default;
upgrade.crds must be set (install.crds: Create is already the default):
apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasespec: install: crds: Create upgrade: crds: CreateReplace # default is Skip; requiredPlain Helm has no automatic path; apply the new chart’s CRDs before upgrading:
helm show crds oci://ghcr.io/home-operations/charts/miroir \ --version <new-version> | kubectl apply --server-side -f -0.11.x → 0.12.0: the health-monitor sidecar is gone
Section titled “0.11.x → 0.12.0: the health-monitor sidecar is gone”CSI v1.13 removed the VolumeCondition alpha API the driver used to report
replication health, replacing it with dedicated health RPCs
(ControllerGetVolumeHealth, ControllerListVolumeHealth,
NodeGetVolumeHealth). The driver answers those instead, and no longer
advertises the VOLUME_CONDITION capability.
csi-external-health-monitor-controller has not adopted the new RPCs and
refuses to start against a driver without VOLUME_CONDITION, so the sidecar
and its sidecars.healthMonitor values are gone from the chart. Nothing else
consumed the old condition.
The PVC events the sidecar raised have no replacement on most clusters:
kubelet’s volume-health manager ships in Kubernetes 1.37+ behind the alpha
CSIVolumeHealth feature gate (default off), and external-health-monitor has
no release that speaks the new RPCs. The miroir_volume_* metrics carry the same signals
(split-brain, disk failed, degraded replication) and the chart’s starter alerts
already fire on them, so alert there instead. See
Monitoring.
0.10.x → 0.11.0: node topology becomes MiroirNode CRs
Section titled “0.10.x → 0.11.0: node topology becomes MiroirNode CRs”The storage configuration moves out of the miroir chart entirely: the
node topology becomes MiroirNode custom resources (or a
MiroirNodeGroup materializing them from a label selector) and,
together with the StorageClasses and VolumeSnapshotClasses, is now
plain manifests you apply and version yourself; the miroir chart
installs only the driver. The CRD schema is what validates the
topology, and kubectl apply rejects unknown fields outright.
Alongside the move:
- Pool options are grouped under a per-backend block (
lvmthin,zfs, orloopfile) instead of prefixed flat keys. - The per-node setup Job is gone. The agent has always run the same pool provisioning at startup; a misconfigured pool now surfaces in the agent log and the MiroirNode status instead of a failed install hook.
- Canonical spellings are required:
volBlockSizeuppercase (4K, not4k),compressionlowercase (lz4, notLZ4). Earlier releases folded case; the CRD validates instead.
Existing volumes, their replicas, and their data are untouched. This is a migration of the configuration surface only; do not recreate any PVC, MiroirVolume, or pool.
1. Update the CRDs
Section titled “1. Update the CRDs”As above: upgrade.crds: CreateReplace for Flux, or helm show crds ... | kubectl apply --server-side -f - for plain Helm.
2. Move the storage configuration to manifests
Section titled “2. Move the storage configuration to manifests”The nodes, storageClasses, and volumeSnapshotClasses values
become plain manifests. For nodes, each entry becomes a MiroirNode
object of the same name (the old values are the CR spec, reshaped):
pools becomes a list of named entries, and each pool’s options move
under its backend’s block:
| 0.10.x values | MiroirNode manifest |
|---|---|
nodes.<n> (map key) |
metadata.name |
nodes.<n>.zone |
spec.zone |
nodes.<n>.address |
spec.address |
nodes.<n>.autoEvict |
spec.autoEvict |
nodes.<n>.pools.<p> (map key) |
spec.pools[].name |
...pools.<p>.backend |
(implied by the block below) |
...pools.<p>.device |
...pools[].lvmthin.device |
...pools.<p>.thinPoolSize |
...pools[].lvmthin.poolSize |
...pools.<p>.zfsDataset |
...pools[].zfs.dataset |
...pools.<p>.zfsCompression |
...pools[].zfs.compression |
...pools.<p>.zfsVolBlockSize |
...pools[].zfs.volBlockSize |
...pools.<p>.baseDir |
...pools[].loopfile.baseDir |
There is no backend field any more: the block that is present IS the
backend, so exactly one of lvmthin/zfs/loopfile is required even
when it has nothing to say: an lvmthin pool whose VG already exists
still writes lvmthin: {}. A homogeneous fleet can become one
MiroirNodeGroup instead of per-node objects
(Quickstart shows the layout).
Each storageClasses entry becomes a standard StorageClass manifest:
the chart’s knobs map to parameters
(the full table), and what
the chart used to default now needs writing:
volumeBindingMode: WaitForFirstConsumer, allowVolumeExpansion: true,
and the storageclass.kubernetes.io/is-default-class annotation where
isDefault was used. volumeSnapshotClasses entries become
VolumeSnapshotClass manifests (driver + deletionPolicy).
Before (0.10 miroir values):
nodes: k8s-0: zone: rack-1 pools: default: backend: lvmthin device: /dev/disk/by-partlabel/r-miroir thinPoolSize: 400gstorageClasses: - name: miroir-replicated replicas: 2After (plain manifests):
apiVersion: miroir.home-operations.com/v1alpha1kind: MiroirNodemetadata: name: k8s-0spec: zone: rack-1 pools: - name: default lvmthin: device: /dev/disk/by-partlabel/r-miroir poolSize: 400g---apiVersion: storage.k8s.io/v1kind: StorageClassmetadata: name: miroir-replicatedprovisioner: miroir.home-operations.comvolumeBindingMode: WaitForFirstConsumerallowVolumeExpansion: trueparameters: miroir.home-operations.com/replicas: "2" csi.storage.k8s.io/fstype: ext4Apply the MiroirNode manifests before upgrading the driver chart,
so the rolled agents boot straight into storage mode. Your cluster
already has one MiroirNode per storage node (the 0.10 agents created
them to publish pool capacity); kubectl apply over them just works:
the spec becomes yours, the status stays the agents’. Declaring the
fleet as a MiroirNodeGroup instead? The existing MiroirNodes carry no
group label, so the group reports them as Conflict and touches nothing
(a direct MiroirNode always wins). Hand them over explicitly, and the
group then converges each spec to its template:
kubectl label miroirnodes --all \ miroir.home-operations.com/node-group=<group-name>The classes need one extra step. The 0.10 miroir release’s manifest
still lists your StorageClass/VolumeSnapshotClass objects, and a Helm
upgrade deletes objects that dropped out of the manifest by name; the
one thing it respects is a helm.sh/resource-policy: keep annotation
on the live object. Shield them before step 3:
classes="$(kubectl get storageclasses -o jsonpath='{range .items[?(@.provisioner=="miroir.home-operations.com")]}storageclass.storage.k8s.io/{.metadata.name} {end}')$(kubectl get volumesnapshotclasses -o jsonpath='{range .items[?(@.driver=="miroir.home-operations.com")]}volumesnapshotclass.snapshot.storage.k8s.io/{.metadata.name} {end}' 2>/dev/null)"kubectl annotate $classes helm.sh/resource-policy=keep --overwriteAfter step 3 they are plain, unowned objects: the manifests you
authored above are their source of truth from then on (apply them over
the live objects, and drop the keep annotation if you like:
kubectl annotate $classes helm.sh/resource-policy-). MiroirNodes need
no shielding: the 0.10 chart never rendered them. Clusters tracking
unreleased main are the one exception (a development-window chart
briefly rendered MiroirNodes); there, extend the annotate command
with $(kubectl get miroirnodes -o name).
Loopfile users: also set the driver chart’s agent.loopfileBaseDirs
to every loopfile.baseDir in use: the agent pod’s hostPath mounts
are pod spec, which the driver chart cannot derive from objects it
does not render.
3. Upgrade the driver chart and let the agents roll
Section titled “3. Upgrade the driver chart and let the agents roll”Remove nodes, storageClasses, and volumeSnapshotClasses from the
miroir chart’s values (it fails fast with a pointer at this page if any
is still set) and run the upgrade as usual. While the
agent DaemonSet rolls node by node, the not-yet-rolled 0.10 agents keep
trying to write their old, partial pool topology into the MiroirNode
spec; the new CRD schema rejects those writes. That is by design (it
stops an old agent from wiping the pool configuration you just
applied), and the visible cost is small: a node’s pool-capacity
heartbeat pauses until its agent rolls, so status.observedAt on some
MiroirNodes goes stale for the duration of the rollout, and 0.10 agents
log rejected MiroirNode updates. Both clear on their own as the rollout
completes.
4. Verify
Section titled “4. Verify”kubectl get miroirnodeskubectl get miroirnode <node> -o yamlspec.pools on every storage node should show the per-backend blocks from
your configuration (lvmthin.device, zfs.dataset, …). If a block is
missing, the CRD update in step 1 was skipped and the old schema pruned it:
apply the CRDs and then your manifests again.
Also breaking, but unlikely to affect you
- The
--nodes-configflag and--mode=setupare gone, along with the per-node setup Jobs and their ServiceAccount. Only customagent.extraArgsor tooling that watched the setup Jobs would notice. - Topology edits no longer roll the pods (the ConfigMap checksum annotation is gone). The controller follows MiroirNode changes live, and each agent restarts itself when its own pool spec changes. This is the new intended behavior, not a regression.
- Two MiroirNodes sharing a replication
addressno longer keep every component from starting. The conflict is reported as anAddressConflictcondition (plus a Warning event) on the offending nodes, which are excluded from new placement until it is resolved. helm uninstallno longer destroys volume data by default. The pre-delete hook that deletes every MiroirVolume/MiroirSnapshot is now rendered only whenuninstall.confirmationis set toyes-really-destroy-data; see Uninstall. If your teardown automation relied on the old behavior, set the confirmation.- The controller pod now tolerates
node.kubernetes.io/unreachablefor 5 seconds instead of Kubernetes’ 300 default, so a dead node stops provisioning for seconds rather than five minutes (unreachableNodeTolerationSecondsrestores the old value if you want it). - The deprecated flat
MiroirNode.status.capacityBytes/status.allocatedBytes/status.metaUsedPercentfields are gone from the schema, along with the controller’s fold of them into the default pool. They existed for the 0.9→0.10 rollout skew; 0.10 agents already publish (and read) onlystatus.pools. Anything scraping the flat paths directly has been broken since 0.10; usestatus.pools[*].
0.9.x → 0.10.0: named storage pools
Section titled “0.9.x → 0.10.0: named storage pools”Each node’s storage config moves under a named pool. Your existing single pool
must be adopted as the pool named default: MiroirVolume replicas and
StorageClasses written before this release carry no pool reference, and they
all resolve to default.
1. Nest each node’s storage under pools.default
Section titled “1. Nest each node’s storage under pools.default”zone and address stay node-level. Everything else moves: backend,
device, zfsDataset, zfsVolBlockSize, zfsCompression, baseDir,
and thinPoolSize.
Before:
nodes: k8s-0: zone: rack-1 backend: lvmthin device: /dev/disk/by-partlabel/r-miroirAfter:
nodes: k8s-0: zone: rack-1 pools: default: backend: lvmthin device: /dev/disk/by-partlabel/r-miroirThe chart fails fast on the flat shape, so a missed node is a template error rather than a broken cluster. Under Flux the HelmRelease reports failed and nothing is applied.
2. Upgrade
Section titled “2. Upgrade”flux reconcile helmrelease miroir -n miroir-system --with-sourceExisting volumes, snapshots, VGs (vg-miroir), datasets, and loopfile
directories are reused as they are, with no data migration and no volume
downtime. During the rollout an old agent and a new controller can briefly
disagree on the MiroirNode status shape; placement treats those stats as
unknown (the same as a cold cluster) until the DaemonSet finishes rolling.
3. Optional: add a second pool
Section titled “3. Optional: add a second pool”nodes: k8s-0: pools: default: backend: lvmthin device: /dev/disk/by-partlabel/r-miroir fast: backend: lvmthin device: /dev/disk/by-id/nvme-Micron_7450_MTFDKBA800TFS_XXXX # k8s-1, k8s-2 identicalstorageClasses: - name: miroir-replicated replicas: 2 - name: miroir-replicated-fast replicas: 3 pool: fastEach agent creates the new pool’s VG and thin-pool at startup. New pools get
vg-miroir-<pool>; the default pool keeps vg-miroir, which is why step 2
needs no data migration.
Also breaking, but unlikely to affect you
- The agent/setup
--lvm-vgand--lvm-thinpoolflags are gone; VG naming derives from the pool name. The chart never set them; only customagent.extraArgswould notice. MiroirNode.spec.backendand the flatstatus.capacityBytes,status.allocatedBytes,status.metaUsedPercentfields are replaced by per-pool lists. Anything scraping those CR fields directly needs the new paths.- Pool metrics gain a
poollabel. The chart’s own alerts and dashboard are updated; your own recording rules or dashboards matching an exact label set are not. - Do not remove a pool from
nodeswhile volumes still reference it. Their reconciles and deletions fail loudly (storage pool "x" is not configured on this node) until the pool returns or the volumes are gone.
0.8.x → 0.9.0: RWX is opt-in
Section titled “0.8.x → 0.9.0: RWX is opt-in”Serving ReadWriteMany is now an explicit operator decision. Gateway pods run privileged in the release namespace, and anyone who can create a PVC could previously cause one to be spawned just by requesting RWX.
If you do not use RWX, no action is needed: the default is false, and the
gateway ServiceAccount, RBAC, PodMonitor, and export alert group are simply
not installed.
Should you upgrade without the flag and only then notice, setting
gateway.enabled: true and reconciling is enough: RWX rejection is
FailedPrecondition, so a pending PVC provisions on the external
provisioner’s next retry. No PVC recreation is needed.
See ReadWriteMany (RWX) for what enabling it entails.