config  /  machine configuration pkg/machinery/config
Chapter 03

Machine Configuration

There is no SSH, no /etc to edit by hand, no imperative provisioning step. A Talos node is its machine config — a single declarative document set that names the machine type, the install disk, every certificate and secret, the network, the Kubernetes parameters, and the storage layout. This chapter reads the config subsystem from the source: the monolithic v1alpha1 schema, the multi-document model replacing it, the kind-keyed registry that decodes both, the unified Provider interface controllers program against, and the lifecycle that turns a chunk of YAML into a live COSI resource.

#The config is the contract

Everything you can tell a Talos machine, you tell it through one declarative document set. Where a conventional distro spreads its truth across /etc, cloud-init snippets, systemd units and a shell history, Talos collapses all of that into a single config that is supplied at provisioning time — cloud user-data, an ISO, or a maintenance-mode talosctl apply-config — and thereafter lives inside the OS as a resource the COSI controllers reconcile against. The machine has no other source of intent. That is what makes the OS immutable and auditable: there is exactly one place where desired state is written down, and it is typed.

The subsystem is mid-migration between two representations of that document set, and a real config almost always uses both at once:

🧱

Monolithic v1alpha1

One large YAML document with top-level machine: and cluster: keys, decoded into v1alpha1.Config in pkg/machinery/config/types/v1alpha1/. The legacy schema — still the bulk of a typical config, but closed to new fields by policy.

📄

Multi-document config

Additional YAML documents, each with its own apiVersion/kind, separated by ---. Each is a small focused type — TrustedRootsConfig, KmsgLogConfig, UserVolumeConfig, SideroLinkConfig. New functionality goes here, never into v1alpha1.

Both forms coexist in one YAML stream. A Container (pkg/machinery/config/container/) wraps the at-most-one v1alpha1.Config plus an ordered list of typed documents and presents the whole thing through a single config.Provider. Here is what that stream actually looks like — a v1alpha1 body, then a dedicated user-volume document after the ---:

controlplane.yamlyaml
version: v1alpha1          # no apiVersion/kind ⇒ this IS the v1alpha1 doc
machine:
  type: controlplane
  install:
    disk: /dev/sda
cluster:
  controlPlane:
    endpoint: https://10.0.0.1:6443
---
apiVersion: v1alpha1        # a dedicated, focused document
kind: UserVolumeConfig
name: ceph-data
provisioning:
  diskSelector:
    match: disk.transport == "nvme"
  minSize: 100GB
The key tell.

A document with machine:/cluster: and no kind is v1alpha1 — internally its kind is the empty string. Every other document carries an explicit apiVersion + kind. That single rule is what lets one decoder handle both the old monolith and the new focused docs in the same stream.

#The document model & registry

Every focused document satisfies the small config.Document interface — clone yourself, name your kind, name your apiVersion. That is the entire contract a new config type must meet to enter the system:

pkg/machinery/config/config/document.gogo
type Document interface {
	Clone() Document
	Kind() string
	APIVersion() string
}

Most of that interface is satisfied for free by embedding meta.Meta, a two-field struct that carries the YAML apiVersion/kind and implements the accessors. A new document type embeds it, adds its own fields, and only has to write Clone():

pkg/machinery/config/types/meta/meta.gogo
type Meta struct {
	MetaAPIVersion string `yaml:"apiVersion,omitempty"`
	MetaKind       string `yaml:"kind"`
}

func (m Meta) Kind() string       { return m.MetaKind }
func (m Meta) APIVersion() string { return m.MetaAPIVersion }

Refinements of the base interface let a document opt into extra behavior: NamedDocument adds Name() (so you can have many UserVolumeConfig docs distinguished by name), ConflictingDocument declares ConflictsWithKinds(), and SecretDocument implements Redact() so secrets can be scrubbed from a dumped config.

Decoding does not switch on a giant hard-coded list of kinds. Instead every type package registers itself at init() time into a global, kind-keyed registry — a map[string]NewDocumentFunc mapping a kind string to a constructor:

pkg/machinery/config/types/security/trusted_roots.gogo
const TrustedRootsConfig = "TrustedRootsConfig"

func init() {
	registry.Register(TrustedRootsConfig, func(version string) config.Document {
		switch version {
		case "v1alpha1":
			return &TrustedRootsConfigV1Alpha1{}
		default:
			return nil
		}
	})
}
Registration panics — it does not error.

