# 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, behind `TextMarshaler`, exactly
  `ByteSize`'s shape from ADR 0003's original spike); `block.Size` and `block.ByteSize`
  (private-field structs behind `TextMarshaler`, used for `minSize`/`maxSize`); and three
  `int`-backed enums with a `TextMarshaler` of their own (`block.VolumeType`,
  `block.FilesystemType`, `block.EncryptionProviderType` — all from the *separate*
  `resources/block` package, not `config/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 bare `int` and 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 with
  `InstallDiskType`, 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 own `EncryptionSpec.Redact`
  method targets this exact field for the same reason Ichor drops it. The *whole* `static` key
  source is dropped, not just its one `passphrase` field, because an empty `static: {}` 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 both `block.EncryptionKey` and the older,
  already-mirrored `v1alpha1.EncryptionKey` (legacy per-partition disk encryption), since both
  share the field name `KeyStatic` and the table is keyed by bare name, not by package.
- **A genuine name collision**, the first the generator has hit: `block.EncryptionKey`,
  `EncryptionKeyKMS`, `EncryptionKeyTPM` and `EncryptionKeyNodeID` all share a bare name with an
  existing, unrelated mirror struct generated from the legacy `v1alpha1.EncryptionKey` family
  (older, per-partition disk encryption, already mirrored via `v1alpha1.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 four
  `block`-sourced types take a `Volume` prefix; the already-established `v1alpha1` names are left
  alone. `structDef` gained a `machName` field (distinct from the possibly-renamed mirror `name`)
  so the generated conversion code still calls the real machinery type regardless of the rename.
  `EncryptionKeyStatic` itself never needed a rename: dropping `EncryptionKey.KeyStatic` means 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 `maxSize` means 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 — is `internal/controller/`, untouched here.
- **A changed selector silently doing nothing**, and a **lowered `maxSize` being 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 of `VolumeConfig`, `UserVolumeConfig` or `RawVolumeConfig` appear on it
  at all. That alone rules out marking any of their paths `safe`.
- Talos's own volume manager
  (`internal/app/machined/pkg/controllers/block/volume_manager.go`'s `processVolumeConfig`) runs
  a state machine per volume: `Missing`/`Waiting` → `LocateAndProvision` (consults the disk
  selector) → `Located` → `Grow` → `Provisioned` → `HandleEncryption` → `Prepared` → `Format` →
  `Ready`, where `Ready` does nothing further. The disk selector and the grow step are only ever
  reached on the way to a volume's *first* `Ready`; nothing re-enters that path afterwards. This
  directly confirms, for all three kinds (the state machine is shared, not system-volume-specific
  -- see `internal/.../volumeconfig/user_volumes.go`'s `UserVolumeTransformer`, which feeds the
  same `VolumeConfig` resource type), the behavior architecture.md already documents for
  `EPHEMERAL` specifically: 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: its `switch` on `impact` handles `InstallTimeOnly` *before*
  `RebootRequired`, and that case returns through `r.ready(...)` with `ConditionFalse` and a
  "needs a reset and reinstall" message — it never calls `r.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`, `.encryption` and `.mount`. The same state machine gates `Format` and
  `HandleEncryption` the 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.trim` and `.scrub`. These are read by their own continuously-scheduled
  controllers (`volume_trim.go`, `fs_scrub.go`), outside the provisioning state machine entirely,
  which argues for `safe` — 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 `UserVolumeConfig` or `RawVolumeConfig` path. Architecture.md states that assigning
  `storage` or `userVolume` to a previously unused disk does *not* need a reset, which rules out
  `installTimeOnly`; the no-reboot allow-list finding above rules out `safe`. Reboot-required is
  each kind's correct classification by elimination, needing no rule at all —
  `TestAssess_UserAndRawVolumeConfigStayRebootRequired` pins 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 on `documents[]` the same way phase 2's kinds already do.
- `typeRenames` and `machName` are 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.
- `secretFields` now 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.
