v0.16.0
Breaking
- Raise
disk_sizeminimum from 5 GiB to 20 GiB on the root disk — the additional-disk floor stays at 5 GiB, but the primary / OS disk now fails validation below 20. Rationale lives indocs/sizing.md#why-the-root-disk-has-a-20-gib-minimum: a Talos CP node pulls kube-apiserver + kube-controller-manager + kube-scheduler + etcd + kube-proxy + CNI + CoreDNS during bootstrap, plus the Talos squashfs image and kubelet's 10% GC headroom. A 5–10 GiB root disk fills up mid-install, the kubelet evicts images mid-pull, and etcd never comes up — observed on the5 GiBdefault path before this change. NewMinRootDiskSizeGiBconst ininternal/provisioner/data.go, validator message cites the bootstrap reason,schema.jsonupdated to"minimum": 20with matching description. Migration: any MachineClass currently specifyingdisk_size< 20 will fail validation on next apply — edit the value to ≥ 20 (default40recommended for production) before provisioning. Existing VMs built against an older class are not retroactively resized; reprovision against the updated class if you hit DiskPressure.
Fixes
-
Auto-configure every additional NIC (DHCP, static addresses, gateway) —
stepCreateVMnow emits anic-interfacesConfigPatchRequest alongside the existingnic-mtupatch wheneveradditional_nicsare declared on the MachineClass. The patch writesmachine.network.interfaces[]entries keyed bydeviceSelector.hardwareAddrwithdhcp,addresses, and per-interface default-routeroutesderived from the newAdditionalNICfields. Talos's default platform config (nocloud, metal, …) only DHCPs the primary link, so before this fix additional NICs came up at the link layer but never acquired an IPv4 address — VMs were effectively single-homed despite the hypervisor attaching the extra vNIC correctly. Observed ontalos-homeworkers running v0.15.5:talosctl get linksshowed botheth0andeth1UP withlinkState: true, buttalosctl get addressesshowedeth1with onlyfe80::/64and no IPv4. MAC-based matching (not interface-name matching) is used because Talos's interface enumeration can shift between boots while the deterministic MACs the provider assigns survive reprovision. Orthogonal toadvertised_subnets— that pins kubelet/etcd to a specific subnet but does not bring additional links up. Worker-safe: nocluster.*section, so no risk of the v0.15.0–v0.15.3 etcd-on-worker validation bug returning.New
AdditionalNICfields (all optional, backward-compatible):dhcp(*bool) — tri-state. Unset → defaulttruewhen no static addresses,falsewhenaddressesis set. Explicittrueorfalsealways wins, so "DHCP + static alias on the same link" and "attach the NIC but disable all autoconfig" are both expressible.addresses([]string) — static IPv4/IPv6 addresses in CIDR form. Validated withnet.ParseCIDRat config-load time; bad entries are rejected with a field-indexed error before the VM is touched.gateway(string) — optional default-route IP. Only meaningful alongsideaddresses— a gateway without addresses is rejected as a config mistake (DHCP supplies its own gateway; a static-only route with no link address can't be installed).
New
buildAdditionalNICInterfacesPatchbuilder +resolveNICDHCPpolicy helper. 20 new regression tests: 4 for the DHCP-default resolver (nil→true/false, explicit true/false), 11 for patch shape (DHCP on/off, static address, static+gateway, DHCP+static coexistence, multi-NIC mixed, empty list→nil, empty-MAC skip, all-empty→nil, JSON structure pin, no-cluster-section pin), 7 forValidate(valid CIDR, invalid CIDR, junk, valid gateway, invalid gateway IP, gateway-without-addresses rejected, DHCP-false-with-no-addresses allowed). Schema drift test guards the three new fields incmd/omni-infra-provider-truenas/data/schema.jsonagainst silent removal.
Hardening (parallel-review follow-up, same session)
- Tighter address/gateway validation.
Data.Validatenow rejects interface addresses that are unspecified (0.0.0.0/0,::/0), multicast, loopback, or zero-length-mask; rejects gateways that are unspecified, multicast, loopback, or IPv4 broadcast; enforces IPv4/IPv6 family match between gateway and at least one address; enforces that the gateway is on-link with at least one of the configured CIDRs; and rejects MachineClasses declaring a gateway on more than one additional NIC (non-deterministic default-route ambiguity). Closes the "malicious-operator-of-a-MachineClass" footguns where a bad value would either fail at Talos apply-time or silently steer worker traffic. - Operator-input DoS caps.
MaxAdditionalNICs = 16andMaxAddressesPerNIC = 16enforced inValidate()and asmaxItemsinschema.json. Prevents a misconfigured MachineClass with 10k entries from serializing a multi-MB ConfigPatchRequest that Omni stores + every reconcile re-fetches. - Schema-side regex for fast-fail.
addressesitems gainpattern: ^[0-9a-fA-F:.]+/[0-9]{1,3}$andgatewaygains^[0-9a-fA-F:.]+$inschema.json— catches "forgot the/24" typos at MachineClass apply time (Omni-side JSON-schema gate) rather than deferring the failure to provision time. - Route network picked by gateway family.
buildAdditionalNICInterfacesPatchemitsnetwork: "::/0"when the gateway is IPv6,"0.0.0.0/0"when IPv4 — previously always IPv4 regardless of family, which Talos rejects at apply for IPv6 gateways. - Duplicate-MAC reject in patch builder.
buildAdditionalNICInterfacesPatchreturns an error on duplicatedeviceSelector.hardwareAddr— defense-in-depth against upstream MAC-collision-resolution bugs that would otherwise produce last-write-wins ambiguity in Talos. disk_sizefloor coverage.data_test.gopinsMinRootDiskSizeGiB = 20: 19 rejected, 20 accepted, 5 rejected. Prevents a future refactor from silently restoring the undersized floor that caused control-plane DiskPressure GC loops during bootstrap.
Observability (parallel-review follow-up, same session)
config_invalidandconfig_patcherror-category buckets.categorizeErrornow routes MachineClass validation failures (wrapped via"invalid MachineClass config: %w") toconfig_invalidand everyCreateConfigPatchfailure toconfig_patch, so dashboards and alert routing can distinguish operator typos from hypervisor regressions and pinpoint which patch kind is failing. Previously both classes aliased intonic_invalid/unknown.truenas.config_patch.durationhistogram. NewFloat64Histogramininternal/telemetry/metrics.gowith apatch_kindlabel covers all five patch-emission RPCs (data-volumes,longhorn-ops,nic-mtu,nic-interfaces,advertised-subnets). Buckets mirrorAPICallDuration. Wraps every call via the newapplyConfigPatchhelper so timing is recorded on success and failure alike.- Warn log on empty-MAC NIC skip. When
AddNICWithConfigsucceeds but TrueNAS returns no MAC attribute, the NIC is silently skipped from the patch — with this fix, a Warn log now fires (not just Debug) so SRE can correlate when a multi-homed VM comes up with fewer IPs than the operator declared. - Aggregate breakdown in
applied additional-NIC interfaces config patchInfo log. Carriesdhcp_nics,static_nics,gateway_nicscounts. SRE can verify "is the static-address codepath actually firing?" during a rollout without enabling Debug everywhere.
Refactor (parallel-review follow-up, no behavior change)
collectNICInterfaceConfigspure helper. Extracts the per-NIC config+aggregate accumulation out ofstepCreateVM's attach loop so it's unit-testable without a liveprovision.Context, TrueNAS client, or VM. Panics onlen(nics) != len(attachedMACs)(caller bug, not recoverable).resolveNICDHCPnow called once per NIC (was twice — hoist).applyConfigPatch(ctx, pctx, kind, requestID, data)helper. CentralizespatchName()+CreateConfigPatch+ timing metric for all five patch kinds. AST-level static check updated:collectPatchNameKindsnow recognizes bothpatchName(<kind>, ...)andapplyConfigPatch(ctx, pctx, <kind>, ...)so the wiring registry stays accurate across the refactor.boolPtrtest helper centralized. Moved fromconfig_patch_test.gointotesthelpers_test.goso new*_test.gofiles in the package don't duplicate it.
Tests (parallel-review follow-up)
- 23 new tests across
config_patch_test.go,multinic_test.go,data_test.go,schema_drift_test.go,error_categorization_test.go: caller-seam wiring forcollectNICInterfaceConfigs(5), extended validation (unspecified / multicast / loopback addresses, gateway non-unicast, family mismatch, not-on-link, multiple gateways, max caps),config_invalid/config_patcherror categorization, schema-drift type pins,MinRootDiskSizeGiBfloor pin. - Patch-kind registry entry for
nic-interfaces.TestStepCreateVM_WiresAllExpectedPatchesnow asserts the kind appears in a non-test source file, so a refactor that deletes the emission site (silently reverting the v0.15.5 fix) fails CI.
Documentation (parallel-review follow-up)
- Talos round-trip gap documented.
docs/testing.mdnow carries a "Known test-coverage gaps" section explaining that Go-side unit tests pin the provider's understanding of Talos config shape but don't round-trip throughconfig.NewFromBytes— so the v0.15.0 etcd-on-worker regression class can recur silently. Lists the two ways to close the gap (test-only dep onmachinery/config, or-tags e2ecassette againsttalosctl apply).
Experimental
- Autoscaler Helm chart + operator docs (phase 4 of 4) —
deploy/helm/omni-autoscaler/ships a two-container chart (autoscaler subcommand + upstreamcluster-autoscalersidecar) with full values surface for Omni + TrueNAS credentials, per-cluster opt-in, experimental labels (bearbinary.com/experimental=true), and Recreate rollout strategy so no two autoscaler replicas are ever alive simultaneously. Chart renders cleanly againsthelm lint/helm template.docs/autoscaler.mdis the operator guide: full annotation reference, deploy recipe, RBAC notes, observability, how to disable, and the known-limitations list (no scale-down, no scale-from-zero, no host-mem check until thesystem.mem_infowrapper lands). Feature is end-to-end deployable but still experimental — the combination of per-MachineClass opt-in, hard-gate-by-default capacity check, scale-down-disabled-at-two-layers, and single-replica rollout gives operators four independent layers to disable if something goes wrong. - Autoscaler write path wired (phase 3d of 4) —
internal/autoscaler/writer.goimplementsScaleWriter.IncreaseMachineCountviasafe.StateUpdateWithConflicts[*omni.MachineSet]with a live re-check of Max inside the mutator so a staleCurrentSizein the caller can't bypass the bound even if Omni's OCC would have let the write land.Server.NodeGroupIncreaseSizeis the first mutating RPC: validates input (delta > 0, non-empty id) → re-runs Discover for a fresh current-size read → runs the capacity gate when one is wired → invokes the writer → logs the structured scale event. Errors map to the specific gRPC status codes cluster-autoscaler uses for scheduling decisions:ResourceExhaustedon capacity breach or Max violation (CAS stops retrying),InvalidArgumenton bad delta (no retry),NotFoundon unknown group (prune from CAS cache),Unavailableon transient state errors (retry later). NewAnnotationAutoscalePool+Config.Poolfield let operators target a specific TrueNAS pool for capacity checks; falls back toServer.WithDefaultPool(…)(set fromDEFAULT_POOLin the subcommand wiring). Subcommand now builds the full production dependency tree: Omni client, TrueNAS client (optional — absent means "capacity gate disabled" with a warn log, useful for dry-run deploys), Discoverer, ScaleWriter, and passes all intoNewServer.TestServer_NodeGroupIncreaseSize_*covers happy path + the reject-with-status matrix (invalid delta, above max, unknown group);TestScaleWriter_*covers the write semantics including the live-recheck race guard. Correctness does not depend on a singleton lease — Omni's optimistic concurrency rejects stale writes on its own — but the chart still pinsreplicas: 1to avoid wasted API calls. - Autoscaler subcommand skeleton (phase 1 of 4) —
omni-infra-provider-truenas autoscaleris the new experimental entry point for a Kubernetes cluster-autoscaler external-gRPC cloud provider, vendored from Justin Rothgar'somni-node-autoscalerPoC. Opt-in per MachineClass viabearbinary.com/autoscale-min/bearbinary.com/autoscale-maxannotations on OmniMachineClass.omni.sidero.devresources; a class without the annotations is not discovered. Optionalbearbinary.com/autoscale-capacity-gate(hardorsoft) controls whether TrueNAS pool/host-memory pressure blocks scale-up. Phase 1 scope is intentionally narrow: env-var config (OMNI_CLUSTER_NAME,AUTOSCALER_LISTEN_ADDRESS,AUTOSCALER_REFRESH_INTERVAL), annotation parser with full table-driven coverage, experimental startup banner, and a hold-open loop. No gRPC server and no Omni writes yet — those land in phases 2–3. The provisioner subcommand (no argv) is unchanged; existing Deployments bumping image tags see zero behavior drift. - Autoscaler read-side gRPC handlers wired (phase 3c of 4) —
Servernow takes a*Discovererand uses it to answerNodeGroups(returns the discovered[]NodeGrouptranslated into proto form —id/minSize/maxSize/debug) andNodeGroupTargetSize(returns currentMachineAllocation.MachineCount).NodeGroupForNodereturns an explicit nil-NodeGroup "not ours" response through the experimental phase — scale-down is disabled at multiple layers, and the node→node-group mapping requires additional Omni state (MachineSetNode / ClusterMachine joins) that isn't worth the surface area while scale-down stays off. When the Server is booted without a Discoverer (early-phase testing, partial boot), handlers return Unimplemented with an operator-readable "discoverer missing" message rather than silently returning an empty list — silent-empty is indistinguishable from "cluster has no opted-in MachineSets" which is a legitimate steady state. 6 newTestServer_*cases cover the happy path (NodeGroups returns minSize/maxSize/debug), empty-cluster non-error, TargetSize found + NotFound, and the two configured/unconfigured NodeGroupForNode paths. Write handlers (NodeGroupIncreaseSize) still return Unimplemented; phase 3d enables them behind the singleton lease. - Autoscaler MachineSet discovery (phase 3b of 4) —
internal/autoscaler/discovery.goresolves one Omni cluster's autoscaler-managed node groups from a COSI state source.Discoverer.DiscoverenumeratesMachineSetsviastate.WithLabelQuery(resource.LabelEqual(omni.LabelCluster, cluster)), skips control-plane MachineSets (no CP scaling support), rejectsUnlimitedallocations with a structured warning log, dereferences each worker'sMachineClassbyMachineAllocation.Name, parses thebearbinary.com/autoscale-*annotations viaParseMachineClassAutoscaleConfig, and returns a[]NodeGroupthe gRPC handlers will consume in phase 3c. Per-MachineSet failures (missing MachineClass, bad annotations, unsupported allocation type) log and skip the offending set — one misconfiguration never takes out scaling for the whole cluster.TestDiscover_*covers 9 scenarios against an inmem COSI state (the same pattern singleton tests use): empty cluster, other-cluster filtering, CP skip, non-opted-in skip, Unlimited reject, bad-annotation-skips-one, config propagation, missing-MachineClass skip, and out-of-bounds-still-included. The gRPC handlers still returnUnimplemented— phase 3c wires discovery intoNodeGroups/NodeGroupForNode/NodeGroupTargetSize; phase 3d enables the write path. - Autoscaler gRPC server scaffold (phase 3a of 4) —
internal/autoscaler/server.gowires the external-gRPC cluster-autoscaler cloud-provider contract via vendored protos underinternal/autoscaler/proto/externalgrpc/(Apache-2.0, from Kubernetes Autoscaler; seePROVENANCE.mdfor refresh workflow). Server boots cleanly, binds the listener, answers RPC calls, and returnscodes.Unimplementedon every handler with an operator-readable message naming the next phase. Graceful shutdown drains on ctx cancel.google.golang.org/grpcpromoted from indirect to direct ingo.mod.TestServer_*exercises the full listen → dial → RPC → shutdown lifecycle against ephemeral ports so CI can't collide with the default:8086or other tests. Every RPC handler is defined explicitly (rather than relying on the generatedUnimplementedCloudProviderServerdefault) so the list of capabilities the autoscaler must support is literal and searchable — phases 3b (MachineSet discovery) and 3d (Omni writes) slot in one handler at a time. - Autoscaler capacity gate (phase 2 of 4) —
internal/autoscaler/capacity.goimplements the TrueNAS-aware scale-up gate. Decision table:OutcomeAllowed(both thresholds pass or disabled),OutcomeDeniedHard(hard gate + threshold breached),OutcomeWarnedSoft(soft gate + threshold breached, still proceeds),OutcomeErrored(capacity query failed — fails closed). Pool-free-bytes check reads TrueNAS via the existingListPoolspath (matches UI-reported values, accounts for ZFS parity/metadata overhead). Host-free-memory check is interface-only for this phase:TrueNASCapacityAdapter.HostFreeMemoryBytesreturnsErrHostMemNotImplementeduntil a follow-up adds aninternal/clientwrapper forsystem.mem_info; operators who want to deploy now must setbearbinary.com/autoscale-min-host-mem-gib: "0"on annotated MachineClasses to disable the host-mem dimension until the wrapper lands.TestCheckCapacitycovers all 11 decision-table branches;TestTrueNASCapacityAdapter_*pins the adapter behavior against a mock client. Still no gRPC server and no Omni writes — phase 3 wires both together behind a singleton lease.
Full Changelog: v0.15.5...v0.16.0