registry.Register panics on a duplicate kind. Kinds are globally unique by construction, and a collision is a programmer error caught the instant the binary starts, not a runtime surprise. The catch-all blank import in pkg/machinery/config/types/types.go pulls in every type package precisely so all those init()s run and the registry is fully populated before any decode happens.

#Decoding the YAML stream

The loader (pkg/machinery/config/configloader/...decoder/decoder.go) splits the stream on --- and, for each document, peeks at its kind and apiVersion to choose a target struct. The dispatch is a small switch whose first arm encodes the "empty kind is v1alpha1" rule:

pkg/machinery/config/configloader/.../decoder.gogo
switch {
case version == "v1alpha1" && kind == "":
	target, err = registry.New("v1alpha1", "")
case kind == "":
	err = ErrMissingKind
case version == "":
	err = ErrMissingAPIVersion
default:
	target, err = registry.New(kind, version)
}

Once a target type is chosen, decoding is deliberately strict: the YAML decoder runs with dec.KnownFields(true) and a post-decode CheckUnknownKeys pass, so a single mistyped field name is a hard failure rather than a silently-ignored key. Duplicate (apiVersion, kind, name) tuples are rejected outright. And because untrusted YAML can drive a decoder into surprising places, the whole parse runs inside a recover() — a malformed document returns an error instead of crashing the process that is trying to boot.

one YAML stream · split on --- v1alpha1 doc machine: / cluster: kind: "" (empty) UserVolumeConfig kind + name: ceph-data SideroLinkConfig kind: SideroLinkConfig registry kind → NewDoc KnownFields(true) no dup tuples recover() Container ≤1 v1alpha1.Config []Document ordered dedicated wins Provider one unified interface Machine() Volumes()… read by controllers controllers never see the YAML — only the Provider COSI controllers
Three documents in one stream → matched to registered types → composed into a Container → exposed as a single Provider. Controllers read the Provider, never the YAML.

#The Config / Provider interface

