0012. Mirroring the volume config document kinds for disk roles
Status: Accepted · Date: 2026-10-25 · Design section: 4.4 (Typed configuration), ### Nodes
Decided: Mirror VolumeConfig/UserVolumeConfig/RawVolumeConfig as three more mirrorgen roots, for disk roles
Context
Phase 3 (disk roles, ### Nodes in docs/design/architecture.md) assigns each disk a role:
system (install target plus EPHEMERAL), ephemeral (EPHEMERAL on its own disk), storage
(handed to a storage preset via a CSI provisioner), userVolume, or unused. system is
already representable — it is machine.install, a v1alpha1.Config field already on the
mirror allow-list. The other four roles are not: Talos expresses them as separate, modern
config document kinds — VolumeConfig, UserVolumeConfig, RawVolumeConfig — none of which
were on mirrorgen's allow-list. So, as the design already says, disk roles begin with generator
work, not controller work; this ADR is that first step.
Decision
Add three roots to internal/tools/mirrorgen/main.go's roots:
block.VolumeConfigV1Alpha1 (kind VolumeConfig), block.UserVolumeConfigV1Alpha1
(kind UserVolumeConfig), block.RawVolumeConfigV1Alpha1 (kind RawVolumeConfig), all from
github.com/siderolabs/talos/pkg/machinery/config/types/block. VolumeConfig covers ephemeral
(and, in principle, a non-default system disk layout); UserVolumeConfig covers userVolume;
RawVolumeConfig covers storage — its own doc comment says it is "intended to be used with CSI
provisioners," which is exactly what a storage preset is. unused needs no document at all: the
absence of an assignment is the representation.
VolumeConfig.MetaName (which system volume this document configures) mirrors as a plain,
unrestricted string — Talos's own type carries no enum here either, since VolumeConfig also
names volumes phase 2 never touches. Both STATE and EPHEMERAL are representable, the same
way, through the same field; TestRoundTripVolumeConfig exercises it with EPHEMERAL (the role
the design calls out), and nothing about the mechanism is specific to that name.
Walking these three surfaced the same two kinds of problem ADR 0003 already has tables for, plus one the generator didn't have a table for yet:
- Six more type-keyed substitutions, added to
substTable:cel.Expression(the disk selector's type — a compiled CEL AST, no exported fields, behindTextMarshaler, exactlyByteSize's shape from ADR 0003's original spike);block.Sizeandblock.ByteSize(private-field structs behindTextMarshaler, used forminSize/maxSize); and threeint-backed enums with aTextMarshalerof their own (block.VolumeType,block.FilesystemType,block.EncryptionProviderType— all from the separateresources/blockpackage, notconfig/types/block, which happens to share its bare package name). The enums are a judgment call rather than a hard requirement: the generic path would mirror them as a bareintand still round-trip correctly, but the CRD would then show a raw ordinal (type: 2) instead of the name Talos itself uses (type: xfs) — inconsistent withInstallDiskType, already mirrored as a string, and genuinely confusing in a user-facing field. - One more secret field, added to
secretFields:EncryptionKey.KeyStatic, a disk encryption passphrase embedded directly in the document. Talos's ownEncryptionSpec.Redactmethod targets this exact field for the same reason Ichor drops it. The wholestatickey source is dropped, not just its onepassphrasefield, because an emptystatic: {}left behind would be unusable and only confusing — there would be no way to say what the key actually is. KMS, TPM and node-ID key sources carry no secret (an endpoint URL, hardware-sealed, or derived) and stay. One table entry drops it from bothblock.EncryptionKeyand the older, already-mirroredv1alpha1.EncryptionKey(legacy per-partition disk encryption), since both share the field nameKeyStaticand the table is keyed by bare name, not by package. - A genuine name collision, the first the generator has hit:
block.EncryptionKey,EncryptionKeyKMS,EncryptionKeyTPMandEncryptionKeyNodeIDall share a bare name with an existing, unrelated mirror struct generated from the legacyv1alpha1.EncryptionKeyfamily (older, per-partition disk encryption, already mirrored viav1alpha1.Config). Mirror struct names are one flat Go namespace (zz_generated.mirror.go), so this is a real conflict, not a type-keyed or field-keyed question the existing tables could resolve. A new, small table,typeRenames(qualified machinery type name → mirror name), disambiguates it: the fourblock-sourced types take aVolumeprefix; the already-establishedv1alpha1names are left alone.structDefgained amachNamefield (distinct from the possibly-renamed mirrorname) so the generated conversion code still calls the real machinery type regardless of the rename.EncryptionKeyStaticitself never needed a rename: droppingEncryptionKey.KeyStaticmeans it is never reached in either package.
TalosConfigPatchDocument (api/v1alpha1/talosconfigpatch_types.go) gained one optional field
per new kind (volumeConfig, userVolumeConfig, rawVolumeConfig), three more names in the
kind enum, and the matching three terms in both CEL rules (exactly-one-populated,
kind-matches-field) — the same shape phase 2's three existing kinds already use.
What the fallback chain can and can't express
The disk selector (ProvisioningSpec.diskSelector.match) is the one field this whole phase
turns on, since it is how a volume document says which disk it means. It mirrors as a plain
string carrying an arbitrary CEL expression, exactly like every other substTable entry: Ichor
carries the text, Talos defines what is inside it. That matters here specifically because
architecture.md's fallback chain (WWID → serial → by-id path → bus path) is not a schema Ichor
has to model — it is just a choice of which fields on Talos's own disk.* CEL variable (a
block.DiskSpec protobuf: wwid, serial, symlinks for by-id-style udev links, bus_path,
transport, rotational, size, …) a given expression happens to reference, and the string
field carries any of them unchanged.
TestRoundTripVolumeConfig also round-trips a negative maxSize ("-10GiB"), confirming the
reserve-free-space meaning architecture.md documents survives the mirror, not just the common
positive case.
Nothing about the three new kinds turned out to be unrepresentable. The things phase 3 still can't do are a different kind of gap, not a mirroring one, and out of this change's scope on purpose:
- User volumes and EPHEMERAL sharing one disk (including what a negative
maxSizemeans in that combination) is still an open question —docs/design/architecture.md's Open questions, unchanged by this ADR. It needs a design decision, not a generator fix. - Actually applying a disk role to a node — the reconciler that turns a
TalosMachine's approved disk roles into these documents — isinternal/controller/, untouched here. - A changed selector silently doing nothing, and a lowered
maxSizebeing silently ignored (both documented in architecture.md, verified against Talos's volume manager) are behaviors of the running system, not of the CRD schema; detecting and reporting them is reconciler work.
Measured: served CRD size
Same methodology as ADR 0003's follow-up: a real envtest apiserver, a raw GET on the
CustomResourceDefinition object itself. talosconfigpatches was 81,965 bytes served (5.2% of
etcd's 1,572,864-byte --max-request-bytes ceiling) before this change. With the three volume
kinds added, it is 96,792 bytes served, 6.2% of the ceiling (210,130 bytes on disk). Headroom
remains in no way the constraint — the three new kinds added about 15 KB served between them,
and there is room for the rest of Talos's modern document kinds many times over.
Follow-up: pkg/fieldmeta rules for the three new kinds
Making the three kinds representable means a user can now write a path pkg/fieldmeta has no
rule for, which would silently fall through to its reboot-required default. The default being
safe does not make it correct: the dangerous direction is an install-time-only field left at
reboot-required, since a reboot that cannot possibly take effect gets re-detected as drift and
rebooted again -- a loop. The safe-but-wasteful direction is a live-safe field left at
reboot-required: an unneeded reboot, not a loop, since the reboot (if performed) still leaves the
node converged with the rendered config afterwards.
Verified, not guessed, and cited:
- Talos's own documented no-reboot allow-list (
docs.siderolabs.com, "Edit Machine Configuration" guide, v1.13) lists exact config paths (.machine.network,.machine.kubelet,.cluster, …) that a no-reboot apply accepts; it says plainly that anything outside that list makes a no-reboot apply "fail[] if the change contains any fields that can not be updated without a reboot." None ofVolumeConfig,UserVolumeConfigorRawVolumeConfigappear on it at all. That alone rules out marking any of their pathssafe. - Talos's own volume manager
(
internal/app/machined/pkg/controllers/block/volume_manager.go'sprocessVolumeConfig) runs a state machine per volume:Missing/Waiting→LocateAndProvision(consults the disk selector) →Located→Grow→Provisioned→HandleEncryption→Prepared→Format→Ready, whereReadydoes nothing further. The disk selector and the grow step are only ever reached on the way to a volume's firstReady; nothing re-enters that path afterwards. This directly confirms, for all three kinds (the state machine is shared, not system-volume-specific -- seeinternal/.../volumeconfig/user_volumes.go'sUserVolumeTransformer, which feeds the sameVolumeConfigresource type), the behavior architecture.md already documents forEPHEMERALspecifically: a selector change on an already-provisioned volume does nothing. internal/controller/talosmachine_controller.go(read, not modified — out of scope) was read to check the loop concern directly: itsswitchonimpacthandlesInstallTimeOnlybeforeRebootRequired, and that case returns throughr.ready(...)withConditionFalseand a "needs a reset and reinstall" message — it never callsr.refuseReboot(the only path that actually reboots a node). Confirmed: an install-time-only classification cannot cause a reboot loop, here or for any existing rule; it is purely reported.
Classified, with the reasoning above: VolumeConfig.provisioning (disk selector, min/max
size, grow) is installTimeOnly — the same classification machine.install already has, for the
matching reason architecture.md gives: changing which disk a system volume lives on is a
reset-and-reinstall operation. This is the only new rule added.
Deliberately left at the reboot-required default, because it was not verified rather than because it was verified safe:
VolumeConfig.filesystem,.encryptionand.mount. The same state machine gatesFormatandHandleEncryptionthe same way it gates provisioning, which is suggestive, but changing encryption or filesystem settings is consequential enough that a suggestive read did not seem like enough to act on within this change.VolumeConfig.trimand.scrub. These are read by their own continuously-scheduled controllers (volume_trim.go,fs_scrub.go), outside the provisioning state machine entirely, which argues forsafe— but that is inference from a different controller's shape, not a statement this change found anywhere, so it stays at the default too.- Any
UserVolumeConfigorRawVolumeConfigpath. Architecture.md states that assigningstorageoruserVolumeto a previously unused disk does not need a reset, which rules outinstallTimeOnly; the no-reboot allow-list finding above rules outsafe. Reboot-required is each kind's correct classification by elimination, needing no rule at all —TestAssess_UserAndRawVolumeConfigStayRebootRequiredpins this so a future change does not "fix" it into something wrong.
Unsupported was considered for the maxSize-shrink case and rejected. Architecture.md records
that a lowered maxSize on an existing volume is silently ignored (partitions only ever grow) —
a real gap, but not one Impact.Unsupported can hold without changing its meaning:
Unsupported is specifically "needs a newer Talos version than the cluster's pinned contract"
(Assess's own doc comment, and every existing use of it), a per-path, version-gated question.
Whether a maxSize change is a no-op is a per-value question — the same shrink value is a no-op
on an existing volume and a perfectly normal initial size on a brand-new one — and Assess only
ever sees changed paths, never old/new values or whether the volume already exists. Overloading
Unsupported (or RebootRequired, or a new field) to mean "this specific value won't do what you
asked" would conflate two different failure modes behind one boolean, which is the kind of
silent-wrongness this project otherwise tries hard to avoid. The gap stays exactly where
architecture.md already put it: a controller-side detection problem ("Ichor has to detect that
itself and report 'accepted, no effect'"), explicitly out of this change's scope
(internal/controller/ untouched). Impact's three fields were sufficient for what this change
needed to express; no fourth field or new axis was added.
Consequences
- Disk roles have a typed, schema-validated representation for everything except the
system/install-target role, which already had one. The reconciler and CLI work phase 3 still needs can build ondocuments[]the same way phase 2's kinds already do. typeRenamesandmachNameare small, general additions (not special-cased to these three kinds), so the next genuine name collision — and there will eventually be one, since Talos's document kinds are not designed with a single flat Go namespace in mind — has a place to go without new generator machinery.secretFieldsnow has ten entries instead of nine, and the pattern (one entry, keyed by bare struct/field name, catches the same field across unrelated machinery types) held up on its second use.