Controllers never touch YAML and never reach into the document list directly. They program against config.Config (pkg/machinery/config/config/config.go) — a deliberately wide interface (it carries a //nolint:interfacebloat for exactly this reason). It exposes the legacy accessors Machine(), Cluster(), Debug() alongside dozens of multi-doc accessors grouped by domain: network (SideroLink(), EthernetConfigs()…), Kubernetes (K8sAPIServerConfig()…), block storage (Volumes(), UserVolumeConfigs()…), CRI (RegistryMirrorConfigs()…). One interface, every domain, so a controller asks "what do you want?" without knowing whether the answer came from v1alpha1 or a dedicated document.

Provider (pkg/machinery/config/provider.go) is Config plus the Container capabilities (encode, validate, Documents(), RawV1Alpha1()) plus mutation helpers Clone(), PatchV1Alpha1(...), RedactSecrets(...), and CompleteForBoot(). The multi-doc accessors share a generic helper that pulls every document of a given interface type out of the list and wraps them:

pkg/machinery/config/container/container.gogo
func (container *Container) TrustedRoots() config.TrustedRootsConfig {
	return config.WrapTrustedRootsConfig(
		findMatchingDocs[config.TrustedRootsConfig](container.documents)...,
	)
}
Precedence is explicit, not magic.

Where old and new overlap — e.g. SysctlConfig() exists both as a v1alpha1 field and as a dedicated document — the container orders v1alpha1 first and dedicated docs after, so the dedicated document wins on a key conflict. This is ordered last-writer-wins over a list, not a deep-merge heuristic. And because the container holds at most one v1alpha1, Machine() returns nil when there is no v1alpha1 doc at all — controllers must nil-check.

One field every consumer reads is the machine type. It is a small enum, and the empty/zero value is TypeUnknown, so a forgotten type does not silently masquerade as a worker:

pkg/machinery/config/machine/machine.gogo
const (
	TypeUnknown      Type = iota // unknown
	TypeInit                     // init
	TypeControlPlane             // controlplane
	TypeWorker                   // worker
)

func (t Type) IsControlPlane() bool { return t == TypeControlPlane || t == TypeInit }

#Validation

Validation is uniform: every entry point returns (warnings []string, err error). A document may optionally implement config.Validator with Validate(validation.RuntimeMode, ...validation.Option). The container orchestrates the whole thing (container/validate.go): validate the v1alpha1 doc first, then each dedicated document, then cross-document conflict checks via V1Alpha1ConflictValidator, then a whole-config validateContainer(mode) pass.

Two axes shape what actually runs. The RuntimeModeRequiresInstall(), InContainer() — gates checks that only make sense for a real install. And there are two distinct worlds: ValidateAsClient runs offline (this is talosctl validate, no runtime state) while ValidateAtRuntime runs inside Talos with a context and live COSI state, so the two can legitimately report different errors. When Strict is requested, warnings are promoted to hard errors so a CI gate fails on anything questionable:

pkg/machinery/config/types/v1alpha1/v1alpha1_validation.gogo
if opts.Strict {
	for _, w := range warnings {
		result = multierror.Append(result, fmt.Errorf("warning: %s", w))
	}
	warnings = nil
}

#The config lifecycle

Booting Talos has to find a config before any of the above matters. That job belongs to config.AcquireController (internal/app/machined/pkg/controllers/config/acquire.go) — a state machine that tries config sources in order and tags each used source so the rest of the system knows where intent came from:

  1. stateDisk. A config previously persisted to the STATE partition (source tag "state"). Mounting STATE uses an embedded blockautomaton.VolumeMounterAutomaton.
  2. stateEmbedded. A config baked directly into the image.
  3. statePlatform. Platform / cloud user-data, fetched through the PlatformConfigurator (EC2 user-data, an ISO, a metadata service…).
  4. stateCmdline. A config pointed to by the talos.config= kernel argument.
  5. stateMaintenance. If nothing is found, enter maintenance mode: bring up the limited maintenance API and wait for talosctl apply-config (source tag "maintenance").

Whichever source wins, the config is validated and then pushed through a Setter (SetConfig / SetPersistedConfig). That is the moment a pile of YAML becomes a first-class object in the runtime — SetConfig hands off to v1alpha2.State.SetConfig, which wraps the provider in a COSI resource:

internal/app/machined/pkg/runtime/v1alpha2/v1alpha2_state.gogo
func (s *State) SetConfig(ctx context.Context, id string, cfg talosconfig.Provider) error {
	cfgResource := config.NewMachineConfigWithID(cfg, id)
	// ...store / update the MachineConfig resource in COSI state
}

The resulting resource (pkg/machinery/resources/config/machine_config.go) is of type MachineConfigType, marked meta.Sensitive because it carries secrets, and it exists under two well-known IDs. This split is load-bearing: a staged or --mode=try apply writes to Persistent ahead of Active, so the two can legitimately diverge.

pkg/machinery/resources/config/machine_config.gogo
const ActiveID     = resource.ID("v1alpha1")    // applied to the running OS
const PersistentID = resource.ID("persistent")  // saved to disk

From there, controllers reach the config the COSI way: they list MachineConfigType at ActiveID and read it through safe.ReaderGetByID, then call .Config() / .Provider() to get the unified interface from above:

a typical config consumergo
cfg, err := safe.ReaderGetByID[*config.MachineConfig](ctx, r, config.ActiveID)
if err != nil { /* nil-check: maybe no config yet */ }
machineType := cfg.Config().Machine().Type()

Two controllers close the loop. MachineTypeController (.../config/machine_type.go) reads the active config and emits a derived MachineType resource that the rest of the graph keys off. PersistenceController (.../config/persistence.go) watches the PersistentID resource and writes it back to the STATE partition — which is exactly what stateDisk reads on the next boot, closing the acquire → persist → re-acquire cycle.

AcquireController · try in order stateDiskSTATE partition · "state" stateEmbeddedbaked into image statePlatformcloud user-data stateCmdlinetalos.config= stateMaintenanceapply-config · "maintenance" first hit wins validate+ Setter MachineConfig COSI · Sensitive ActiveID = v1alpha1 PersistentID = persistent controllersReaderGetByID(Active) MachineTypeController PersistenceControllerwatch PersistentID writes config back to STATE → feeds stateDisk next boot
Acquire tries sources in order; the first hit is validated and set as the MachineConfig resource. Controllers read ActiveID; PersistenceController writes PersistentID to STATE, closing the loop.

That is the whole arc: a declarative document set is acquired from whichever source wins, decoded against a registry, composed into a Container, validated, and published as a sensitive COSI resource under two IDs — at which point it stops being a file and becomes the live, reconciled intent every controller reads. The most consequential thing that intent describes is storage: which disk to install on, which volumes to provision, how the partitions are laid out. In the next chapter we follow those install.disk and UserVolumeConfig declarations into the block subsystem, where the config's disks become real.